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/BACKLOG.md b/docs/BACKLOG.md index 9ac07304..54e59319 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -3140,7 +3140,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. @@ -4659,7 +4659,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. @@ -4675,6 +4675,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. 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/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f28578b5..516ead1a 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 6a2d7115..f089d4cc 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/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/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/docs/archive/backlog/BACKLOG-CLOSED.md b/docs/archive/backlog/BACKLOG-CLOSED.md index 7586ea2f..b90a2eda 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/__main__.py b/messagefoundry/__main__.py index 2f1b92b2..2a0aaa39 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -1117,12 +1117,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 @@ -1901,9 +1920,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 @@ -1924,7 +1944,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/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 e0fb7e24..92968ff0 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, @@ -1540,6 +1544,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 @@ -1581,6 +1588,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/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. 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 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 104f0839..91a92775 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -1320,6 +1320,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_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: 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)