From 631458e5bec89e5d635f8d71c945bd64daba2f07 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 05:02:12 -0500 Subject: [PATCH 1/8] feat(api): report-only TLS key-exchange groups posture field; correct stale "pinned" doc claims (BACKLOG #338) The engine's TLS key-exchange (KEX) groups are INHERITED from OpenSSL's default group list, not pinned to the approved set. harden_kex_groups pins nothing until SSLContext.set_groups lands in Python 3.15, so on every interpreter this project currently runs on the approved pin is inert. This is documentation accuracy plus observability -- it changes no live TLS behaviour (the TLS 1.2+ floor is the enforced control), and on a NOT-DEPLOYED beta there is no exposure today; the pin is a future 3.15 hardening. Two parts: 1. Report-only surfacing. New pure helper config/tls_policy.kex_groups_report() builds a throwaway probe context and asks the ONE authority, harden_kex_groups, what it manages to pin -- so the read-out can never drift from what the connectors actually do. It returns "inherited (...)" on a pre-3.15 interpreter and "pinned: ..." on 3.15+. Surfaced as an additive SecurityPosture.kex_groups field (str | None, default None), wired in create_app beside fips_attestation(), rendered as a status-page row in the web console beside the FIPS/OpenSSL rows. Report-only: it reflects, and changes, no TLS behaviour. 2. Three doc-accuracy edits correcting restatements that still read as "pinned": CONTAINER-EXPOSURE-EVALUATION.md (verification table), ASVS-L2-PHASE0-CHANGES.md (PQC roadmap row), and #200's Closes line in docs/archive/backlog/BACKLOG-CLOSED.md (11.6.2 annotated PARTIAL). Each links to PHI.md's data-in-transit section, the single source of record for the measured accepted set, rather than restating it. The two Python-3.15 tripwire tests in test_tls_policy.py that fire when set_groups/get_groups land are left untouched -- they are the signal to actually set the pin. Engine UI seam bumped 17 -> 18: the golden seam snapshot introspects SecurityPosture's field set, so a purely additive field trips the handshake; SUPPORTED_ENGINE_SEAMS and the golden snapshot updated to match. Tests: test_tls_policy.test_kex_groups_report_reports_inherited_today (the helper reports inherited + names the approved list, never "pinned:"); test_api_auth.test_security_posture_reports_kex_groups (the field flows through the MONITORING_READ-gated, audited posture route and matches the helper); a status-builder assertion that the console renders the row. All three falsified: blanking the helper reddens the two report tests (assert 'inherited' in ''); removing the console row reddens the status-builder test (assert 'key-exchange' in html). --- docs/ASVS-L2-PHASE0-CHANGES.md | 2 +- docs/CONTAINER-EXPOSURE-EVALUATION.md | 2 +- docs/archive/backlog/BACKLOG-CLOSED.md | 2 +- messagefoundry/api/_ui_seam.py | 8 +++++- messagefoundry/api/app.py | 10 +++++++- messagefoundry/api/models.py | 5 ++++ messagefoundry/config/tls_policy.py | 25 +++++++++++++++++++ messagefoundry_webconsole/__init__.py | 2 +- messagefoundry_webconsole/pages/monitoring.py | 5 ++++ .../tests/test_webui.py | 3 +++ tests/golden/webconsole_seam.snapshot | 4 +-- tests/test_api_auth.py | 22 ++++++++++++++++ tests/test_tls_policy.py | 19 ++++++++++++++ 13 files changed, 101 insertions(+), 8 deletions(-) diff --git a/docs/ASVS-L2-PHASE0-CHANGES.md b/docs/ASVS-L2-PHASE0-CHANGES.md index bd0f09e7..efc68247 100644 --- a/docs/ASVS-L2-PHASE0-CHANGES.md +++ b/docs/ASVS-L2-PHASE0-CHANGES.md @@ -251,7 +251,7 @@ maintainer wears every hat today; the label fixes accountability, not headcount) | Vault Transit at-rest (`vault_transit`) | Bulk crypto runs inside Vault, so a PQC data key is a **Transit key-type change in Vault** with no engine code change; existing `mfenc:v3` blobs re-key via `rotate-key` | Trigger: OpenBao/Vault Transit ships a PQC key type · **review 2027-01** | store-crypto maintainer | | Password hashing | argon2id `needs_rehash`-on-login upgrades params or the primitive transparently on next sign-in | Trigger: OWASP/NIST password-hash guidance change · **review 2027-01** | auth maintainer | | Hashing / signing chokepoints | SHA-256 (session-token storage, audit chain, integrity digests) → a longer digest / SHA-3 is a one-line primitive swap per chokepoint; the audit chain re-anchors from the swap point | Trigger: a SHA-2 deprecation signal · **review 2027-01** | store-crypto maintainer | -| Transport TLS → hybrid-KEM | Adopt X25519 + ML-KEM once stdlib `ssl` / platform OpenSSL ship it and add it to the pinned group/cipher policy (WP-L3-10); immaterial on the default loopback bind | Trigger: platform OpenSSL hybrid-KEM support · **track 2026-H2, review 2027-01** | transport/TLS maintainer | +| Transport TLS → hybrid-KEM | Adopt X25519 + ML-KEM once stdlib `ssl` / platform OpenSSL ship it and add it to the approved group/cipher policy (WP-L3-10); immaterial on the default loopback bind | Trigger: platform OpenSSL hybrid-KEM support · **track 2026-H2, review 2027-01** | transport/TLS maintainer | | WebAuthn COSE public keys (at rest) | Verification material via the `webauthn` library + a registered algorithm allow-list; a PQC COSE alg is an allow-list addition once authenticators + the library ship it | Trigger: FIDO2/WebAuthn PQC alg support in the `[webauthn]` extra · **review 2027-01** | auth/WebAuthn maintainer | | OIDC RP id-token verification | Cached IdP verifying keys behind the closed `SignatureAlgorithm` enum + the JWKS floor (`auth/oidc/jwks.py`); a PQC JOSE alg is an enum addition once IdPs issue it | Trigger: JOSE PQC signature standardization + IdP issuance · **review 2027-01** | auth/federation maintainer | | Per-message JWS (RS256/PS256/ES256) | `kid`-carried key rotation + the KeyProvider seam (`transports/signing.py`); a PQC JOSE signature alg is added additively per connection | Trigger: a JOSE PQC signature RFC + partner support · **review 2027-01** | transport/signing maintainer | diff --git a/docs/CONTAINER-EXPOSURE-EVALUATION.md b/docs/CONTAINER-EXPOSURE-EVALUATION.md index 591f248f..bd48e0df 100644 --- a/docs/CONTAINER-EXPOSURE-EVALUATION.md +++ b/docs/CONTAINER-EXPOSURE-EVALUATION.md @@ -47,7 +47,7 @@ operational notes. | Control | Where | Confirmed behavior | |---|---|---| -| API/WSS in-process TLS (WP-13a) | [`api/tls.py`](../messagefoundry/api/tls.py) `build_api_ssl_context`; [`config/settings.py`](../messagefoundry/config/settings.py) `ApiSettings.tls_*` | `PROTOCOL_TLS_SERVER`, `minimum_version` from `tls_min_version` (1.2/1.3 floor), `load_cert_chain(cert, key, password)`, optional ciphers, hardened KEX groups + strict X.509; opt-in mTLS via `tls_client_ca_file` → `CERT_REQUIRED`. Wired into the single `uvicorn.run(...)` via `ssl_context_factory` ([`__main__.py`](../messagefoundry/__main__.py) ~538-545). | +| API/WSS in-process TLS (WP-13a) | [`api/tls.py`](../messagefoundry/api/tls.py) `build_api_ssl_context`; [`config/settings.py`](../messagefoundry/config/settings.py) `ApiSettings.tls_*` | `PROTOCOL_TLS_SERVER`, `minimum_version` from `tls_min_version` (1.2/1.3 floor), `load_cert_chain(cert, key, password)`, optional ciphers, inherited KEX groups (approved-group pin inert until Python 3.15 - see [PHI.md](PHI.md) §4) + strict X.509; opt-in mTLS via `tls_client_ca_file` → `CERT_REQUIRED`. Wired into the single `uvicorn.run(...)` via `ssl_context_factory` ([`__main__.py`](../messagefoundry/__main__.py) ~538-545). | | API bind guard ("exposed" gate) | [`__main__.py`](../messagefoundry/__main__.py) ~419-451 | Non-loopback `[api].host` → **allow** if `tls_enabled`, **allow** if `tls_terminated_upstream` (+`trusted_proxies`), **warn** if `--allow-insecure-bind`, else **refuse (exit 2)**. Auth-disabled non-loopback is refused by a separate earlier gate **regardless of** `--allow-insecure-bind`. | | MFA-at-exposure gate | [`__main__.py`](../messagefoundry/__main__.py) ~462-481 | Non-loopback + `auth.enabled` + **not** `require_mfa`: **refuse** on a production PHI instance, **warn** on a non-production PHI instance, quiet on synthetic. Gates **local** Administrator accounts only (AD MFA delegated). | | MLLP-over-TLS (WP-13b) | [`transports/mllp.py`](../messagefoundry/transports/mllp.py) `_mllp_ssl_context`; `MLLP(...)` in [`config/wiring.py`](../messagefoundry/config/wiring.py) ~540-610 | Per-connection `tls=true`. Inbound presents `tls_cert_file`/`tls_key_file`; `tls_ca_file` opts into mTLS (`CERT_REQUIRED`). Outbound verifies the peer (`tls_verify=true` default; `false` refused unless `MEFOR_ALLOW_INSECURE_TLS`), optional client cert. `start_server(ssl=)` / `open_connection(ssl=, server_hostname=)`. TLS 1.2+. | diff --git a/docs/archive/backlog/BACKLOG-CLOSED.md b/docs/archive/backlog/BACKLOG-CLOSED.md index df7f8704..ad3dd55e 100644 --- a/docs/archive/backlog/BACKLOG-CLOSED.md +++ b/docs/archive/backlog/BACKLOG-CLOSED.md @@ -4290,7 +4290,7 @@ Two findings are worth surfacing here. **Posture B scores worse on Fails than Po **Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build. **Severity:** medium. -**Closes (ASVS 5.0 L3):** 4.2.1, 4.4.1, 11.6.2, 12.1.3, 12.2.2, 12.3.1, 12.3.3, 12.3.5 · *(class 3)* +**Closes (ASVS 5.0 L3):** 4.2.1, 4.4.1, 11.6.2 (PARTIAL - KEX-group pin inert until Python 3.15; see PHI.md §4), 12.1.3, 12.2.2, 12.3.1, 12.3.3, 12.3.5 · *(class 3)* **Scope:** Extend the existing exposed-gate pattern (which already refuses a non-loopback plaintext bind) to the remaining unencrypted and unauthenticated paths: the Posture-B proxy→engine cleartext `ws://` / `http://` hop, the `--allow-insecure-bind` escape, mTLS as an *identity* rather than a bare admission gate, KEX/cipher validation when TLS is proxy-terminated, and cert-authenticated (rather than IP-trusted) intra-service auth. diff --git a/messagefoundry/api/_ui_seam.py b/messagefoundry/api/_ui_seam.py index 5a86a599..c7d8978c 100644 --- a/messagefoundry/api/_ui_seam.py +++ b/messagefoundry/api/_ui_seam.py @@ -88,7 +88,13 @@ #: `app.state.auth` would register nothing in production while passing every test that constructs the #: app with `auth=` directly. Additive with defaults, and the defaults are the STRICT position — an #: older or partial caller gets the interstitial on every absolute destination, never none. -ENGINE_UI_SEAM: int = 17 +#: seam v18 (ASVS 11.6.2, #338): SecurityPosture gained the additive REPORT-ONLY `kex_groups` field — a +#: read-out of whether the approved TLS key-exchange groups are PINNED on built contexts or INHERITED +#: from OpenSSL's default group list (today always inherited: `SSLContext.set_groups` is a Python 3.15 +#: API). Report-only, reflects/changes NO live TLS behaviour; additive with a default, so an older +#: console simply ignores it. Bumped because the golden seam snapshot introspects SecurityPosture's +#: field set, so any added field trips the handshake even when it is purely additive. +ENGINE_UI_SEAM: int = 18 @dataclass(frozen=True, slots=True) diff --git a/messagefoundry/api/app.py b/messagefoundry/api/app.py index cc1a1833..b7fba40d 100644 --- a/messagefoundry/api/app.py +++ b/messagefoundry/api/app.py @@ -242,7 +242,11 @@ hop_posture_from_ai, security_loosenings, ) -from messagefoundry.config.tls_policy import fips_attestation, phi_read_hop_disposition +from messagefoundry.config.tls_policy import ( + fips_attestation, + kex_groups_report, + phi_read_hop_disposition, +) from messagefoundry.config.wiring import ( EnvRef, Registry, @@ -1535,6 +1539,9 @@ async def security_posture( # FIPS-provider attestation of the interpreter's ssl/_hashlib OpenSSL (report-only, #73 / ADR 0120): # metadata (a boolean + version string), never key material, never enforced. fips_mode, openssl_version = fips_attestation() + # TLS key-exchange groups read-out (report-only, #338). Pure helper over a throwaway probe + # context; reflects/changes NO live TLS behaviour, reports "inherited" until Python 3.15. + kex_groups = kex_groups_report() # Platform memory-encryption READ-OUT (report-only, ADR 0152 Phase 1). Pure platform read # (/proc/cpuinfo flags + guest device presence on Linux; all-None everywhere else), no engine # state, never raises. It reports what the HOST SAYS ABOUT ITSELF and therefore satisfies @@ -1576,6 +1583,7 @@ async def security_posture( synthetic_relaxation=synthetic_relaxation, fips_mode=fips_mode, # interpreter ssl/_hashlib OpenSSL FIPS-provider state; None=undeterminable openssl_version=openssl_version, # that OpenSSL's version string (public metadata) + kex_groups=kex_groups, # report-only: are the approved KEX groups pinned or inherited (#338)? # ADR 0152: a SELF-REPORT plus the operator's claim. Neither satisfies ASVS 11.7.1 at any # value — see the field comments on SecurityPosture. The disclaimer ships IN THE BODY # (memory_encryption_note), unconditionally: this endpoint is the designated evidence diff --git a/messagefoundry/api/models.py b/messagefoundry/api/models.py index 815c4931..79703658 100644 --- a/messagefoundry/api/models.py +++ b/messagefoundry/api/models.py @@ -969,6 +969,11 @@ class SecurityPosture(BaseModel): # cryptography-wheel OpenSSL that encrypts PHI at rest — so it is "reported", never "certified". fips_mode: bool | None = None openssl_version: str | None = None + # TLS key-exchange groups read-out (report-only, #338 / ASVS 11.6.2). A read of whether the approved + # KEX groups are PINNED on built contexts or INHERITED from OpenSSL's default group list — today + # always inherited, because ``SSLContext.set_groups`` is a Python 3.15 API. Report-only: it reflects, + # and changes, NO live TLS behaviour (the TLS 1.2+ floor is the enforced control; see docs/PHI.md §4). + kex_groups: str | None = None # Platform memory-encryption READ-OUT (report-only, ADR 0152 Phase 1 / ASVS 11.7.1) + the operator # declaration (Phase 2). Named "self_reported" on purpose: these are values the host OS emits # about ITSELF (/proc/cpuinfo flags, guest device-node presence), and 11.7.1 exists precisely diff --git a/messagefoundry/config/tls_policy.py b/messagefoundry/config/tls_policy.py index ffbe826c..c15fb6ba 100644 --- a/messagefoundry/config/tls_policy.py +++ b/messagefoundry/config/tls_policy.py @@ -70,6 +70,7 @@ "harden_cipher_suites", "harden_kex_groups", "harden_verify_flags", + "kex_groups_report", "relax_verify_expiry", "in_process_tls_revocation_refused", "insecure_hop_disposition", @@ -162,6 +163,30 @@ def harden_kex_groups(ctx: ssl.SSLContext) -> str | None: return APPROVED_KEX_GROUPS +def kex_groups_report() -> str: + """Report whether the approved KEX groups are PINNED on built contexts, or INHERITED (#338). + + A report-only read-out, the KEX sibling of :func:`fips_attestation` — it changes NO live TLS + behaviour and never raises. It builds a throwaway probe context and asks the ONE authority, + :func:`harden_kex_groups`, what it manages to pin there, so the report can never disagree with what + the connectors actually do (a second, hand-rolled version-check would be exactly the drift #338 is + about). On every interpreter this project currently runs on ``SSLContext.set_groups`` is absent + (it is a **Python 3.15** API), so ``harden_kex_groups`` pins nothing and this returns the + ``inherited`` string; the first interpreter that grows the API flips it to ``pinned: ...``. + + The ``inherited`` wording deliberately does NOT restate the measured accepted set (ffdhe2048 / + ffdhe3072 / secp521r1) — that lives in ``docs/PHI.md`` §4, and stating a load-bearing fact once + keeps the two from drifting (CLAUDE.md §11).""" + probe = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + pinned = harden_kex_groups(probe) + if pinned is not None: + return f"pinned: {pinned}" + return ( + f"inherited (OpenSSL default group list; the approved pin {APPROVED_KEX_GROUPS} is inert " + "until Python 3.15 - see docs/PHI.md §4)" + ) + + def harden_verify_flags(ctx: ssl.SSLContext) -> None: """Best-effort enable strict X.509 path validation on a *verifying* ``ctx`` (ASVS 12.1.4). diff --git a/messagefoundry_webconsole/__init__.py b/messagefoundry_webconsole/__init__.py index 186466be..c94e72a3 100644 --- a/messagefoundry_webconsole/__init__.py +++ b/messagefoundry_webconsole/__init__.py @@ -45,7 +45,7 @@ # If cross-seam support is ever genuinely wanted, re-widen this set AND add the CI matrix that # installs the MIN and MAX supported engine builds — the claim and its test land together, or not # at all. -SUPPORTED_ENGINE_SEAMS: frozenset[int] = frozenset({17}) +SUPPORTED_ENGINE_SEAMS: frozenset[int] = frozenset({18}) #: The vendored static assets shipped in THIS wheel (mounted at /ui/static by :func:`mount_ui`). STATIC_DIR = Path(__file__).parent / "static" diff --git a/messagefoundry_webconsole/pages/monitoring.py b/messagefoundry_webconsole/pages/monitoring.py index c3413437..c6bd67c0 100644 --- a/messagefoundry_webconsole/pages/monitoring.py +++ b/messagefoundry_webconsole/pages/monitoring.py @@ -403,6 +403,11 @@ def status( _fips(getattr(posture, "fips_mode", None)), ], ["OpenSSL version (ssl/_hashlib)", _opt(getattr(posture, "openssl_version", None))], + # TLS key-exchange groups read-out (report-only, #338 / ASVS 11.6.2). Mirrors the FIPS/ + # OpenSSL rows above: says whether the approved KEX groups are pinned on built contexts or + # inherited from OpenSSL's default (inherited until Python 3.15). getattr-with-default is + # defensive, not cross-seam compat (one supported seam, #279), so a None renders as a dash. + ["TLS key-exchange groups (reported)", _opt(getattr(posture, "kex_groups", None))], # Platform memory-encryption read-out (report-only, ADR 0152 Phase 1 / ASVS 11.7.1). # Wording is a security property here: every label says "self-reported", and capability # ("this silicon can") is a SEPARATE row from activation ("this guest is"), because a diff --git a/packaging/messagefoundry-webconsole/tests/test_webui.py b/packaging/messagefoundry-webconsole/tests/test_webui.py index b59db361..d7e18b46 100644 --- a/packaging/messagefoundry-webconsole/tests/test_webui.py +++ b/packaging/messagefoundry-webconsole/tests/test_webui.py @@ -1591,6 +1591,7 @@ def test_status_builder_escapes_and_formats() -> None: key_id="abc123", require_encryption=True, allow_unencrypted_phi=False, + kex_groups="inherited (test read-out)", # #338 report-only KEX read-out ) cluster = ClusterStatus( node_id="n1", clustered=False, is_leader=True, role="single-node", config_version=0 @@ -1620,6 +1621,8 @@ def test_status_builder_escapes_and_formats() -> None: assert "yes" in html and "single-node" in html # _yn + role assert "host" not in html # hostile node host escaped assert "<b>host</b>" in html + # #338: the report-only TLS key-exchange read-out row renders (label + the inherited value). + assert "key-exchange" in html and "inherited" in html # L6a: the hosting-service badge renders the state + name. assert "Hosting service" in html and "MEFOR_Engine" in html and "running" in html # When reporting is off, the badge says so (no state leaked). diff --git a/tests/golden/webconsole_seam.snapshot b/tests/golden/webconsole_seam.snapshot index 7ada857d..1a6011bf 100644 --- a/tests/golden/webconsole_seam.snapshot +++ b/tests/golden/webconsole_seam.snapshot @@ -5,7 +5,7 @@ # This is a GOLDEN gate: any diff means the seam contract changed - see the test's failure hint. ## ENGINE_UI_SEAM -17 +18 ## dataclass messagefoundry.api._ui_seam.UiDeps engine_seam @@ -170,7 +170,7 @@ MetricsHistorySample: outbox_by_status, ts PendingApprovalResponse: approval_id, detail, operation, status ReloadRequest: config_dir, dry_run ReloadResult: dry_run, handlers, inbound, outbound, routers, running -SecurityPosture: allow_unencrypted_phi, backend, client_address_monoculture, client_denied_last, client_network_denials, data_class, encryption_enabled, enforcement, environment, fips_mode, key_id, key_source, loosenings, loosenings_scope, memory_encryption_note, memory_encryption_operator_declared, memory_encryption_readout_contradicts_declaration, memory_encryption_readout_source, memory_encryption_self_reported_active, memory_encryption_self_reported_capability, memory_encryption_self_reported_mechanism, openssl_version, plaintext_columns, production, require_encryption, security, synthetic_relaxation +SecurityPosture: allow_unencrypted_phi, backend, client_address_monoculture, client_denied_last, client_network_denials, data_class, encryption_enabled, enforcement, environment, fips_mode, kex_groups, key_id, key_source, loosenings, loosenings_scope, memory_encryption_note, memory_encryption_operator_declared, memory_encryption_readout_contradicts_declaration, memory_encryption_readout_source, memory_encryption_self_reported_active, memory_encryption_self_reported_capability, memory_encryption_self_reported_mechanism, openssl_version, plaintext_columns, production, require_encryption, security, synthetic_relaxation ServiceStatusInfo: enabled, service_name, state StatsResetRequest: all, targets StatsResetTarget: channel_id, destination, role diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 06defb59..b06dca34 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -1306,6 +1306,28 @@ async def test_security_posture_reports_fips_attestation(engine: Engine) -> None assert rows # at least the read above was recorded +async def test_security_posture_reports_kex_groups(engine: Engine) -> None: + # #338 / ASVS 11.6.2: the posture route reports whether the approved TLS key-exchange groups are + # PINNED on built contexts or INHERITED from OpenSSL's default group list — report-only metadata, + # MONITORING_READ-gated + audited like the rest of the payload, and it changes NO live TLS behaviour + # (inherited on every interpreter before Python 3.15). + from messagefoundry.config.tls_policy import kex_groups_report + + service = await _service(engine) + await _add(service, "vw", Role.VIEWER) # holds monitoring:read + async with _posture_client(engine, service) as c: + vw = _auth((await _login(c, "vw")).json()["token"]) + resp = await c.get("/security/posture", headers=vw) + assert resp.status_code == 200 + body = resp.json() + # The additive field is present and matches the pure helper's read-out; inherited on this runtime. + assert body["kex_groups"] == kex_groups_report() + assert "inherited" in body["kex_groups"] + # The read stays audited (security.posture_view) — a viewer's read produced an audit row. + rows = await engine.store.list_audit(limit=20, action="security.posture_view") + assert rows # at least the read above was recorded + + async def test_security_posture_encrypted_exposes_fingerprint_not_key_bytes( tmp_path: Path, engine: Engine ) -> None: diff --git a/tests/test_tls_policy.py b/tests/test_tls_policy.py index c55864d9..ab5ef3f7 100644 --- a/tests/test_tls_policy.py +++ b/tests/test_tls_policy.py @@ -31,6 +31,7 @@ in_process_tls_revocation_refused, insecure_hop_disposition, is_loopback_hop_host, + kex_groups_report, tls_revocation_attested, validate_tls_ciphers, ) @@ -196,6 +197,24 @@ def test_approved_groups_are_ecdhe_curves() -> None: # API; do not "normalise" them to one. +# --- kex_groups_report: report-only KEX read-out (#338, ASVS 11.6.2) ---------------------------- +def test_kex_groups_report_reports_inherited_today() -> None: + """#338: the report-only KEX read-out says the approved groups are INHERITED on this runtime. + + ``SSLContext.set_groups`` is a Python 3.15 API, so ``harden_kex_groups`` pins nothing on any + interpreter this project currently runs on. The read-out must therefore report "inherited" (never + "pinned:") and name the approved group list it WOULD pin, so an operator reading it sees what is at + stake. It is a pure read-out over a throwaway probe context — report-only, and it never raises. On + the Python 3.15 interpreter that grows the API this flips to "pinned:", the same signal the + ``test_the_group_pin_is_inert_on_this_runtime_and_says_so`` tripwire fires on. + """ + report = kex_groups_report() + assert isinstance(report, str) and report # a non-empty string + assert "inherited" in report # nothing is pinned on a pre-3.15 interpreter + assert APPROVED_KEX_GROUPS in report # names the approved list it WOULD pin + assert "pinned:" not in report # the "pinned:" branch is 3.15-only + + # --- harden_verify_flags ----------------------------------------------------------------------- def test_harden_verify_flags_sets_strict() -> None: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) From 693f43989d9168ef66ec8515d71a4ff382156b29 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 05:02:32 -0500 Subject: [PATCH 2/8] docs(backlog): flip #338 banner to shipped (BACKLOG #338) Flip the #338 status banner from filed/not-started to shipped, now that the report-only kex_groups posture field and the three doc-accuracy corrections have landed. The banner records that the KEX groups are documented as inherited (the pin is inert until Python 3.15) plus the report-only surfacing behind engine seam v18. Banner line only, under the #338 heading, verified by number. The ranked table, the four census distribution lines, and every other item's banner are untouched. The census was NOT recomputed. --- docs/BACKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 38305897..f7e1039a 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -3127,7 +3127,7 @@ What it *is*: an adopter who turns on the strict gate gets a **green build** on ## 338. TLS key-exchange groups are inherited, not pinned -> 🔢 **Filed 2026-08-01 — not started.** Value **3/10** · Difficulty **2/10** · _fill-in_. `harden_kex_groups` still returns `None` when `set_groups` is absent, and all three restatements survive the 2026-07-29 sweep — `CONTAINER-EXPOSURE-EVALUATION.md` still says "hardened KEX groups" under a *verification* heading, `BACKLOG.md:6422` still lists 11.6.2 in #200's Closes line against PHI.md's PARTIAL, and `ASVS-L2-PHASE0-CHANGES.md:254` still presupposes a pin — but every group that gets in is forward-secret and the floor plus `harden_cipher_suites` admit nothing static, so this is documentation accuracy plus observability; three doc edits and one additive report-only `SecurityPosture` field beside `fips_attestation()`, with the two tripwire tests left alone as the 3.15 trigger. +> ✅ **SHIPPED 2026-08-06 (#338) — key-exchange groups documented as inherited, plus a report-only surfacing.** Value **3/10** · Difficulty **2/10**. `harden_kex_groups` pins nothing until `SSLContext.set_groups` lands in **Python 3.15**, so every built context inherits OpenSSL's default group list — forward-secret but wider than the approved pin — which makes this documentation accuracy plus observability, changing no live TLS behaviour. The three restatements that still read as *pinned* are corrected to say *inherited*: `CONTAINER-EXPOSURE-EVALUATION.md` and `ASVS-L2-PHASE0-CHANGES.md`, plus #200's Closes line in `docs/archive/backlog/BACKLOG-CLOSED.md` (11.6.2 annotated PARTIAL, see PHI.md §4). Added an additive report-only `kex_groups` field on `SecurityPosture` beside `fips_attestation()`, rendered on the console status page behind engine seam v18. The two Python-3.15 tripwire tests are left in place as the trigger to set the pin. **Cluster:** Security & Compliance. **Priority:** P3. **Verdict:** build. **Severity:** low. From 2d64db6af777ca5e89ac6d34c8c523de9a363103 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 09:09:39 -0500 Subject: [PATCH 3/8] docs(research): OpenFlow step-attribute vocabulary gap-map (BACKLOG #238) A findings note comparing Windmill's seven OpenFlow step-attributes (retry, timeout, stop_after_if, skip_if, continue_on_error, mock, cache_ttl) against MessageFoundry's engine/handler vocabulary. Per attribute: what it is, the engine analogue (grounded in a real symbol/file), and the gap (covered-different-locus / partial / absent). This is a review, not a feature. OpenFlow is explicitly NOT a compatibility target; the note is an informational vocabulary map, not a gap-to-close list. Adopting a declarative artifact stays declined by ADR 0076 section 7 and BACKLOG #26. Framing is conditional throughout (not-deployed beta). Lands at docs/research/openflow-step-attributes.md, mirroring the existing docs/research/ review-note convention (config-ux-review.md, message-model-eval.md, ide-low-code-options.md). The mock row cites config/db_lookup.py / config/fhir_lookup.py for the pure-dry-run raise guard (config/db_lookup.py raise DbLookupError / config/fhir_lookup.py raise FhirLookupError), not config/wiring.py whose raise text is about the router phase. --- docs/research/openflow-step-attributes.md | 49 +++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/research/openflow-step-attributes.md diff --git a/docs/research/openflow-step-attributes.md b/docs/research/openflow-step-attributes.md new file mode 100644 index 00000000..4b67698d --- /dev/null +++ b/docs/research/openflow-step-attributes.md @@ -0,0 +1,49 @@ +# OpenFlow step attributes vs. the engine vocabulary (research / findings) + +**Date:** 2026-08-06 · **Status:** research / findings (no code) · **Owner action:** none required — informational vocabulary map. + +This is BACKLOG **[#238](../BACKLOG.md)**. It reads Windmill's **OpenFlow** step-attribute vocabulary +(Apache-2.0, safe to read and cite) as a **completeness checklist** against MessageFoundry's own +step/connector semantics, and records, per attribute, whether the engine already covers it (and where), +covers it partially, or does not have it — and why. **OpenFlow is explicitly NOT a compatibility +target.** Emitting or consuming it is a separate, unauthorized question, and adopting a *declarative +artifact* remains declined by [ADR 0076](../adr/0076-typed-action-vocabulary-action-list-lens.md) §7 and +BACKLOG #26. This note exists so the mapping need not be re-derived; the gaps below are described as +**vocabulary differences, not defects**, and nothing here is a to-build list. MessageFoundry is a +not-deployed beta, so claims are stated in the conditional. + +The one fact worth leading with: **most of these attributes already have an engine analogue, but at a +different locus** — a per-connection / delivery / pipeline policy, or plain Python control flow inside a +Handler — rather than as an attribute hanging off a single step row. Saying *where* each lives precisely +is most of the value here. + +## The seven attributes, mapped + +| Attribute | What it is (OpenFlow) | Engine analogue (symbol · file) | Gap | +|---|---|---|---| +| `retry` | A per-step retry policy (constant or exponential backoff, N attempts) before the step is treated as failed. | `RetryPolicy` (`config/models.py`) — `max_attempts` / `backoff_seconds` / `backoff_multiplier` / `max_backoff_seconds`; attached per-outbound as `Destination.retry`, drained by the outbound delivery worker under the staged-queue at-least-once model. | **Covered, different locus** — retry is a per-**outbound-connection** delivery policy, not a per-handler-row attribute. | +| `timeout` | A per-step wall-clock timeout after which the step is killed. | Boundary timeouts only: `Validation.strict_timeout_s` (`config/models.py`) / `_STRICT_VALIDATE_TIMEOUT_SECONDS` (`pipeline/wiring_runner.py`) bound strict validation; `_LOOKUP_RESULT_TIMEOUT_SECONDS` (`pipeline/wiring_runner.py`) bounds a bridged `db_lookup`/`fhir_lookup`; connectors carry their own (`timeout_seconds`/`connect_timeout` in `transports/tcp.py`, `transports/mllp.py`; `acquire_timeout` in `transports/database.py`). | **Partial** — timeouts bound the external / parse boundaries; there is **no** generic per-handler/transform wall-clock timeout (a pure transform is CPU-bounded by design). | +| `stop_after_if` | Stop the flow early (as success or skip) when an expression over the step result is true. | A Router that forwards to no / fewer handlers yields `UNROUTED`; a Handler that returns no `Send` yields `FILTERED` (`disposition_for`, `pipeline/dryrun.py`; the count-and-log invariant). Control flow projects as `if` rows in the Steps view (`_emit_if`, `lens.py`). | **Partial / structural mismatch** — the config is a **graph** with no linear "steps after this" to stop; "stop the flow" is expressed by a Router/Handler declining to forward, not a post-step early-terminate knob. | +| `skip_if` | Skip this step when an expression is true; the flow continues past it. | Ordinary Python `if cond: return` in a `@router` / `@handler`; the router's `accepts=` seam filters which handlers ever see a message; also projects as an `if` row (`_emit_if`, `lens.py`). | **Covered, as code** — conditional skipping is plain control flow in a code-first Handler, deliberately Python rather than a declarative attribute (the #26 differentiator). | +| `continue_on_error` | Let the flow proceed when this step errors, instead of failing the run. | `InternalErrorPolicy.CONTINUE` (`config/models.py`) is the **default** — dead-letter the offending row (replayable) and keep the lane moving; each outbound drains independently; post-ACK routing/transform errors are logged `ERROR` / dead-lettered, never fatal (count-and-log). `STOP` is the opt-in opposite. | **Covered, different locus + inverted default** — error-and-continue is the pipeline **default** at the delivery / row level, per-connection, not a per-handler-row toggle. | +| `mock` | Replace a step's execution with a fixed, canned result (for testing a flow). | No per-step canned-result substitution. Adjacent: the traced dry-run (`pipeline/dryrun.py`; [ADR 0072](../adr/0072-traced-dryrun-mode.md)) runs routing + handling with **no** connectors / network (delivery previewed, not executed), and `Destination.simulate` (`config/models.py`) shadow-suppresses real egress. | **Largely absent** — the engine can mock **delivery** (dry-run / shadow suppress egress) but cannot feed a step a fixed stand-in result; `db_lookup` / `fhir_lookup` **raise** in a pure dry-run rather than returning a mock (`config/db_lookup.py` / `config/fhir_lookup.py`). | +| `cache_ttl` | Cache a step's result for N seconds, reusing it on re-run within the window. | None for handler / step results. The reliability model **requires** routers / transforms to be pure and re-runnable with identical output ([ADR 0001](../adr/0001-staged-pipeline-architecture.md)); the sanctioned non-pure inputs `db_lookup` / `fhir_lookup` ([ADR 0010](../adr/0010-handler-callable-db-lookup.md) / [ADR 0043](../adr/0043-fhir-read-lookup.md)) are deliberately **live** reads whose result may differ on a re-run. | **Absent, by design** — a per-step result cache would contradict the pure-re-run at-least-once invariant; the caches the engine does keep are engine-internal infrastructure, not step-result caches. | + +*Caveat:* `file:line` anchors are a 2026-08-06 snapshot and drift — the table cites **symbol names** as +the primary anchor (`RetryPolicy`, `InternalErrorPolicy`, `Destination.simulate`, +`_LOOKUP_RESULT_TIMEOUT_SECONDS`, `disposition_for`, `_emit_if`); re-confirm at read time and cite +symbols, not just lines. + +## Reading of the gap-map + +Most of the seven are **already covered engine-side, at a different locus** — a connection / delivery / +pipeline policy (`retry`, `continue_on_error`, and most of `timeout`) or plain Python control flow inside +a Handler (`skip_if`, and `stop_after_if` as a Router / Handler declining to forward) — rather than as a +per-step-row attribute. Only two are genuinely absent: `mock` (the engine mocks **delivery**, not step +results) and `cache_ttl` — and `cache_ttl` is absent **by design**, because a per-step result cache would +fight the pure-re-run at-least-once invariant the pipeline depends on. + +This confirms the item's framing: OpenFlow compatibility is unnecessary and unwanted here. The +vocabulary is a **lens on semantics the engine already has**, expressed at the connection / delivery / +pipeline level or as code-first control flow — not a gap-to-close list, and not a case for emitting or +consuming a declarative artifact. From 7ddc690b3bb4948f0c549bb1273fc1c80057f75b Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 09:10:04 -0500 Subject: [PATCH 4/8] docs(backlog): flip #238 banner to CLOSED (BACKLOG #238) The findings note (docs/research/openflow-step-attributes.md) is the item's expected output and is now delivered, so #238's banner flips from open to closed. One banner line only, under the #238 heading. Census NOT recomputed: this commit changes only #238's banner line and does not touch the ranked table or the four census distribution lines. --- docs/BACKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 911cf3da..6bfde870 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2513,7 +2513,7 @@ def route_demo_oru(msg): ## 238. OpenFlow step-attribute completeness pass over the engine vocabulary -> 🔢 **Filed 2026-07-30 — not started.** Value **1/10** · Difficulty **1/10** · _fill-in_. A review whose output is findings, not a feature; OpenFlow is explicitly **not** a compatibility target. +> ✅ **CLOSED 2026-08-06 — findings note delivered.** Value **1/10** · Difficulty **1/10**. The gap-map lives at [docs/research/openflow-step-attributes.md](research/openflow-step-attributes.md); OpenFlow remains explicitly **not** a compatibility target — the note is a vocabulary map, not a gap-to-close list. **Cluster:** IDE & Authoring / Engine. **Priority:** P3. **Verdict:** build (a review, not a feature). **Severity:** none — this is a gap-analysis task whose output is findings. From fec380b0a1ff688ecdff6f927f403eee7af37665 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 09:43:33 -0500 Subject: [PATCH 5/8] fix(serve): auth-off startup arm now refuses a declared terminator (BACKLOG #1013) The `[auth] enabled=false` startup arm keyed on the bind alone (`not settings.api.is_loopback`), so it did not fire for a loopback bind behind a declared upstream TLS terminator. A PHI instance with authentication entirely off behind a declared terminator would have started with no refusal and no warning on first deployment, while the same topology with auth on but MFA off is already refused by the gate #326 fixed. The two arms disagreed about what "exposed" means in the same file. Hoist the single `instance_exposed` definition (#326: an off-loopback bind OR a declared upstream TLS terminator) above the auth-off arm and widen the arm to consult it, so it refuses on a non-loopback bind OR a declared terminator. The existing loopback refusal is kept; the condition is widened, not replaced. Load order verified: `instance_exposed`'s inputs -- `settings.api.host` (through `is_loopback`) and `settings.api.tls_terminated_upstream` -- are read straight off the loaded config, and the only in-place mutation of `settings.api.*` between the hoisted definition and the former site is `serve_ui` (twice), which the predicate does not read. The definition remains defined exactly once. Tests (tests/test_cli.py): auth off + declared terminator on a loopback bind refuses (positive); auth off + true loopback with no terminator still starts (negative control); auth on + declared terminator is unaffected by the arm. Each was falsified -- reverting the arm to the bare bind check reds the positive test, firing on any auth-off reds the negative control, and dropping the auth check reds the auth-on test; each was restored. Docs updated so the contract travels with the code (CLAUDE.md 11): DEPLOYMENT.md, SYSTEM-REQUIREMENTS.md, SECURITY.md, REMOTE-CONSOLE.md and SECURITY-LOOSENING.md now describe the auth-off refusal as firing on an exposed instance (a non-loopback bind OR a declared terminator), not on the bind alone. BACKLOG #1013 --- docs/DEPLOYMENT.md | 4 +-- docs/REMOTE-CONSOLE.md | 5 +-- docs/SECURITY-LOOSENING.md | 11 +++--- docs/SECURITY.md | 5 +-- docs/SYSTEM-REQUIREMENTS.md | 2 +- messagefoundry/__main__.py | 37 ++++++++++++++----- tests/test_cli.py | 72 +++++++++++++++++++++++++++++++++++++ 7 files changed, 115 insertions(+), 21 deletions(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 13782a03..f0d346c3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -89,7 +89,7 @@ engine binds. Three planes sit at different exposure levels: | Plane | What it is | Where it binds | Posture | |---|---|---|---| -| **Management** | web console (`/ui`) / IDE → engine API | loopback by default (or a restricted management subnet) | auth + RBAC + full audit, **on by default** (`[security].require_sign_in`, default `true`) — disabling it is refused on a non-loopback bind, but on **loopback** it is permitted and drops the plane to a full-privilege no-RBAC identity; smallest surface — keep it off general-user VLANs | +| **Management** | web console (`/ui`) / IDE → engine API | loopback by default (or a restricted management subnet) | auth + RBAC + full audit, **on by default** (`[security].require_sign_in`, default `true`) — disabling it is refused on a non-loopback bind **or a loopback bind behind a declared TLS terminator**, but on a bare **loopback** bind with no declared terminator it is permitted and drops the plane to a full-privilege no-RBAC identity; smallest surface — keep it off general-user VLANs | | **Data** | inbound feeds you *receive* (MLLP, TCP/X12, DB-poll) | the **internal network interface** — feeds come from other systems on your LAN, not `127.0.0.1` | **TLS on the wire where the channel has it** (enable MLLP-over-TLS; **TCP/X12 have none** — segment them) + the `[egress]`/ingress allow-lists + your network segmentation. PHI must not cross the LAN in cleartext | | **Inbound web service** | a partner *calls into* MEFOR (`Http()` source) | its own connector-owned socket | built (ADR 0023) — per-connection TLS + opt-in mTLS + IP allow-list, **no bearer/basic partner auth**. Both peer controls are **optional and unenforced** — a TLS-on listener with neither accepts any peer; see the caveat below | @@ -274,7 +274,7 @@ authentication on the channel · **Egress gate** = the `[egress]` allow-list tha | Channel | Bind default | TLS support | Auth | Ingress/egress gate | Off-loopback guarded? | |---|---|---|---|---|---| -| **Engine API** (FastAPI/uvicorn) | `[security].local_access_only` = true → `127.0.0.1` | **Yes** — in-process via `tls_cert_file`/`tls_key_file`, *or* upstream via `tls_terminated_upstream` + `trusted_proxies`; `tls_min_version` (≥1.2); opt-in mTLS via `tls_client_ca_file`; HSTS over https | Bearer token + session RBAC — **required by default** (`[security].require_sign_in`, default `true`); `false` is refused on a non-loopback bind, and on loopback yields a full-privilege *system* identity with no RBAC | — (auth-gated) | **Yes** — refused without TLS or a trusted terminator, and `--allow-insecure-bind` is clamped inert on an enforcing PHI instance (the default); also refused if sign-in is disabled on a non-loopback bind | +| **Engine API** (FastAPI/uvicorn) | `[security].local_access_only` = true → `127.0.0.1` | **Yes** — in-process via `tls_cert_file`/`tls_key_file`, *or* upstream via `tls_terminated_upstream` + `trusted_proxies`; `tls_min_version` (≥1.2); opt-in mTLS via `tls_client_ca_file`; HSTS over https | Bearer token + session RBAC — **required by default** (`[security].require_sign_in`, default `true`); `false` is refused on a non-loopback bind or a loopback bind behind a declared TLS terminator, and on a bare loopback bind with no declared terminator yields a full-privilege *system* identity with no RBAC | — (auth-gated) | **Yes** — refused without TLS or a trusted terminator, and `--allow-insecure-bind` is clamped inert on an enforcing PHI instance (the default); also refused if sign-in is disabled on a non-loopback bind or a loopback bind behind a declared terminator | | **MLLP source** | `[inbound].bind_host` = `127.0.0.1` | **Yes** — per-connection opt-in `tls=true` + `tls_cert_file`/`tls_key_file`; opt-in mTLS via `tls_ca_file`; ≥TLS 1.2. **Plaintext by default** | None (MLLP has no app auth) | — | **Yes** — non-loopback plaintext refused (`check_mllp_tls_exposure`) | | **HTTP source** (`Http()`, ADR 0023) | `[inbound].bind_host` = `127.0.0.1` | **Yes** — per-connection opt-in `tls=true` + `tls_cert_file`/`tls_key_file`; opt-in mTLS via `tls_ca_file`. **Plaintext by default** | mTLS client cert only — **no bearer/basic partner auth**, and **neither mTLS nor the IP allow-list is required**: with TLS on and both unset the listener accepts any peer | per-connection `source_ip_allowlist` — **optional, defaults to no restriction** | **Yes** — non-loopback plaintext refused (`check_http_tls_exposure`) — but the gate checks **only** that TLS is on, **never** that a peer control exists (unlike the DICOM SCP row below) | | **DICOM C-STORE SCP** (`DICOM()`, ADR 0025) | `[inbound].bind_host` = `127.0.0.1` | **Yes** — per-connection opt-in `tls=true` + cert/key; opt-in mTLS via `tls_ca_file`. **Plaintext by default** | `calling_ae_allowlist` / `require_called_ae_title` / mTLS (DIMSE has no transport auth of its own) | per-connection `source_ip_allowlist` | **Yes** — non-loopback plaintext refused (`check_dimse_tls_exposure`), **and** a non-loopback SCP with *no* peer control (calling-AE allow-list, IP allow-list, or mTLS) is refused at construction | diff --git a/docs/REMOTE-CONSOLE.md b/docs/REMOTE-CONSOLE.md index 17fd92c3..ceaaad0f 100644 --- a/docs/REMOTE-CONSOLE.md +++ b/docs/REMOTE-CONSOLE.md @@ -167,8 +167,9 @@ carries the recommended hardening for an exposed console (client-certificate dev Auth is on by default; remote users sign in with local accounts (± TOTP MFA) or AD/LDAP. Note: -- With `[security].require_sign_in = false`, an off-loopback bind is **hard-refused** (loopback is the - only no-auth posture). +- With `[security].require_sign_in = false`, an exposed instance is **hard-refused** — an off-loopback + bind, or a loopback bind behind a declared TLS terminator (a bare loopback bind with no declared + terminator is the only no-auth posture). - `[security].require_mfa` is **on by default**, and MFA is an access gate: an enrolled-pending session gets `403` + `X-MFA-Required: 1` on every authorized route. **Leave it on** — that default, not the startup gate below, is the control. diff --git a/docs/SECURITY-LOOSENING.md b/docs/SECURITY-LOOSENING.md index c7a77878..6d2317b2 100644 --- a/docs/SECURITY-LOOSENING.md +++ b/docs/SECURITY-LOOSENING.md @@ -177,9 +177,10 @@ trail. ### `require_sign_in = false` — disable authentication - **What you lose:** every request runs as a full-privilege *system* identity; no RBAC. - **When acceptable:** a **loopback-only** embedding/dev harness. -- **Compensating controls:** loopback bind only. -- **Still refused:** a non-loopback bind with auth off is a **hard refuse** — serving full-privilege admin to - the network is never one "I accept the risk" away, at any posture. +- **Compensating controls:** a loopback bind with no declared TLS terminator only. +- **Still refused:** an exposed instance with auth off — a non-loopback bind, **or** a loopback bind behind a + declared TLS terminator — is a **hard refuse** — serving full-privilege admin to the network is never one "I + accept the risk" away, at any posture. ### `require_mfa = false` — single-factor admin - **What you lose:** the Administrator role authenticates with a password only (no native TOTP second @@ -299,8 +300,8 @@ trail. - **Compensating controls:** return to `enforce` before carrying real patient traffic; the warnings + startup **AUDIT** line + posture view keep the deviation visible. - **Still refused (even at `warn`):** the **no-auth-to-the-network** hard refuse (`require_sign_in = false` on - a non-loopback bind) is unconditional at **any** enforcement level — `enforcement = warn` does **not** open - it — and the unconditional ePHI audit floor is untouched. `enforcement` is **binary** (no `off`): silencing + an exposed instance — a non-loopback bind, or a loopback bind behind a declared TLS terminator) is + unconditional at **any** enforcement level — `enforcement = warn` does **not** open it — and the unconditional ePHI audit floor is untouched. `enforcement` is **binary** (no `off`): silencing a PHI cleartext hop *entirely* is only reachable by declaring the box synthetic (`handles_real_patient_data = false`), never by the dial ([ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md)). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 80a4c8c0..ccf370f6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -33,7 +33,8 @@ The in-process embedding factory `create_app(engine)` is **fail-closed**: with n attached it denies every protected route (503) unless the caller explicitly opts out with `create_app(..., allow_no_auth=True)` — the deliberate embedding/local-dev escape hatch. The `serve` path runs auth-enabled by default; if `[auth] enabled = false` it sets that opt-in itself, and -`__main__` refuses to serve auth-off on a non-loopback host — and, even with auth enabled, a +`__main__` refuses to serve auth-off on an exposed instance — a non-loopback host, or a loopback host +behind a declared TLS terminator — and, even with auth enabled, a non-loopback bind requires **TLS**: in-process (`[api].tls_cert_file`, WP-13a) or terminated at a trusted upstream proxy (`tls_terminated_upstream` + `trusted_proxies`, WP-15), or — as a dev override — an explicit `serve --allow-insecure-bind` (without any of these, bearer tokens + PHI would cross the @@ -1094,7 +1095,7 @@ one-to-one — that is why the bind/exposure posture occupies two rows and the A | PHI-read volume, per actor | `identity.user_id` | > 120 reads (`phi_read_rate_limit_per_actor`) per 60 s (`phi_read_rate_limit_window_seconds`); the global dimension `phi_read_rate_limit_global` defaults to `0` = **off** | **THROTTLE** 429 + `Retry-After: 10`, WARNING-logged, charged at **admission** before any store work | on, 120 / 60 s | `[auth].phi_read_rate_limit_enabled` | | Admin-write rate, per actor | `identity.user_id` × request method | **non-GET only**; > 12 writes (`admin_write_rate_limit_per_actor`) per 1.0 s (`admin_write_rate_limit_window_seconds`); no global dimension (`glob=0`) | **THROTTLE** 429 + `Retry-After: 1`, WARNING-logged. **JSON API only** — no `/ui` route charges it at this release | on, 12 writes / 1.0 s | `[auth].admin_write_rate_limit_enabled` | | Serve-hop security posture | declared data class (`[ai].data_class`, or derived from `[ai].environment`) × `[security].enforcement` × (`api.is_loopback` **or** `exposure_protected`), via `phi_read_hop_disposition` | disposition is REFUSE — a **PHI** instance under `enforcement = enforce` whose serve hop is neither loopback, nor in-process TLS, nor a declared TLS-terminating proxy. Setting `[security].enforcement = warn` turns the refusal into WARN-and-serve; a non-PHI declared data class removes it entirely | **DENY** 403 (PHI-free message) on every **JSON-API** PHI-read route (`require_phi_read`, plus the step-up bulk routes), **before** any identity work. **Not applied on the `/ui` browse routes** — `enforce_phi_read_hop` has no console call site, so those get the per-actor budget only (pinned by `test_the_ui_phi_browse_gap_is_disclosed`) | ALLOW on loopback | `[security].enforcement`, `[ai].data_class`/`environment`, `[api].tls_cert_file`, `tls_terminated_upstream` + `trusted_proxies` | -| Bind / exposure posture — refusing arms | `[api].host` loopback-ness, `tls_terminated_upstream`, `trusted_proxies`, `public_origin`; derived `instance_exposed` (loopback-ness **or** a declared terminator) and `admin_exposed`, plus `ui_exposed` for the `/ui` arms only; `[security].enforcement`; declared data class | auth off on a non-loopback bind; `/ui` exposed without the required origin/TLS declarations; `admin_exposed` + PHI + `enforcing` + `require_mfa` explicitly opted out | **DENY at startup** — `serve` prints an error and exits **2**. The refuse/warn dial is `[security].enforcement` (default `enforce`), **not** `production`: the auth-off and `/ui`-exposure arms refuse **unconditionally**, and the `require_mfa` arm refuses when the declared data class is PHI **and** enforcement is `enforce` — which includes the non-production `dev` and `staging` environments, both of which derive PHI — and warns otherwise. `[security].allow_single_factor_admin_when_exposed = true` downgrades that one arm to permitted-but-audited. **`admin_exposed` is `instance_exposed`, and reads no console flag** (BACKLOG #326): the ADR 0143 degrade arms rewrite `serve_ui` in place earlier in the same startup, so deriving an exposure decision from it made this arm and the dual-control arm below miss a declared-proxy instance whose console had been degraded or disabled — while the ASVS 11.7.1 arm called that same boot exposed. The same attributes force the session cookie's `Secure` flag + HSTS, and permit WebAuthn `rp_id` derivation from the request URL **only** on a loopback bind with no proxy declared | loopback, nothing declared | `[api].*`, `[security].enforcement`, `[security].allow_single_factor_admin_when_exposed`, `[ai].data_class`/`environment` | +| Bind / exposure posture — refusing arms | `[api].host` loopback-ness, `tls_terminated_upstream`, `trusted_proxies`, `public_origin`; derived `instance_exposed` (loopback-ness **or** a declared terminator) and `admin_exposed`, plus `ui_exposed` for the `/ui` arms only; `[security].enforcement`; declared data class | auth off on an exposed instance — a non-loopback bind **or** a declared terminator (`instance_exposed`); `/ui` exposed without the required origin/TLS declarations; `admin_exposed` + PHI + `enforcing` + `require_mfa` explicitly opted out | **DENY at startup** — `serve` prints an error and exits **2**. The refuse/warn dial is `[security].enforcement` (default `enforce`), **not** `production`: the auth-off and `/ui`-exposure arms refuse **unconditionally**, and the `require_mfa` arm refuses when the declared data class is PHI **and** enforcement is `enforce` — which includes the non-production `dev` and `staging` environments, both of which derive PHI — and warns otherwise. `[security].allow_single_factor_admin_when_exposed = true` downgrades that one arm to permitted-but-audited. **`admin_exposed` is `instance_exposed`, and reads no console flag** (BACKLOG #326): the ADR 0143 degrade arms rewrite `serve_ui` in place earlier in the same startup, so deriving an exposure decision from it made this arm and the dual-control arm below miss a declared-proxy instance whose console had been degraded or disabled — while the ASVS 11.7.1 arm called that same boot exposed. The same attributes force the session cookie's `Secure` flag + HSTS, and permit WebAuthn `rp_id` derivation from the request URL **only** on a loopback bind with no proxy declared | loopback, nothing declared | `[api].*`, `[security].enforcement`, `[security].allow_single_factor_admin_when_exposed`, `[ai].data_class`/`environment` | | Bind / exposure posture — dual-control arm | `admin_exposed` (= `instance_exposed`: an off-loopback bind **or** a declared TLS terminator — never the console flag, BACKLOG #326) × `[approvals].enabled` × declared data class | `admin_exposed` **and** PHI **and** `[approvals].enabled` off — high-value actions complete on one caller's authority | **LOG** — a startup **WARNING only, on every instance including production**; `serve` does **not** refuse. The refuse arm is an explicit unresolved owner fork recorded in `__main__.py`, not a shipped control | approvals off | `[approvals].enabled` | | Pending federated-login flows, per client IP | the `client_ip` recorded on each staged flow | ≥ **16** pending flows from this address (`DEFAULT_PER_IP_CAP`, no knob), or ≥ `oidc_flow_cache_max` (**512**) engine-wide; 300 s TTL; **reject-when-full, never evict** (evict-oldest would turn a start-leg flood into a login DoS) | **DENY** the start leg — `FlowCacheFullError` → **303** to `/ui/login?e=rate_limited`, WARNING-logged, deliberately **never** audited so a flood cannot amplify into `audit_log` growth | 16 / 512 / 300 s | `[auth].oidc_flow_cache_max`, `oidc_flow_ttl_seconds` | | `Sec-Fetch-Mode` on the federated sign-in legs | the browser fetch-metadata header on `GET /ui/sso`, `POST /ui/oidc/start`, `GET /ui/oidc/callback` | header **present** and not `navigate` (absent = allowed, for non-browser clients). Distinct from the `Sec-Fetch-Site` row below: a different header, a different surface, and `assert_same_origin` deliberately does **not** run on the callback leg, whose `Sec-Fetch-Site` is legitimately cross-site | **DENY** — 303 → `/ui/login?e=sso_failed`\|`oidc_failed`, plus an **audited** `auth.login_failed` row carrying the closed-set slug `non_navigation_fetch`. Evaluated **after** the login limiter, so the audit write is itself rate-bounded | on | (no knob) | diff --git a/docs/SYSTEM-REQUIREMENTS.md b/docs/SYSTEM-REQUIREMENTS.md index a8636c25..e50e5af6 100644 --- a/docs/SYSTEM-REQUIREMENTS.md +++ b/docs/SYSTEM-REQUIREMENTS.md @@ -140,7 +140,7 @@ SEV-SNP needs EPYC 7003+ and TDX needs 5th Gen Xeon Scalable+, which is newer th | Purpose | Default | Notes | |---|---|---| -| **Engine API** (HTTP + WebSocket) | `127.0.0.1:8765` | **Loopback by default**; **authentication required by default** — `[security].require_sign_in` defaults `true`, and it is an operator switch, not a fixed property of the software. Setting it `false` is **refused outright on a non-loopback bind** (no flag covers it), so the one posture where auth can legitimately be off is precisely the one this row describes: on **loopback** with sign-in disabled, every request runs as a **full-privilege system identity with no RBAC**, reachable by any local process on the host. Put `require_sign_in` on your config-review checklist rather than recording "the API requires authentication" as a given. **In-process TLS is built and opt-in** (WP-13a, [ADR 0002](adr/0002-phase2-transport-security-and-strong-auth.md)): set `[api].tls_cert_file` (plus `tls_key_file` when the key is a separate PEM) and the engine terminates TLS in uvicorn, so the API **and** the `/ws/stats` WebSocket serve `https`/`wss`. TLS **1.2 floor** (`tls_min_version` — `1.2` or `1.3`), optional `tls_ciphers`, and **opt-in mTLS** via `tls_client_ca_file` (a client certificate is then required and verified). A **TLS-terminating reverse proxy** remains the supported alternative (`tls_terminated_upstream` + `trusted_proxies`). An off-loopback bind needs one of the two — **and on the shipped PHI + `enforcement = enforce` default each branch carries a second precondition that is a refusal, not an advisory**: in-process TLS additionally needs an *attested revocation posture* (the engine performs no OCSP/CRL, so `serve` refuses an off-loopback in-process-TLS bind unless `MEFOR_TLS_REVOCATION_ATTESTED=1` declares that your PKI enforces revocation — [ADR 0078](adr/0078-certificate-revocation-posture.md)); the terminator branch additionally needs `[api].proxy_intra_service_auth` **and** `[api].proxy_tls_min_version`. Neither is covered by `--allow-insecure-bind`; both are worked through in [DEPLOYMENT.md](DEPLOYMENT.md) §Before you expose off-loopback. The browser console refuses an unprotected off-loopback bind outright. | +| **Engine API** (HTTP + WebSocket) | `127.0.0.1:8765` | **Loopback by default**; **authentication required by default** — `[security].require_sign_in` defaults `true`, and it is an operator switch, not a fixed property of the software. Setting it `false` is **refused outright on a non-loopback bind — or a loopback bind behind a declared TLS terminator** (no flag covers it), so the one posture where auth can legitimately be off is a bare **loopback** bind with no declared terminator: with sign-in disabled there, every request runs as a **full-privilege system identity with no RBAC**, reachable by any local process on the host. Put `require_sign_in` on your config-review checklist rather than recording "the API requires authentication" as a given. **In-process TLS is built and opt-in** (WP-13a, [ADR 0002](adr/0002-phase2-transport-security-and-strong-auth.md)): set `[api].tls_cert_file` (plus `tls_key_file` when the key is a separate PEM) and the engine terminates TLS in uvicorn, so the API **and** the `/ws/stats` WebSocket serve `https`/`wss`. TLS **1.2 floor** (`tls_min_version` — `1.2` or `1.3`), optional `tls_ciphers`, and **opt-in mTLS** via `tls_client_ca_file` (a client certificate is then required and verified). A **TLS-terminating reverse proxy** remains the supported alternative (`tls_terminated_upstream` + `trusted_proxies`). An off-loopback bind needs one of the two — **and on the shipped PHI + `enforcement = enforce` default each branch carries a second precondition that is a refusal, not an advisory**: in-process TLS additionally needs an *attested revocation posture* (the engine performs no OCSP/CRL, so `serve` refuses an off-loopback in-process-TLS bind unless `MEFOR_TLS_REVOCATION_ATTESTED=1` declares that your PKI enforces revocation — [ADR 0078](adr/0078-certificate-revocation-posture.md)); the terminator branch additionally needs `[api].proxy_intra_service_auth` **and** `[api].proxy_tls_min_version`. Neither is covered by `--allow-insecure-bind`; both are worked through in [DEPLOYMENT.md](DEPLOYMENT.md) §Before you expose off-loopback. The browser console refuses an unprotected off-loopback bind outright. | | **Inbound MLLP / TCP listeners** | operator-defined (samples use e.g. `2575`, `2600`) | Open to sending systems via firewall. **MLLP-over-TLS is built and opt-in per connection** (WP-13b, `tls = true`, TLS 1.2+ — see [CONNECTIONS.md](CONNECTIONS.md)): an inbound presents `tls_cert_file`/`tls_key_file` as its server identity and opts into **mTLS** with `tls_ca_file`; an outbound **verifies the partner's certificate by default** (`tls_verify`, `tls_check_hostname`, both `true`). Plaintext is still the **default**, so a non-loopback MLLP listener **must** set `tls = true` — it is refused at wiring time otherwise (`check_mllp_tls_exposure`), before the engine starts. A cleartext MLLP **egress** off loopback is likewise **refused at construction** (`messagefoundry check` / dry-run / the serve pre-flight) on any instance where `[security].enforcement = enforce` — the default, and **regardless of data class**: [ADR 0153](adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) removed the data label from that authority, so a *synthetic* box is refused too. The only **per-connection** way across is `cleartext_accepted = true` + `cleartext_reason` (crossed, but warned + audited at every construction); `[security].enforcement = warn` downgrades the refusal to a warning **globally**, which is the operator off-switch. (`tls_hop_attested` appears in the refusal messages and the gates do read it, but it has **no authoring surface** — no factory parameter and no `connections.toml` key — so do not plan around it.) | | **Outbound** | as configured | Reachability to downstream partners and, for server DBs, to the database host. | | Installer egress | HTTPS | Outbound access for the service installer to fetch the pinned NSSM binary (or pre-stage it). | diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index ae1b1ce0..a4a7f6d0 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -1107,12 +1107,31 @@ def _serve(args: argparse.Namespace) -> int: if effective_root is not None and not Path(settings.store.path).is_absolute(): settings.store.path = str(effective_root / settings.store.path) - # Fail closed: with auth disabled the API answers as a full-privilege system identity, so a - # non-loopback bind would publish admin access to the network. Loopback is the only no-auth posture. - if not settings.auth.enabled and not settings.api.is_loopback: + # THE SINGLE DEFINITION of "this instance is exposed" (BACKLOG #326): an off-loopback bind OR a + # declared upstream TLS terminator. Hoisted here so its earliest consumer — the auth-off arm just + # below (BACKLOG #1013) — can read it; the full rationale (why not `serve_ui`, why deliberately + # narrow) sits at the MFA-at-exposure gate that was its original first consumer. Defined ONCE: a + # second copy is exactly how the ASVS 11.7.1 and 6.3.3 arms once disagreed about the same boot (#326). + instance_exposed = not settings.api.is_loopback or settings.api.tls_terminated_upstream + + # Fail closed: with auth disabled the API would answer as a full-privilege system identity, so any + # exposed instance would publish admin access to the network with no authentication at all. Exposure + # is EITHER a non-loopback bind OR a declared upstream TLS terminator on a loopback bind — the same + # `instance_exposed` the MFA-at-exposure gate consults (BACKLOG #1013: this arm previously keyed on + # the bind alone, so an auth-off PHI instance behind a declared terminator would have started + # silently on first deployment). A true loopback posture with no declared terminator is the only + # place no-auth may run. + if not settings.auth.enabled and instance_exposed: + exposure_desc = ( + f"non-loopback host {settings.api.host!r}" + if not settings.api.is_loopback + else "loopback host behind a declared TLS-terminating reverse proxy " + "([api].tls_terminated_upstream)" + ) print( - "error: refusing to serve with [auth] enabled=false on non-loopback host " - f"{settings.api.host!r}; enable auth or bind 127.0.0.1", + f"error: refusing to serve with [auth] enabled=false on {exposure_desc}; the API would " + "answer as a full-privilege system identity with no authentication. Enable auth or bind a " + "loopback host with no declared terminator.", file=sys.stderr, ) return 2 @@ -1891,9 +1910,10 @@ def _serve(args: argparse.Namespace) -> int: file=sys.stderr, ) - # THE SINGLE DEFINITION OF "this instance is exposed" (BACKLOG #326). Derived here, above the first - # consumer, from two fields no earlier arm reassigns — `is_loopback` and `tls_terminated_upstream` - # are read straight off the loaded config and are never mutated in place, unlike `serve_ui`. + # THE SINGLE DEFINITION OF "this instance is exposed" (BACKLOG #326) is derived above, before the + # auth-off arm (BACKLOG #1013) that also consumes it, from two fields no earlier arm reassigns — + # `is_loopback` and `tls_terminated_upstream` are read straight off the loaded config and are never + # mutated in place, unlike `serve_ui`. # # WHY IT CANNOT READ `settings.api.serve_ui`: that field is flipped to False IN PLACE twice above — # the ADR 0143 soft-degrade when the console wheel is absent, and the ADR 0143 auto-degrade when a @@ -1914,7 +1934,6 @@ def _serve(args: argparse.Namespace) -> int: # cleared for exactly this input — a DEFAULT-on console plus a set `public_origin` — so on the # commonest shape of this posture it does not print at all. Citing it as the compensating control # would have rested that control on a premise measurement contradicts. - instance_exposed = not settings.api.is_loopback or settings.api.tls_terminated_upstream # MFA-at-exposure posture (sec-mfa-on; WP-14, ASVS 6.3.3): an off-loopback bind serving local # accounts puts admin authentication on the network, where a single password factor is far weaker. diff --git a/tests/test_cli.py b/tests/test_cli.py index d2db6cb1..4415d57b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -611,6 +611,78 @@ def test_serve_non_loopback_with_auth_off_refused_despite_flag( assert "refusing to serve the API on non-loopback" not in err # ...not the bind gate +def test_serve_refuses_auth_off_behind_declared_terminator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # BACKLOG #1013 (positive regression): auth entirely off behind a DECLARED TLS terminator is exposed + # exactly like an off-loopback bind — the API would answer as a full-privilege identity with no + # authentication — so the auth-off startup arm must refuse it, not only the non-loopback case. A + # loopback bind (default local_access_only) + a declared terminator -> instance_exposed True, auth + # off. The arm returns before the env-required check (like the non-loopback case above), so no --env, + # store key, or server mock is needed. + monkeypatch.chdir(tmp_path) + (tmp_path / "messagefoundry.toml").write_text( + "security.require_sign_in = false\n" + "[api]\n" + "tls_terminated_upstream = true\n" + 'trusted_proxies = ["10.0.0.1"]\n', # settings.py requires this alongside the terminator + encoding="utf-8", + ) + assert main(["serve", "--config", str(SAMPLES_CONFIG)]) == 2 + err = capsys.readouterr().err + # rc alone is not enough: the later env-required gate also returns 2, so the message asserts are + # what give this test teeth (they disappear if the arm reverts to the bare bind check). + assert "enabled=false" in err # the no-auth gate fired + assert ( + "tls_terminated_upstream" in err or "reverse proxy" in err + ) # named the terminator exposure + + +def test_serve_auth_off_on_unexposed_loopback_still_starts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + from messagefoundry.store.crypto import generate_key + + # BACKLOG #1013 (negative control): the arm was WIDENED, not broadened to fire on any auth-off. A + # true loopback dev instance with no declared terminator (instance_exposed False) is the supported + # no-auth flow and must still start silently. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", generate_key()) + monkeypatch.setattr("messagefoundry.api.create_managed_app", lambda **kw: object()) + monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) + (tmp_path / "messagefoundry.toml").write_text( + "security.handles_real_patient_data = false\n" + "security.local_access_only = true\n" + "security.require_sign_in = false\n", + encoding="utf-8", + ) + assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 + # The widened arm stayed silent because the instance is not exposed. + assert "refusing to serve with [auth] enabled=false" not in capsys.readouterr().err + + +def test_serve_auth_on_behind_terminator_unaffected_by_arm( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # BACKLOG #1013: the arm is inert under auth ON even when exposed. A synthetic loopback instance + # behind a declared terminator is instance_exposed True, but auth is on by default (require_mfa + # defaults on -> the MFA-at-exposure gate stays quiet; synthetic keeps the PHI gates quiet), so the + # auth-off arm must not fire. + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("messagefoundry.api.create_managed_app", lambda **kw: object()) + monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) + (tmp_path / "messagefoundry.toml").write_text( + "security.handles_real_patient_data = false\n" + "security.local_access_only = true\n" + "[api]\n" + "tls_terminated_upstream = true\n" + 'trusted_proxies = ["10.0.0.1"]\n', + encoding="utf-8", + ) + assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 + assert "enabled=false" not in capsys.readouterr().err # the arm did not fire (auth is on) + + def test_serve_insecure_bind_clamp_keys_on_enforcement_not_tier( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: From 5cb4661dc1419f0493063cb95ae7f5e1d4a1003c Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 09:43:48 -0500 Subject: [PATCH 6/8] docs(backlog): mark #1013 fixed; record the resolved load-order question (BACKLOG #1013) Flip the #1013 banner from filed/open to fixed and record, in the AMENDED blockquote, that the load-order prerequisite the item flagged as unproven holds: `instance_exposed`'s inputs resolve where the auth-off arm runs. Name the single-definition pointer comment rather than pin its line number, since the hoist shifts that line. Banner flip only: the ranked table and the four census distribution lines were NOT recomputed. BACKLOG #1013 --- docs/BACKLOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 911cf3da..febd1fb9 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -4637,7 +4637,7 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre ## 1013. The `[auth] enabled=false` startup arm keys on the bind alone, so auth-off behind a declared terminator still starts -> 🔢 **Filed 2026-08-04 — not started.** Value **7/10** · Difficulty **4/10** · _quick win_. The auth-off startup arm reads `not settings.auth.enabled and not settings.api.is_loopback`, so it does not fire for a declared TLS-terminating proxy. A PHI instance with authentication **entirely off** behind a declared terminator starts with **no refusal and no warning** — while the same topology with auth ON but MFA off is refused by the gate #326 fixed. The two arms disagree about what "exposed" means, in the same file, for the same topology. +> ✅ **Fixed 2026-08-06.** Value **7/10** · Difficulty **4/10** · _quick win_. The auth-off startup arm read `not settings.auth.enabled and not settings.api.is_loopback` (the bind alone), so it did not fire for a declared TLS-terminating proxy: a PHI instance with authentication **entirely off** behind a declared terminator would have started with **no refusal and no warning** on first deployment — while the same topology with auth ON but MFA off is refused by the gate #326 fixed. The two arms disagreed about what "exposed" means, in the same file, for the same topology. The auth-off arm now consults the single `instance_exposed` definition (hoisted above it), so it refuses on a non-loopback bind OR a declared terminator. **Cluster:** Security / startup gates. **Priority:** P1. **Verdict:** build. **Severity:** high on first deployment — no authentication at all on an off-loopback PHI instance. @@ -4653,6 +4653,8 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre ⚠️ **THE REMEDY IS UNPROVEN — do not read this item as prescribing one.** Nobody has established that hoisting `instance_exposed` to the auth-off arm is safe. That arm runs **early** in the startup ladder, and whether the settings it reads are fully resolved at that point is unknown. **That ordering question is the actual work of this item**, not the two-line re-key it superficially resembles. +> **AMENDED 2026-08-06 — remedy proven; the load-order question is resolved.** The prerequisite this item flagged as unproven holds. `instance_exposed`'s inputs are fully resolved where the auth-off arm runs: its two fields — `settings.api.host` (through `is_loopback`) and `settings.api.tls_terminated_upstream` — are read straight off the loaded config, and the only in-place mutation of `settings.api.*` between the arm and the former definition site is `serve_ui` (twice), which the predicate does not read. So the single definition was hoisted above the auth-off arm with a byte-identical value, and the arm was widened to consult it (refuse on a non-loopback bind OR a declared terminator). Exactly one definition site remains, per the pointer comment #326 left ("`instance_exposed` is NOT re-derived here") — the hoist shifts that comment's line, so it is named rather than pinned to a number. + **#326 HAS LANDED** (PR #189), and the re-verification this paragraph asked for was performed at `17374679`: the arm moved `:1080` to `:1112`, `instance_exposed` moved `:2368` to `:1917`, `admin_exposed` is now `admin_exposed = instance_exposed` at `:1939`, and the separation narrowed from 1,288 lines to **805**. The duplicate definition at the former `:2368` is **gone**, replaced by a pointer comment at `:2454` ("`instance_exposed` is NOT re-derived here. It is defined ONCE, above"), so there is now exactly ONE definition site to move rather than two to keep in sync. **The load-bearing property survives the move and so does the difficulty-4 pricing:** the arm at `:1112` still sits ABOVE the definition at `:1917`, so it still cannot reference it without hoisting, and the ordering question is still the actual work. Only the numbers changed. ⚠️ **A consequence of #326 that this item does not cover, and that no gate can see.** Re-keying `admin_exposed` onto `instance_exposed` means the MFA-at-exposure refusal now fires on a declared-TLS-terminator topology where it previously could not — a posture change under **ASVS 6.3.3**, whose citations all still resolve, so nothing went red. Raised by the vault drift-repair pass of 2026-08-04; 6.3.3 needs re-validating against the code rather than being assumed still correct. Not folded in here. From f87143e7fc4d1be25494247864908beef7059a7e Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 12:03:19 -0500 Subject: [PATCH 7/8] docs(coord): the session record has no branch, and two rosters disagree about one A session was told a coordinator "might not be there" on the strength of the session-list MCP tool's `isRunning: false`, and separately the two rosters reported different branches for one checkout. Both readings were wrong the same way -- a field answering a question adjacent to the one asked -- and neither trap was written down anywhere a reader would look. MEASURED 2026-08-06. A session record holds exactly cwd, entrypoint, kind, name, nameSource, peerProtocol, pid, procStart, sessionId, startedAt, version. There is NO branch field. So any branch printed beside a session came from elsewhere, and the two sources answer different questions while both being labelled "branch": presence.ps1 / occupancy.ps1 the WORKTREE's branch, live from `git worktree list --porcelain`. Current. session-list MCP tool a SESSION attribute captured at registration. Does not follow a later `git switch`. For one checkout they reported two different names -- the live roster the branch that checkout had been switched onto, the session list the one it registered with. Neither was wrong. A disagreement is not evidence that either roster is broken, and a branch from the session list must never be quoted as a checkout's current branch. ALSO RECORDED, same family: `isRunning` means "currently EXECUTING A TURN", not "alive". An idle session between turns reads false while being perfectly reachable. It is not a liveness fence and must not be used as one -- Get-SessionLiveness is, subject to the rule already stated directly above it that ONLY THE POSITIVE ANSWER IS SAFE TO ACT ON. That rule is why the original inference was doubly wrong: it drew a negative conclusion from a signal that cannot support one, using a field that answers a different question. The concrete branch names are deliberately NOT quoted -- the leak gate refused the first attempt because a real worktree slug is an internal project name, and the lesson does not need them. Documentation only; no behaviour change. Both files parse, presence.ps1 still runs, 272 tests pass across the coord/presence/occupancy suites. --- scripts/coord/occupancy.ps1 | 5 +++++ scripts/coord/session-registry.ps1 | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/scripts/coord/occupancy.ps1 b/scripts/coord/occupancy.ps1 index 3b66f7f7..e3394f95 100644 --- a/scripts/coord/occupancy.ps1 +++ b/scripts/coord/occupancy.ps1 @@ -84,6 +84,11 @@ function ConvertTo-Norm([string]$p) { # Every worktree sharing one .git. Keyed on the worktree SET rather than a single path, because the # whole point is seeing siblings, not just yourself. +# The Branch this returns is the WORKTREE's, read live from git, and is therefore current at the moment +# of the call. It is NOT a session attribute: a session record carries no branch at all (see +# session-registry.ps1). The session-list MCP tool reports the branch a session STARTED on, which does +# not follow a later `git switch` -- so the two legitimately disagree for a checkout that has moved, and +# a disagreement is not evidence that either is broken. Use this one for "what is that checkout on now". function Get-RepoWorktrees([string]$RepoHint) { $gitArgs = @() if ($RepoHint) { $gitArgs = @("-C", $RepoHint) } diff --git a/scripts/coord/session-registry.ps1 b/scripts/coord/session-registry.ps1 index 69e4cacb..d5092d43 100644 --- a/scripts/coord/session-registry.ps1 +++ b/scripts/coord/session-registry.ps1 @@ -38,6 +38,31 @@ registry writes are event-driven, so nothing here can PROVE a session is gone -- only that it is present. A DEAD/STALE/not-found verdict must never by itself authorise a destructive action; combine it with an independent signal and let either one veto. + + THE RECORD CARRIES NO BRANCH, AND TWO ROSTERS DISAGREE ABOUT ONE. A session record holds exactly + `cwd, entrypoint, kind, name, nameSource, peerProtocol, pid, procStart, sessionId, startedAt, + version` -- there is no branch field and never has been. So any branch you see printed beside a + session came from somewhere else, and the two sources answer DIFFERENT QUESTIONS while both being + labelled "branch": + + presence.ps1 / occupancy.ps1 the WORKTREE's branch, read live from `git worktree list + --porcelain` (occupancy.ps1, the `branch ` porcelain line). Current + at the moment you asked. This is the one to trust for "what is that + checkout on NOW". + the session-list MCP tool a SESSION attribute captured when the session registered. It does + not track a later `git switch`, so it is the branch the session + STARTED on. + + Measured 2026-08-06: for ONE checkout the two rosters reported two DIFFERENT branch names -- the + live roster the branch that checkout had been switched onto, the session list the one it registered + with. Neither was wrong; they were answering different questions. NEVER quote a branch from the + session list as a checkout's current branch, and never treat a disagreement between the two as + evidence that either roster is broken. + + RELATED TRAP IN THE SAME FAMILY: the session list also exposes an `isRunning` flag. It means "this + session is currently EXECUTING A TURN", not "this session is alive" -- an idle session between turns + reads false while being perfectly reachable. It is not a liveness fence and must not be used as one; + that is what Get-SessionLiveness above is for, subject to the positive-answer-only rule. #> # Every config root that actually holds a session registry. From 3443de8610712026dab71f836196df4ff7938413 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 19:34:58 -0500 Subject: [PATCH 8/8] docs(supply-chain): correct at least two claims the shipped v0.3.2 release assets do not support Verified against the actual release assets, not the prose: `gh release download v0.3.2` gives an SBOM with licenses on 40/40 components and hashes on 0/40, and a VEX with `"statements": []`. Two claims did not survive that check. VEX contents. `docs/SUPPLY-CHAIN.md` described the OpenVEX asset as "our per-CVE exploitability assessments" and told the reader it "records, per CVE, whether the vulnerable code is reachable" -- a statement about the contents of a published artifact, false in any tense. It sat immediately after "Do not demand a zero-CVE clean scan", so a procurement reader who applied the VEX, saw no suppressions, and read that as an assessed all-clear would have had no assessment behind it: a compensating control resting on a false premise (CLAUDE.md section 11). The page now names the artifact, says what a statement carries when one exists, and says plainly that where we have not assessed a CVE the document is silent and the scanner's finding stands. The empty-state rule stays stated once, at security/vex/README.md:17-18 and ADR 0149, and is linked rather than re-copied -- the removed sentence was itself the divergent third copy. Component hashes. The inventory sentence enumerated "components, versions, PackageURLs, hashes, and licenses". Backfilling hashes was investigated and rejected on semantics rather than effort: CycloneDX `component.hashes` means the hash of THE file, while requirements-core.lock carries 301 `--hash=sha256:` lines over 41 packages and no package with exactly one (cryptography alone has 40). cyclonedx-py deliberately routes lock hashes to `externalReferences` for that reason, the 1.6 schema imposes no uniqueness constraint so a multi-entry set would validate clean as a silent false claim, and `pip` is an inventoried component with no lock line at all. So the sentence is corrected instead: "at least" replaces the closed enumeration, the reason given is the verified one, and no substitute integrity control is offered -- Sigstore and SLSA attest the SBOM document and our own release files, which is an adjacent question, and the lock is not a released artifact. The same false enumeration sat in scripts/security/sbom_finalize.py's docstring, one hop from the corrected page, which names that script by path in its "for auditors" section. Fixed there too rather than leaving the repo self-contradictory on the fact this commit is about. Nothing is deployed, so nobody has been misled; the defect is that the shipped page WOULD mislead a first reader who tried to verify components against hashes the SBOM does not carry. Deliberately unchanged: the true "hash-locked" phrases at :16 and :86 refer to the lock the inventory is built from, and a blanket scrub of the word would have deleted accurate claims -- the new text disambiguates them instead. No VEX statement is written here; see the notes handed to the coordinator. --- docs/SUPPLY-CHAIN.md | 20 +++++++++++++------- scripts/security/sbom_finalize.py | 7 +++++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/SUPPLY-CHAIN.md b/docs/SUPPLY-CHAIN.md index aa2e794f..ca6208d6 100644 --- a/docs/SUPPLY-CHAIN.md +++ b/docs/SUPPLY-CHAIN.md @@ -14,7 +14,7 @@ decision record is [ADR 0149](adr/0149-multi-ecosystem-sbom-vex-and-sbom-quality |---|---|---| | `messagefoundry-*.whl` / `*.tar.gz` | The Python engine (wheel + sdist) | GitHub release + PyPI | | `messagefoundry-sbom.cdx.json` | **CycloneDX SBOM** of the engine — license-complete, from the hash-locked core runtime, lifecycle = `build` | GitHub release | -| `messagefoundry-vex.openvex.json` | **OpenVEX** — our per-CVE exploitability assessments | GitHub release | +| `messagefoundry-vex.openvex.json` | **OpenVEX** — the document carrying our exploitability assessment for a CVE, once one has been made | GitHub release | | `*.sigstore*` bundles | Sigstore signatures for the wheel, sdist, **SBOM, and VEX** | GitHub release | | PEP 740 attestations | PyPI-side provenance (Trusted Publishing) | PyPI | | SLSA build provenance | in-toto attestation binding each artifact (incl. SBOM + VEX) to the source commit | GitHub attestations / Sigstore bundle | @@ -59,12 +59,15 @@ Attestations, and our releases, if you standardize on one tool across ecosystems ## Using the SBOM + VEX -The SBOM (CycloneDX 1.6) is a machine-readable inventory: components, versions, PackageURLs, hashes, and -**licenses**. Feed it to your own tooling: +The SBOM (CycloneDX 1.6) is a machine-readable inventory carrying at least a name, version, PackageURL and +**license** for every component. It does **not** carry per-component file hashes — the generator we run does +not emit them (see [How the SBOMs are generated](#how-the-sboms-are-generated-for-auditors)) — so use it as an +inventory, not as an integrity check on the components it lists. "Hash-locked" elsewhere on this page refers +to the lock file the inventory is built from, not to a field inside the SBOM. Feed it to your own tooling: ```bash -# Scan the SBOM for known CVEs, applying our VEX to suppress vulnerabilities we've assessed as -# not-affected/fixed — so you triage real risk, not unreachable CVEs: +# Scan the SBOM for known CVEs. --vex applies whatever assessments our VEX carries; --show-suppressed +# lists what was suppressed, so a run with nothing to apply is visibly a no-op: trivy sbom messagefoundry-sbom.cdx.json --vex messagefoundry-vex.openvex.json --show-suppressed # Or score the SBOM's completeness (0-10, NTIA minimum elements): @@ -72,8 +75,11 @@ sbomqs score -b messagefoundry-sbom.cdx.json ``` **Do not demand a zero-CVE "clean scan."** Per CISA's *Minimum Requirements for VEX* and NTIA's -*Software Consumers Playbook*, the correct posture is to accept a valid VEX assessment. Our VEX -(`security/vex/README.md`) records, per CVE, whether the vulnerable code is reachable in MessageFoundry. +*Software Consumers Playbook*, the correct posture is to accept a valid VEX assessment. Our VEX is the +`messagefoundry-vex.openvex.json` release asset above. Where we have assessed a CVE, its statement records +whether the vulnerable code is reachable in MessageFoundry and carries an OpenVEX `justification`. Where we +have not, the document says nothing about that CVE and your scanner's finding stands unsuppressed — see +[`security/vex/README.md`](../security/vex/README.md) for the assessment process and when a statement is added. ## How the SBOMs are generated (for auditors) diff --git a/scripts/security/sbom_finalize.py b/scripts/security/sbom_finalize.py index 3832e1ea..2ba0ec01 100644 --- a/scripts/security/sbom_finalize.py +++ b/scripts/security/sbom_finalize.py @@ -6,8 +6,11 @@ WHY THIS EXISTS (ADR 0149). The SBOM generators we run — ``cyclonedx-py environment`` (Python), ``@cyclonedx/cyclonedx-npm`` (the VS Code extension), and ``trivy image`` (the container) — emit a -CycloneDX BOM with components, hashes, licenses, and ``metadata.tools`` (the generating tool, i.e. -the draft-2025 CISA "Tool Name" minimum element). Two gaps remain that this closes: +CycloneDX BOM carrying at least components, licenses, and ``metadata.tools`` (the generating tool, i.e. +the draft-2025 CISA "Tool Name" minimum element). Per-component *hashes* are NOT among them for the +Python SBOM: ``cyclonedx-py environment`` emits none at all, so do not describe the finalized artifact +as hash-bearing (docs/SUPPLY-CHAIN.md says so to operators, and the two must not drift apart again). +Two gaps remain that this closes: 1. None of them set ``metadata.lifecycles`` — the CycloneDX field that records WHERE in the SDLC the BOM was produced. That maps to CISA's "Build" SBOM Type and the draft-2025 CISA "Generation