From 90dc30a86f93b2e814613b371de163e999086693 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 11:08:44 -0500 Subject: [PATCH 1/2] fix(tls): route four insecure-TLS escape cells through the ADR-0092 clamp (BACKLOG #329) LDAPS (auth/ldap.py), SFTP host-key (transports/remotefile.py), the webhook sink (pipeline/alert_sinks.py) and the AI-broker (transports/ai_broker.py) read the raw MEFOR_ALLOW_INSECURE_TLS escape directly; on an enforcing-PHI instance each would otherwise honour the env var on first deployment. Each now routes through the ADR-0092 weakened_tls_escape helper: SFTP is built in-gate so it uses _here(); the other three are built outside the hop scope, so the instance posture is threaded explicitly through AuthService / notifier_from_settings / ai_broker_from_settings (additive, default None = byte-identical for existing callers). The fifth cell the item names (direct.py) was already clamped in #323, so this converts the remaining four. Docs (CONNECTIONS/DEPLOYMENT/ PHI) corrected from 'not clamped'/'unclamped' to clamped. --- docs/CONNECTIONS.md | 8 +- docs/DEPLOYMENT.md | 14 +- docs/PHI.md | 2 +- messagefoundry/api/app.py | 35 ++- messagefoundry/auth/ldap.py | 31 ++- messagefoundry/auth/service.py | 10 +- messagefoundry/config/settings.py | 17 +- messagefoundry/pipeline/alert_sinks.py | 26 ++- messagefoundry/transports/ai_broker.py | 23 +- messagefoundry/transports/remotefile.py | 11 +- tests/test_hop_refusal_329.py | 281 ++++++++++++++++++++++++ 11 files changed, 408 insertions(+), 50 deletions(-) create mode 100644 tests/test_hop_refusal_329.py diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index cd4219f8..bd6bbf27 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -840,9 +840,11 @@ poll/write shape against a remote server, selected by an internal `protocol` set (`pip install 'messagefoundry[sftp]'`, lazily imported so an install that never uses SFTP skips it). **Host-key verification is ON by default** (the system host keys plus an optional extra `known_hosts`, paramiko `RejectPolicy`); an unknown key is **refused** unless `MEFOR_ALLOW_INSECURE_TLS` is set (and - loudly logged when it is). **This one cell reads the raw escape and is *not* clamped** — unlike the - `tls_verify` / `encrypt` cells elsewhere in this document, the variable still works here on a - production-PHI enforcing instance, so it is the SFTP setting to audit for rather than assume inert. + loudly logged when it is). **Since #329 this cell routes the escape through the clamped + `weakened_tls_escape_permitted_here()`** — like the `tls_verify` / `encrypt` cells elsewhere in this + document, so on a production-PHI enforcing instance the escape is inert and an unknown host key stays + refused (`RejectPolicy`) even with the variable set; it takes effect only on a non-enforcing / non-PHI + instance. - **`Ftp(...)`** — stdlib `ftplib`, **no extra**: `tls=False` is plain FTP, `tls=True` is **FTPS** (explicit TLS + `PROT P`, encrypting the control *and* data channels). FTPS **verifies the server certificate and hostname by default** (a verifying `SSLContext`, not ftplib's no-verify fallback). diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 13782a03..f28578b5 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -410,12 +410,12 @@ With it set, these otherwise-refused settings become permitted (each logs a loud - DATABASE destination / store: `Encrypt=false` or `TrustServerCertificate=true` (SQL Server), `[store].trust_server_certificate=true` / `[store].encrypt=false`. *(Clamped.)* - Plain-FTP credentials. *(Clamped.)* -- RemoteFile SFTP: accepting an unknown host key. *(Not clamped — the raw escape still applies.)* +- RemoteFile SFTP: accepting an unknown host key. *(Clamped since #329.)* - Cleartext SMTP submission on a **Direct** (S/MIME) destination. *(Not clamped; AUTH credentials over cleartext stay refused outright either way.)* - The non-connection cells that have nowhere to carry a per-hop declaration: the `[logging]` syslog/SIEM - forwarder and the API PHI-read serve hop *(both clamped)*, plus LDAPS, the webhook alert sink and the - AI-broker endpoint *(raw escape)*. + forwarder and the API PHI-read serve hop, plus LDAPS, the webhook alert sink and the + AI-broker endpoint. *(All clamped — LDAPS / the webhook sink / the AI broker since #329.)* **Two limits worth stating plainly.** *(a)* Since [ADR 0153](adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) this variable has been **unhooked from the cleartext-hop authority** — that decision no longer reads it, @@ -426,9 +426,11 @@ cleartext HTTP family are now governed only by a per-connection `cleartext_accep factory parameter and no `connections.toml` key, so it is unreachable from config today. Refusal messages that suggest it are ahead of the code.) *(b)* Where it does still apply it is mostly **clamped** (ADR 0092 decision 2 / ADR 0148): it cannot relax a hop while `[security].enforcement = -enforce`, and for the MLLP/FTPS/plain-FTP and store-TLS cells the clamp additionally requires the instance -to be PHI — which is also the default. Either way, on the shipped posture those cells are inert; the -bullets marked *not clamped* are the exceptions that still honour the raw variable. +enforce`, and for the weakened-TLS / cleartext-escape cells that route through +`weakened_tls_escape_permitted` — at least the store-TLS, MLLP/FTPS and plain-FTP cells and, since #329, +LDAPS, the SFTP host key, the webhook sink and the AI broker — the clamp additionally requires the +instance to be PHI, which is also the default. Either way, on the shipped posture those cells are inert; +the bullets marked *not clamped* are the exceptions that still honour the raw variable. **Never set `MEFOR_ALLOW_INSECURE_TLS` in production.** Its presence is the single **environment-variable** switch that turns the remaining fail-closed verification checks into best-effort. diff --git a/docs/PHI.md b/docs/PHI.md index 245602ea..420383a6 100644 --- a/docs/PHI.md +++ b/docs/PHI.md @@ -970,7 +970,7 @@ with materially different PHI profiles, so they get their own rows; stream 4 is | **7. `connection_event` table — DEFAULT ON** (`[diagnostics].connection_events = true`) | transport/lifecycle events per connection: `established`, `closed` (reason `eof` or `idle_timeout` — no path produces any other), `idle_timeout`, `at_capacity`, `peer_not_allowlisted`, `frame_oversize`, `framing_error`, `peer_reset`, the inbound-HTTP intake-auth refusals `intake_auth_failed` / `auth_subject_denied` / `auth_rate_limited` (ADR 0154 D6 — peer address and mode only; **never** the credential, a prefix of it, or its length. Each of these also writes a tamper-evident audit-log row — the copy that survives an operator turning this diagnostics stream off), plus the runner's `connection_lost` / `connection_restored`. That is the whole vocabulary, asserted in CI against the literal emit call sites in `transports/` and the pipeline runner **and** cross-checked against the console's own filter tuple. The MLLP, raw-TCP and HTTP listeners emit these; the **DICOM inbound C-STORE SCP** and the **`ISA`/`IEA`-framed X12 inbound** emit none — the runner injects the sink onto **every** source (`wiring_runner.py`, over the base-class `on_connection_event` field), so both connectors *have* the wiring and simply never call it — so this stream covers those three listeners plus the runner's outbound-lane transitions — not literally every connection. An X12 feed's connects, allow-list refusals and at-capacity refusals are therefore **absent** from this stream | rows: `ts`, `connection`, `transport`, `direction`, `kind`, `peer_host`, `message_id` (correlation hint), `reason` | the store database, **all three backends** | Corepoint-style transport diagnostics — "did the sender connect, and why did it drop" | `GET /events` and `GET /connections/{name}/events` under **`monitoring:read`** (**not** a PHI permission) with per-channel RBAC — an out-of-scope `connection=` is 403'd *and* audited — server-clamped to ≤1000 rows | `[retention].connection_event_retention_hours` (its own **hours** window); 0 inherits `[retention].messages_days`; both 0 = keep forever. Plain age `DELETE` (metadata-only) | **`reason` is free text that can carry sensitive fragments.** Defended twice — `safe_exc()` at the source, `safe_text(reason)[:200]` at the store — then cipher-encrypted (AAD `("connection_event","reason",connection,ts,kind)`). Every other column is config metadata; the table is documented **metadata-only** — never a frame, body or HL7 field value. Writes are a pure side observer: a bounded in-memory queue drained by a background task outside any handoff transaction, so a flood can never block a listener or pin a message disposition | | **8. `alert_instance` table — default on wherever an `[alerts]` notifier exists** | resolvable operator alerts: `connection_stopped`, `queue_buildup`, `lane_stuck`, `message_stall`, `saturation`, `connection_error`, `content_match`, `storage_threshold`, `cert_expiry`, `secret_rotation`, `bootstrap_admin_expiring` (the UNCLAIMED first-run bootstrap admin nearing its auto-disable deadline — ASVS 6.4.5; its payload carries only the ISO deadline plus whole hours remaining, never the password or any secret), `integrity_drift`, `update_available`, `backup_failed`, `rcsi_off_degraded`, `leadership_acquired`, `dr_activated`, `gcm_invocations` (the per-key AES-GCM invocation bound crossing its 2^31 soft warn — ASVS 11.3.4; its payload carries a one-way `key_id` fingerprint plus counters, never key bytes) The three reachable **inverse** signals — `connection_restored`, `leadership_lost`, `dr_released` — are never rows here: `_record_state` routes an inverse through `_AUTO_RESOLVE` to `resolve_alert_instances_for`, never to `upsert_alert_instance`. (A fourth mapped key, `connection_started`, is emitted by no code path today.) | rows: `event_type`, `connection`, `severity`, `status`, `first_seen`, `last_seen`, `count`, `reason`, `acked_by`, `acked_at`, `resolved_at`, `suspended_until`, `escalation_tier` | the store database, **all three backends** | the operator alert list — acknowledge / resolve / suspend. Durable state is recorded **before** any suppression or throttle return, so a muted alert still leaves a record | `GET /alerts/active` under **`monitoring:diagnose`** (**not** a PHI permission) with the same per-channel scope; ack/resolve/suspend/**resume** are POSTs on the same tier, and the separate read-only `GET /alerts/rules` view sits on its own gate | shares the connection-event window; **only RESOLVED instances are DELETEd**, by `resolved_at` — an open or acknowledged condition is never aged out from under an operator | **`reason` is free text** taken from the event's `detail`/`reason`/`label`: `safe_text(reason)[:200]` then cipher-encrypted (AAD `("alert_instance","reason",event_type,connection)` — the de-dup grain, so one AAD covers both the INSERT and the re-fire UPDATE). `content_match` is **PHI-free by contract**: the sink method takes no value parameter, only the connection, an operator label and an optional rule id | | **9. `response` rows with `kind='ack_sent'` — DEFAULT ON** (`[diagnostics].response_sent = true`) | the ACK/NAK the engine returned to an inbound sender, under a sentinel destination `\x1fack:` | rows: `ack_code` (`AA`/`AE`/`AR`/`CA`/`CE`/`CR`), `ack_phase` (`decode`/`parse`/`strict`/`ingest`), `outcome`, `body`, `detail` | the store database | "what did we actually reply, and why" — the operator's answer to a sender disputing an ACK | `GET /messages/{id}/responses` under `messages:read` + `require_phi_read`; the `body` only for a caller who also holds `messages:view_raw`; every read writes a `response.read` audit row | `body`, `detail` and `resp_headers` are set to `NULL` in place by `purge_message_bodies` on the message-body window, on all three backends | **PHI fail-safe:** the ACK **body** is stored **only when the store cipher is active** — on a keyless store it is `NULL` rather than plaintext — and every NAK passes no body at all, so the offending field value is never persisted. The disposition metadata (`ack_code`/`ack_phase`/`outcome`) is non-PHI and always captured; `detail` is `safe_text`-scrubbed, 200-char bounded and encrypted | -| **10. `[alerts]` webhook transport** (off by default — `webhook_url` unset) | one HTTPS POST per alert, carrying every non-underscore event key as JSON | JSON | the operator's webhook endpoint (Slack/Teams/PagerDuty/custom) | operator notification | **`https` only** — a plaintext `http://` webhook URL is refused at construction unless the raw `MEFOR_ALLOW_INSECURE_TLS` escape is set (and then a warning is logged); note this path reads the **unclamped** escape, unlike the connectors. Redirects are refused; an optional `webhook_allowed_hosts` egress allowlist gates the host | the endpoint's | **carries the alert's `detail`/`reason` free text** (`safe_exc()`-scrubbed at the emit sites, but **not** re-run through `safe_text` on this path). Internal `_`-prefixed keys (per-rule recipients, rule id, cooldown) are stripped before send, so recipient addresses never cross the wire | +| **10. `[alerts]` webhook transport** (off by default — `webhook_url` unset) | one HTTPS POST per alert, carrying every non-underscore event key as JSON | JSON | the operator's webhook endpoint (Slack/Teams/PagerDuty/custom) | operator notification | **`https` only** — a plaintext `http://` webhook URL is refused at construction unless the `MEFOR_ALLOW_INSECURE_TLS` escape is set (and then a warning is logged); since #329 this path routes that escape through the clamped `weakened_tls_escape_permitted(posture)` (the instance posture threaded from the API lifespan), so on an enforcing-PHI instance the escape is inert and a cleartext webhook POST stays refused — the same clamp as the connectors, no longer the raw escape. Redirects are refused; an optional `webhook_allowed_hosts` egress allowlist gates the host | the endpoint's | **carries the alert's `detail`/`reason` free text** (`safe_exc()`-scrubbed at the emit sites, but **not** re-run through `safe_text` on this path). Internal `_`-prefixed keys (per-rule recipients, rule id, cooldown) are stripped before send, so recipient addresses never cross the wire | | **11. `[alerts]` SMTP transport — operator alert list** (off unless `email_smtp_host` + `email_from` + ≥1 `email_to`) | one email per alert; default subject `[MessageFoundry] `, default body every non-underscore event key as `k: v` | plain text (always kept — never HTML-only); optional HTML alternative | the operators' mailboxes | operator notification | `smtp_allowed_hosts` egress allowlist; the SMTP password comes from `MEFOR_ALERTS_EMAIL_PASSWORD` or a `[secrets]` provider, never the config file; per-send timeout `email_timeout` | the mail system's | carries the same `detail`/`reason` free text as the webhook. #138 operator templates are constrained to a **closed non-PHI variable allowlist** validated fail-closed at config load. **Transport posture:** `send_plain_email` builds an explicit **verifying** context (chain + hostname + strict RFC 5280, TLS 1.2 floor) via `tls_policy.build_smtp_tls_context()` and passes it to `starttls()`, anchored to the OS roots, `[alerts].email_tls_ca_file`, or `[tls].internal_ca_file` — the same factory the EMAIL and DIRECT *message destinations* use, so all three SMTP cells now share one policy ([#323](BACKLOG.md), closed 2026-08-02). Before that this call passed **no** context and Python's stdlib default applied (`ssl._create_stdlib_context` **is** `ssl._create_unverified_context` — `CERT_NONE`, `check_hostname = False`), leaving the hop encrypted but unauthenticated. There is still **no hop gradient or attestation on this path** — unlike the connectors, this cell is constructed outside the `active_hop_posture` scope, so its deviations (`email_use_tls = false`, or `email_tls_verify = false`) are gated by a `[security].allow_unverified_alert_smtp_tls` **acknowledgment switch at the serve gate** rather than by the clamped escape: on an enforcing PHI instance `serve` refuses to start without it, and permits + `AUDIT`-logs the start with it. Both deviations are named by `security_loosenings()` and reported by `messagefoundry check`'s `alert-smtp-tls` advisory | | **12. Per-user security-event SMTP notifier** — **posture-mandatory on a PHI instance** | `account_locked`, `login_after_failures`, `password_changed`, `password_reset`, `email_changed`, `roles_changed`, `account_disabled`, `mfa_enabled`, `mfa_disabled`, `admin_action_new_ip` | plain-text email | the **affected user's own** mailbox | ASVS 6.3.5 / 6.3.7 out-of-band notification of security-relevant account changes | shares stream 11's SMTP transport and therefore its verifying context and its `[alerts].email_tls_*` knobs — note this is a **separate call site** (`pipeline/security_notify.py`), plumbed in its own right rather than inheriting by accident. On a PHI instance with auth enabled `serve` **refuses to start (exit 2) under `[security].enforcement = enforce`** when no effective channel exists; the explicit, **audited** opt-out is `[alerts].security_notifications_required = false` | the mail system's | the body carries the account username, a fixed description, optionally the failed-attempt count or the new email on file, and the source IP — **no message data, no secrets**. Dispatch is a bounded background queue; a failed send is logged, never raised (the event is still in `audit_log`) | | **13. `LoggingAlertSink` fallback** (when no `[alerts]` transport is configured) | every alert **this state-less sink implements**, at `WARNING` — `leadership_lost` / `dr_released` at `INFO`, and `connection_restored` is a **deliberate no-op** (a recovery needs no page and there is no instance to auto-resolve), so a lane recovery produces no record on this stream at all. `content_match` exists only on `NotifierAlertSink` and has no fallback-path record | — | folds into stream 1 | so alerts are never silent | inherits stream 1's | inherits stream 1's | includes the `detail`/`reason` free text, and therefore inherits stream 1's filters, ACL, forwarder and retention | diff --git a/messagefoundry/api/app.py b/messagefoundry/api/app.py index cc1a1833..e0fb7e24 100644 --- a/messagefoundry/api/app.py +++ b/messagefoundry/api/app.py @@ -1427,8 +1427,13 @@ async def ai_chat( ) # Build the broker from the SERVER's settings (never the request body). The SSRF endpoint-allowlist # + cleartext-credential checks run in the constructor; a misconfiguration is an operator error. + # #329: thread the derived instance posture (the same _phi_read_posture derived at create_app time + # from ai_settings, which == app.state.ai == `ai` on the managed path) so the broker's cleartext- + # http credential refusal is clamped on an enforcing-PHI instance — the escape can no longer put + # the api_key on the wire. Without this the route would build the broker with an unclamped escape + # (green and inert), the exact failure mode #329 exists to close. try: - broker = ai_broker_from_settings(ai) + broker = ai_broker_from_settings(ai, posture=_phi_read_posture) except AiBrokerError as exc: _log.warning("engine AI broker misconfigured: %s", exc) raise HTTPException(503, "engine-brokered AI assistance is not available") from exc @@ -5331,19 +5336,27 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # WITHOUT changing capacity. A no-op returning None in production / every other test, so the # engine is byte-identical when the gate is unset. Stashed for /stats + shut down in finally. app.state.connscale_executor = maybe_install_executor_shim(asyncio.get_running_loop()) + # #329: the derived instance hop posture, computed ONCE here for the OUT-OF-GATE cells this + # lifespan builds — the alerts webhook sink (notifier_from_settings) and the LDAPS bind + # (AuthService → LdapAuthenticator). Neither is built inside an active_hop_posture scope, so + # current_hop_posture() is None there and their weakened-TLS escape would ship UNCLAMPED (green + # and inert) without an explicit posture; threading this makes the ADR-0092 clamp apply on first + # deployment. None when the instance declares no [ai] (SQLite/test) → the unclamped escape, + # byte-identical. Reuses the same hop_posture_from_ai derivation the store hop and the runner use. + _hop_posture = ( + hop_posture_from_ai( + ai_settings, enforcement=(security_settings or SecuritySettings()).enforcement + ) + if ai_settings is not None + else None + ) # #200 (ADR 0092 decision 2): thread the derived instance posture so the engine<->store weakened- # TLS refusal (connection_string / _build_ssl) clamps MEFOR_ALLOW_INSECURE_TLS — the escape can # never relax a production-PHI store hop. None when no [ai] (SQLite/test) → unclamped, unchanged. store = await open_store( resolved, message_events=message_events, - posture=( - hop_posture_from_ai( - ai_settings, enforcement=(security_settings or SecuritySettings()).enforcement - ) - if ai_settings - else None - ), + posture=_hop_posture, ) # Offline uploaded-logs store (BACKLOG #125/#126, ADR 0134), on the LIVE store's cipher instance. # DISABLED (None) unless [store].uploads_dir is set, so no PHI-at-rest surface exists unless an @@ -5385,6 +5398,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # #323 layer 3: the instance [tls] internal-CA policy reaches the alerts SMTP hop too, so # an estate on a private CA needs no per-alert CA path. trust_anchor_policy=tls_settings.policy() if tls_settings else None, + # #329: clamp the webhook sink's cleartext-http escape to the derived instance posture. + posture=_hop_posture, ) if alerts_settings is not None else None @@ -5633,6 +5648,10 @@ async def _audit_upload_prune(meta: UploadedFileMeta) -> None: # central run_anchor_preflight above) rather than refusing at enforce-only. Central # preflight already ran before any listener bound; this keeps the seam consistent. enforcing=trust_anchors_enforcing, + # #329: thread the derived instance posture to the LDAPS bind so its ad_tls_verify=false + # escape is clamped on an enforcing-PHI instance (LdapAuthenticator is built out of the + # connector-construction gate, so the clamp is inert unless the posture arrives here). + hop_posture=_hop_posture, ) bootstrap = await auth.initialize() app.state.auth = auth diff --git a/messagefoundry/auth/ldap.py b/messagefoundry/auth/ldap.py index 82e1b62e..66ae634d 100644 --- a/messagefoundry/auth/ldap.py +++ b/messagefoundry/auth/ldap.py @@ -27,8 +27,9 @@ from messagefoundry.config.settings import ( INSECURE_TLS_ESCAPE_ENV, AuthSettings, - insecure_tls_allowed, + weakened_tls_escape_permitted, ) +from messagefoundry.config.tls_policy import HopPosture logger = logging.getLogger(__name__) @@ -87,7 +88,11 @@ class LdapAuthenticator: """Binds against Active Directory over LDAPS and resolves a user's (nested) group membership.""" def __init__( - self, settings: AuthSettings, *, secret_provider: SecretProvider | None = None + self, + settings: AuthSettings, + *, + secret_provider: SecretProvider | None = None, + posture: HopPosture | None = None, ) -> None: if not settings.ad_server or not settings.ad_user_search_base: raise LdapError("AD is enabled but ad_server / ad_user_search_base are not configured") @@ -105,16 +110,26 @@ def __init__( if not settings.ad_bind_dn or not self._bind_password: raise LdapError("AD is enabled but the service-account bind is not configured") self._s = settings - # A disabled-cert-verification posture (ad_tls_verify=false over LDAPS) makes the service- - # account and user binds MITM-able, so it now REFUSES at startup unless the operator sets the - # explicit MEFOR_ALLOW_INSECURE_TLS dev escape — it can no longer be silently turned on in - # production (ASVS 12.3.2). With the escape set, we still warn loudly once at startup. + # #329: the instance hop posture (threaded by AuthService from create_app's derived posture). + # LDAPS is built OUT of the connector-construction gate (AuthService, not build_check_registry), + # so current_hop_posture() would be None here; the posture must be passed explicitly or the + # clamp below would be inert. None (a direct/test/embedding construction) falls back to the + # unclamped escape — byte-identical to the pre-#329 bare read. + self._posture = posture + # A disabled-cert-verification posture (ad_tls_verify=false over LDAPS) would make the service- + # account and user binds MITM-able on first deployment, so it REFUSES at startup unless the + # operator sets the explicit MEFOR_ALLOW_INSECURE_TLS dev escape (ASVS 12.3.2). #329 routes that + # escape through the ADR-0092 clamp (weakened_tls_escape_permitted): under an enforcing-PHI + # posture the escape is INERT, so it can never silence this refusal on such an instance — the + # blunt env var no longer buys verify-off there. With the escape permitted (non-enforcing/non-PHI + # or unstamped posture), we still warn loudly once at startup. if str(settings.ad_server).lower().startswith("ldaps") and not settings.ad_tls_verify: - if not insecure_tls_allowed(): + if not weakened_tls_escape_permitted(self._posture): raise LdapError( "ad_tls_verify=false disables LDAPS certificate verification (MITM risk). Use a " f"trusted CA via ad_tls_ca_cert_file, or set {INSECURE_TLS_ESCAPE_ENV}=1 to " - "explicitly allow it for a trusted-network dev/test bind." + "explicitly allow it for a trusted-network dev/test bind (refused on an enforcing " + "PHI instance even with that override set, #329)." ) logger.warning( "AD LDAPS certificate verification is DISABLED (ad_tls_verify=false, permitted by " diff --git a/messagefoundry/auth/service.py b/messagefoundry/auth/service.py index 4fd0bf00..f18a100e 100644 --- a/messagefoundry/auth/service.py +++ b/messagefoundry/auth/service.py @@ -61,6 +61,7 @@ from messagefoundry.config.models import SignatureAlgorithm from messagefoundry.config.secretprovider import SecretProvider, resolve_connector_secret from messagefoundry.config.settings import AuthSettings +from messagefoundry.config.tls_policy import HopPosture from messagefoundry.store.base import AdminStore from messagefoundry.store.store import SessionRecord, UserRecord, WebAuthnCredential @@ -239,6 +240,7 @@ def __init__( security_notifier: SecurityNotifier | None = None, secret_provider: SecretProvider | None = None, enforcing: bool = True, + hop_posture: HopPosture | None = None, ) -> None: self._store = store self._settings = settings @@ -271,8 +273,12 @@ def __init__( self._ldap: LdapAuthenticator | None = ldap elif settings.ad_enabled: # Thread the connector SecretProvider (ADR 0019 §5) so an ad_bind_password_secret reference - # resolves the bind password from the external backend (fail-closed) at construction. - self._ldap = LdapAuthenticator(settings, secret_provider=secret_provider) + # resolves the bind password from the external backend (fail-closed) at construction. #329: + # thread the instance hop posture too — LDAPS is built out of the connector-construction gate, + # so its ad_tls_verify=false escape clamp is inert unless the posture arrives explicitly here. + self._ldap = LdapAuthenticator( + settings, secret_provider=secret_provider, posture=hop_posture + ) else: self._ldap = None # Instance-scoped (one event loop per AuthService) so it never crosses loops in tests. diff --git a/messagefoundry/config/settings.py b/messagefoundry/config/settings.py index acd0e132..672b14ee 100644 --- a/messagefoundry/config/settings.py +++ b/messagefoundry/config/settings.py @@ -213,13 +213,18 @@ def weakened_tls_escape_permitted(posture: HopPosture | None = None) -> bool: """Whether ``MEFOR_ALLOW_INSECURE_TLS`` may permit a weakened / verify-off TLS hop under ``posture``, CLAMPED so an enforcing PHI hop is NEVER relaxed (#200, ADR 0092 decision 2). - The is_phi-blind **strict verify-off** cells — the engine<->store TLS gate + The is_phi-blind **weakened-TLS / cleartext-escape** cells route their global-escape check through + here so the blunt escape can no longer silence an **enforcing PHI** refusal (matching the + ``--allow-insecure-bind`` API-bind clamp). That is **at least** the engine<->store TLS gate (:func:`~messagefoundry.store.sqlserver.connection_string` / ``store.postgres._build_ssl``), the MLLP - and FTPS ``tls_verify=false`` contexts, and the credentialed plain-``ftp`` guard — route their global- - escape check through here so the blunt escape can no longer silence an **enforcing PHI** refusal - (matching the ``--allow-insecure-bind`` API-bind clamp). Pass the construction-time - :func:`~messagefoundry.config.tls_policy.current_hop_posture` (transport cells) or the store's threaded - posture. Semantics: the escape must be set at all, AND the hop must not be enforcing PHI. ``None`` + and FTPS ``tls_verify=false`` contexts and the credentialed plain-``ftp`` guard, **and — since #329 —** + the LDAPS ``ad_tls_verify=false`` bind (:mod:`messagefoundry.auth.ldap`), the SFTP unknown-host-key + acceptance (:mod:`messagefoundry.transports.remotefile`), and the webhook-alert-sink and AI-broker + cleartext-``http`` hops. Pass the construction-time + :func:`~messagefoundry.config.tls_policy.current_hop_posture` (in-gate transport cells, via + :func:`weakened_tls_escape_permitted_here`) or an explicitly-threaded posture (the store hop and the + out-of-gate #329 cells, whose construction never stamps the contextvar). Semantics: the escape must + be set at all, AND the hop must not be enforcing PHI. ``None`` (a backup utility / embedding / test outside the construction gate) falls back to the **unclamped** escape — byte-identical to pre-#200 — since the enforced serve/reload gate already vetted the real production posture, so this fallback never loosens the clamp.""" diff --git a/messagefoundry/pipeline/alert_sinks.py b/messagefoundry/pipeline/alert_sinks.py index 550a787d..79c310c7 100644 --- a/messagefoundry/pipeline/alert_sinks.py +++ b/messagefoundry/pipeline/alert_sinks.py @@ -43,9 +43,9 @@ AlertSeverity, AlertsSettings, EscalationTier, - insecure_tls_allowed, + weakened_tls_escape_permitted, ) -from messagefoundry.config.tls_policy import TrustAnchorPolicy, build_smtp_tls_context +from messagefoundry.config.tls_policy import HopPosture, TrustAnchorPolicy, build_smtp_tls_context __all__ = [ "AlertTransport", @@ -277,18 +277,26 @@ class WebhookTransport: """POST the event as JSON to a configured URL (fronts Slack/Teams/PagerDuty/custom webhooks).""" def __init__( - self, url: str, *, timeout: float = 10.0, allowed_hosts: tuple[str, ...] = () + self, + url: str, + *, + timeout: float = 10.0, + allowed_hosts: tuple[str, ...] = (), + posture: HopPosture | None = None, ) -> None: # Refuse a plaintext http:// webhook target unless the explicit dev escape is set: the alert # POST otherwise crosses the network in cleartext (ASVS 12.2.1 — no insecure fallback). https # is the only scheme accepted by default; http(s) remain the only schemes at all (see _post). - # Same refuse-unless-MEFOR_ALLOW_INSECURE_TLS pattern as LDAPS / SQL Server / MLLP — stricter - # than the credentialed-only http refusal on REST/SOAP, since a webhook has no PHI but should - # still never fall back to cleartext. + # Same refuse-unless-MEFOR_ALLOW_INSECURE_TLS pattern as LDAPS / SQL Server / MLLP. #329: read + # the escape through the ADR-0092 clamp (weakened_tls_escape_permitted) so on an enforcing-PHI + # instance the blunt env var can never re-permit a cleartext alert POST. The webhook sink is + # built out of the connector-construction gate (notifier_from_settings, in the app lifespan), so + # the posture is threaded explicitly; None (a direct/test construction) falls back to the + # unclamped escape — byte-identical to the pre-#329 bare read. scheme = urllib.parse.urlsplit(url).scheme.lower() if scheme not in ("http", "https"): raise ValueError(f"webhook url must be http or https, got scheme {scheme!r}") - if scheme == "http" and not insecure_tls_allowed(): + if scheme == "http" and not weakened_tls_escape_permitted(posture): raise ValueError( f"webhook url {url!r} uses plaintext http; refused unless " f"{INSECURE_TLS_ESCAPE_ENV} is set (dev/trusted-network only) — use https" @@ -1168,6 +1176,7 @@ def notifier_from_settings( *, secret_provider: SecretProvider | None = None, trust_anchor_policy: TrustAnchorPolicy | None = None, + posture: HopPosture | None = None, ) -> NotifierAlertSink | None: """Build a :class:`NotifierAlertSink` from ``[alerts]`` settings, or ``None`` when no transport is configured (the caller then leaves the engine on its default logging sink). @@ -1197,6 +1206,9 @@ def notifier_from_settings( alerts.webhook_url, timeout=alerts.webhook_timeout, allowed_hosts=tuple(alerts.webhook_allowed_hosts), + # #329: thread the derived instance posture so the cleartext-http refusal is clamped on + # an enforcing-PHI instance (the escape can no longer re-permit a plaintext alert POST). + posture=posture, ) ) if alerts.email_smtp_host and alerts.email_from and alerts.email_to: diff --git a/messagefoundry/transports/ai_broker.py b/messagefoundry/transports/ai_broker.py index 450aae66..834db029 100644 --- a/messagefoundry/transports/ai_broker.py +++ b/messagefoundry/transports/ai_broker.py @@ -41,7 +41,8 @@ import urllib.request from typing import TYPE_CHECKING -from messagefoundry.config.settings import INSECURE_TLS_ESCAPE_ENV, insecure_tls_allowed +from messagefoundry.config.settings import INSECURE_TLS_ESCAPE_ENV, weakened_tls_escape_permitted +from messagefoundry.config.tls_policy import HopPosture # Reuse rest.py's hardened, TLS-verifying, no-redirect opener + URL redaction (no new HTTP plumbing) — # exactly as smart.py / fhir.py / soap.py do. No import cycle: rest.py never imports this module. @@ -114,6 +115,7 @@ def __init__( model: str = "claude-opus-4-8", max_output_tokens: int = _DEFAULT_MAX_OUTPUT_TOKENS, timeout_seconds: float = _DEFAULT_TIMEOUT, + posture: HopPosture | None = None, ) -> None: if not endpoint: raise AiBrokerError( @@ -136,8 +138,13 @@ def __init__( "list it explicitly to permit engine-brokered AI egress (SSRF fail-closed)" ) # The api_key is a credential — refuse to send it over cleartext http (mirrors smart.py's token - # endpoint), unless the dev escape is set for a trusted-network dev/test box. - if scheme == "http" and not insecure_tls_allowed(): + # endpoint), unless the dev escape is set for a trusted-network dev/test box. #329: read the + # escape through the ADR-0092 clamp (weakened_tls_escape_permitted) so on an enforcing-PHI + # instance the blunt env var can never re-permit the key on the wire. The broker is built out of + # the connector-construction gate (the create_app ai_chat route), so the posture is threaded + # explicitly; None (a direct/test construction) falls back to the unclamped escape — byte- + # identical to the pre-#329 bare read. + if scheme == "http" and not weakened_tls_escape_permitted(posture): raise AiBrokerError( "[ai].endpoint over cleartext http would expose the api_key; refused unless " f"{INSECURE_TLS_ESCAPE_ENV} is set (dev/trusted-network only) — use https" @@ -239,14 +246,20 @@ def _extract_text(self, body: str) -> str: return text -def ai_broker_from_settings(ai: AiSettings) -> AiBroker: +def ai_broker_from_settings(ai: AiSettings, *, posture: HopPosture | None = None) -> AiBroker: """Build the :class:`AiBroker` from the loaded ``[ai]`` settings, or raise :class:`AiBrokerError` when the engine broker is not fully configured. Settings arrive already ``env()``-resolved (the API - lifespan stashes the resolved :class:`AiSettings` on ``app.state.ai``).""" + lifespan stashes the resolved :class:`AiSettings` on ``app.state.ai``). + + ``posture`` (#329) is the derived instance hop posture, threaded from the create_app ai_chat route so + the broker's cleartext-http credential refusal is clamped on an enforcing-PHI instance (the escape + can no longer put the ``api_key`` on the wire there). ``None`` = the unclamped escape, byte-identical + to before.""" return AiBroker( endpoint=ai.endpoint or "", api_key=ai.api_key or "", allowed_endpoints=list(ai.allowed_endpoints), provider=ai.provider, model=ai.model, + posture=posture, ) diff --git a/messagefoundry/transports/remotefile.py b/messagefoundry/transports/remotefile.py index ae737648..bf68157a 100644 --- a/messagefoundry/transports/remotefile.py +++ b/messagefoundry/transports/remotefile.py @@ -53,7 +53,6 @@ from messagefoundry.config.models import ConnectorType, ContentType, Destination, Source from messagefoundry.config.settings import ( INSECURE_TLS_ESCAPE_ENV, - insecure_tls_allowed, weakened_tls_escape_permitted_here, ) from messagefoundry.config.tls_policy import ( @@ -370,9 +369,13 @@ def __init__(self, settings: dict[str, Any]) -> None: self._known_hosts = settings.get("known_hosts") self._timeout = float(settings.get("connect_timeout", 30.0)) # Fail fast at construction (build_check time): an unknown-host-key posture without the escape - # must never silently weaken to auto-accept. The accept-unknown policy is gated here so the - # connector refuses to build rather than trust-on-first-use a man-in-the-middle. - self._accept_unknown = insecure_tls_allowed() + # must never silently weaken to auto-accept. #329: read the escape through the ADR-0092 clamp + # (weakened_tls_escape_permitted_here consults the active construction posture, exactly like the + # FTPS tls_verify=false sibling at :176 that this class is built alongside), so under an + # enforcing-PHI posture the escape is INERT — the accept-unknown policy then stays RejectPolicy + # and an unknown host key is refused at connect (:392-394), as today. Off the construction gate + # (posture unstamped) the escape is unclamped, byte-identical to the pre-#329 bare read. + self._accept_unknown = weakened_tls_escape_permitted_here() if self._accept_unknown: logger.warning( "REMOTEFILE sftp %s accepts UNKNOWN host keys (AutoAddPolicy) because %s is set " diff --git a/tests/test_hop_refusal_329.py b/tests/test_hop_refusal_329.py new file mode 100644 index 00000000..4b653764 --- /dev/null +++ b/tests/test_hop_refusal_329.py @@ -0,0 +1,281 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""#329 — route the four remaining ``MEFOR_ALLOW_INSECURE_TLS`` cells through the ADR-0092 clamp. + +Before this lane four cells still consulted the raw ``insecure_tls_allowed()`` predicate, so on first +deployment each WOULD permit weakened TLS regardless of the posture clamp: the SFTP unknown-host-key +acceptance (in-gate), and the LDAPS ``ad_tls_verify=false`` bind, the webhook alert sink and the +AI-broker cleartext-http credential hop (all out-of-gate). This suite proves each cell now REFUSES an +enforcing-PHI hop even with the escape set, that a non-enforcing / unstamped posture still crosses +(byte-identical to before), that the secure path is untouched, and — the load-bearing guard against the +item's headline "green and inert" risk — that the out-of-gate factories/constructors actually THREAD a +non-``None`` posture through the real ``create_app`` route and ``AuthService``/notifier seams. +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator +from pathlib import Path + +import httpx +import pytest + +from messagefoundry.api import create_app +from messagefoundry.auth.ldap import LdapAuthenticator, LdapError +from messagefoundry.auth.service import AuthService +from messagefoundry.config.ai_policy import AiMode +from messagefoundry.config.settings import ( + INSECURE_TLS_ESCAPE_ENV, + AiSettings, + AlertsSettings, + AuthSettings, +) +from messagefoundry.config.tls_policy import HopPosture, active_hop_posture +from messagefoundry.pipeline import Engine +from messagefoundry.pipeline.alert_sinks import WebhookTransport, notifier_from_settings +from messagefoundry.store.store import MessageStore +from messagefoundry.transports import ai_broker as ai_broker_mod +from messagefoundry.transports.ai_broker import AiBroker, AiBrokerError, ai_broker_from_settings +from messagefoundry.transports.remotefile import _SftpClient + +# Mirror tests/test_hop_refusal_serve_clamp.py so the two suites decide against the same postures. +PROD_PHI = HopPosture(is_phi=True, enforcing=True) +STAGING_PHI = HopPosture(is_phi=True, enforcing=False) +SYNTHETIC = HopPosture(is_phi=False, enforcing=False) # dev / synthetic instance (no PHI) + + +@pytest.fixture +async def store(tmp_path: Path) -> AsyncIterator[MessageStore]: + s = await MessageStore.open(tmp_path / "hop329.db") + yield s + await s.close() + + +# --- Cell (2): SFTP host key (IN-GATE, reads current_hop_posture via _here) --------------------- + + +def test_sftp_host_key_clamped_prod_phi_even_with_escape(monkeypatch: pytest.MonkeyPatch) -> None: + # In-gate cell: with the escape set, the unknown-host-key auto-accept is CLAMPED under an enforcing + # PHI posture (accept_unknown stays False → RejectPolicy), but a non-enforcing / synthetic posture + # still auto-accepts. Construction reads only the posture, so no paramiko fake is needed here. + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + s = {"host": "h", "port": 22, "remote_dir": "/in"} + with active_hop_posture(PROD_PHI): + assert _SftpClient(s)._accept_unknown is False # clamped: enforcing PHI refuses the escape + with active_hop_posture(STAGING_PHI): + assert ( + _SftpClient(s)._accept_unknown is True + ) # non-enforcing PHI still crosses with the escape + with active_hop_posture(SYNTHETIC): + assert _SftpClient(s)._accept_unknown is True # synthetic instance still crosses + + +def test_sftp_host_key_fail_closed_without_escape(monkeypatch: pytest.MonkeyPatch) -> None: + # Escape-off negative control: no posture crosses (unchanged fail-closed). Asserting True here would + # red immediately — proof the assertion binds something. + monkeypatch.delenv(INSECURE_TLS_ESCAPE_ENV, raising=False) + s = {"host": "h", "port": 22, "remote_dir": "/in"} + with active_hop_posture(PROD_PHI): + assert _SftpClient(s)._accept_unknown is False + # Unstamped (no active_hop_posture) with the escape off is also fail-closed. + assert _SftpClient(s)._accept_unknown is False + + +# --- Cell (1): LDAPS ad_tls_verify=false (OUT-OF-GATE, explicit posture) ------------------------ + + +def _ldaps_verifyoff() -> AuthSettings: + return AuthSettings( + ad_enabled=True, + ad_server="ldaps://dc.example.com", + ad_user_search_base="DC=example,DC=com", + ad_bind_dn="CN=svc,DC=example,DC=com", + ad_bind_password="synthetic-bind-secret", + ad_tls_verify=False, + ) + + +def test_ldaps_verifyoff_clamped_prod_phi_even_with_escape( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + s = _ldaps_verifyoff() + # Enforcing PHI: refused even with the escape (the clamp bites). + with pytest.raises(LdapError, match="ad_tls_verify=false"): + LdapAuthenticator(s, posture=PROD_PHI) + # Non-enforcing PHI: crosses with the escape (warned loudly). + with caplog.at_level(logging.WARNING): + LdapAuthenticator(s, posture=STAGING_PHI) + assert any("DISABLED" in r.getMessage() for r in caplog.records) + # Unstamped posture (a direct/test construction) falls back to the unclamped escape — byte-identical. + LdapAuthenticator(s, posture=None) + + +async def test_ldaps_authservice_threads_posture( + store: MessageStore, monkeypatch: pytest.MonkeyPatch +) -> None: + # Seam: AuthService must thread hop_posture into the LdapAuthenticator it builds. With an enforcing + # PHI posture + the escape set, constructing the service raises LdapError — proof the posture reaches + # the cell. If the seam dropped it (posture=None), the escape would be unclamped and the build would + # succeed. + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + with pytest.raises(LdapError, match="ad_tls_verify=false"): + AuthService(store, _ldaps_verifyoff(), hop_posture=PROD_PHI) + # A non-enforcing posture builds the service without raising (crosses with the escape). + AuthService(store, _ldaps_verifyoff(), hop_posture=STAGING_PHI) + + +def test_ldaps_verifying_secure_path_unaffected(monkeypatch: pytest.MonkeyPatch) -> None: + # Secure-path negative control: ad_tls_verify=True (verifying LDAPS) + enforcing PHI + escape UNSET + # builds with no error — the guard only fires on the verify-OFF path, so #329 leaves the secure path + # byte-identical. + monkeypatch.delenv(INSECURE_TLS_ESCAPE_ENV, raising=False) + s = AuthSettings( + ad_enabled=True, + ad_server="ldaps://dc.example.com", + ad_user_search_base="DC=example,DC=com", + ad_bind_dn="CN=svc,DC=example,DC=com", + ad_bind_password="synthetic-bind-secret", + ad_tls_verify=True, + ) + LdapAuthenticator(s, posture=PROD_PHI) # no raise: verifying LDAPS is unaffected by the change + + +# --- Cell (3): webhook alert sink cleartext http (OUT-OF-GATE, explicit posture) ---------------- + + +def test_webhook_cleartext_clamped_prod_phi_even_with_escape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + with pytest.raises(ValueError, match="plaintext http"): + WebhookTransport("http://hooks.example/x", posture=PROD_PHI) + # Non-enforcing PHI and unstamped both cross with the escape (byte-identical to before). + WebhookTransport("http://hooks.example/x", posture=STAGING_PHI) + WebhookTransport("http://hooks.example/x", posture=None) + + +def test_webhook_notifier_threads_posture(monkeypatch: pytest.MonkeyPatch) -> None: + # Seam: notifier_from_settings must thread posture into the WebhookTransport it builds. + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + with pytest.raises(ValueError, match="plaintext http"): + notifier_from_settings( + AlertsSettings(webhook_url="http://hooks.example/x"), posture=PROD_PHI + ) + # Non-enforcing posture builds the notifier (crosses with the escape). + notifier_from_settings( + AlertsSettings(webhook_url="http://hooks.example/x"), posture=STAGING_PHI + ) + + +def test_webhook_https_secure_path_unaffected(monkeypatch: pytest.MonkeyPatch) -> None: + # Negative control: an https webhook is never refused, whatever the posture. + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + WebhookTransport("https://hooks.example/x", posture=PROD_PHI) + + +# --- Cell (4): AI-broker cleartext http credential hop (OUT-OF-GATE, explicit posture) ---------- + +_HTTP_ENDPOINT = "http://ai.internal/v1/messages" +_HTTPS_ENDPOINT = "https://ai.internal/v1/messages" + + +def _managed_ai(**over: object) -> AiSettings: + kw: dict[str, object] = { + "mode": AiMode.MANAGED_ENDPOINT, + "environment": "prod", + "endpoint": _HTTP_ENDPOINT, + "api_key": "sk-synthetic-key", + "allowed_endpoints": ["ai.internal"], + } + kw.update(over) + return AiSettings(**kw) # type: ignore[arg-type] + + +def test_ai_broker_cleartext_clamped_prod_phi_even_with_escape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + + def _build(posture: HopPosture | None) -> AiBroker: + return AiBroker( + endpoint=_HTTP_ENDPOINT, + api_key="k", + allowed_endpoints=["ai.internal"], + posture=posture, + ) + + with pytest.raises(AiBrokerError, match="cleartext http"): + _build(PROD_PHI) + _build(STAGING_PHI) # crosses with the escape on non-enforcing PHI + _build(None) # unstamped falls back to the unclamped escape — byte-identical + + +def test_ai_broker_factory_threads_posture(monkeypatch: pytest.MonkeyPatch) -> None: + # Seam: ai_broker_from_settings must thread posture into the AiBroker it builds. + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + with pytest.raises(AiBrokerError, match="cleartext http"): + ai_broker_from_settings(_managed_ai(), posture=PROD_PHI) + ai_broker_from_settings(_managed_ai(), posture=STAGING_PHI) # crosses on non-enforcing PHI + + +def test_ai_broker_https_secure_path_unaffected(monkeypatch: pytest.MonkeyPatch) -> None: + # Negative control: an https endpoint is never refused on the cleartext arm, whatever the posture. + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + AiBroker( + endpoint=_HTTPS_ENDPOINT, api_key="k", allowed_endpoints=["ai.internal"], posture=PROD_PHI + ) + + +# --- Anti-"green-and-inert" WIRING test: the real create_app ai_chat route threads the posture ----- + + +@pytest.fixture +async def engine(tmp_path: Path) -> AsyncIterator[Engine]: + eng = await Engine.create(tmp_path / "ai_wiring.db", poll_interval=0.02) + yield eng + await eng.stop() + + +def _client(app: object) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=app) # type: ignore[arg-type] + return httpx.AsyncClient(transport=transport, base_url="http://t") + + +@pytest.fixture +def stub_chat(monkeypatch: pytest.MonkeyPatch) -> None: + # If (and only if) the broker BUILDS, a canned reply keeps the route off the network — so a route + # that fails to thread the posture returns 200, not a network 502. That makes the 503-vs-not signal + # crisp for the falsification below. + def fake_chat(self: AiBroker, prompt: str) -> str: + return "def handle(msg): return Send('OB', msg)" + + monkeypatch.setattr(ai_broker_mod.AiBroker, "chat", fake_chat) + + +async def test_ai_chat_route_refuses_cleartext_under_enforcing_phi( + engine: Engine, monkeypatch: pytest.MonkeyPatch, stub_chat: None +) -> None: + # The load-bearing wiring guard: through the REAL route, a prod (enforcing-PHI) instance with the + # escape set must REFUSE to build the broker over a cleartext-http endpoint → HTTP 503. This proves + # create_app actually threads a non-None posture into ai_broker_from_settings (the "green and inert" + # failure the item warns of). FALSIFY: drop `posture=_phi_read_posture` in the ai_chat route → the + # broker builds over http and the route returns 200 (chat stubbed) instead of 503. + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + app = create_app(engine, ai_settings=_managed_ai(endpoint=_HTTP_ENDPOINT), allow_no_auth=True) + async with _client(app) as c: + r = await c.post("/ai/chat", json={"prompt": "hi"}) + assert r.status_code == 503 # broker refused by the clamp through the real route + + +async def test_ai_chat_route_allows_https_under_enforcing_phi( + engine: Engine, monkeypatch: pytest.MonkeyPatch, stub_chat: None +) -> None: + # Companion control: the SAME enforcing-PHI + escape setup over an HTTPS endpoint is NOT 503 — so the + # 503 above is the cleartext clamp, not a blanket refusal of the route. + monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") + app = create_app(engine, ai_settings=_managed_ai(endpoint=_HTTPS_ENDPOINT), allow_no_auth=True) + async with _client(app) as c: + r = await c.post("/ai/chat", json={"prompt": "hi"}) + assert r.status_code == 200 From bb8ee564bb982fd017355bbe4977602545bed5d4 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 11:10:45 -0500 Subject: [PATCH 2/2] docs(backlog): flip #329 to shipped -- four insecure-TLS cells clamped (BACKLOG #329) Banner line only (leaves the 2026-08-03 amendment note); census not recomputed. --- docs/BACKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 911cf3da..af89ca2a 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2859,7 +2859,7 @@ This is **wider than the disclosure describes.** [`CONFIGURATION.md:718`](CONFIG ## 329. Five `MEFOR_ALLOW_INSECURE_TLS` cells bypass the ADR 0092 clamp -> 🔢 **Filed 2026-08-01 — not started.** Value **6/10** · Difficulty **4/10** · _quick win_. The LDAPS bind (`ssl.CERT_NONE` on the authentication substrate for every AD identity), the SFTP host key, the webhook sink and the `[ai].api_key` still cross an enforcing production-PHI posture on one env var, and converting them is what collapses five per-site facts into one repo-wide invariant the ASVS scorecard's regex mechanism can actually express — bounded because setting the variable needs Administrator, who can already do worse; the cheap in-gate half shipped with #323, so what remains is threading an explicit posture into `AuthService`/`create_app`'s three out-of-gate constructors, where `_here()` would otherwise ship green and inert. +> ✅ **SHIPPED 2026-08-06 — the four out-of-gate insecure-TLS cells now route through the ADR-0092 clamp.** Value **6/10** · Difficulty **4/10** · _quick win_. LDAPS (`auth/ldap.py`), the SFTP host key (`transports/remotefile.py`), the webhook sink (`pipeline/alert_sinks.py`) and the AI-broker (`transports/ai_broker.py`) now gate the `MEFOR_ALLOW_INSECURE_TLS` escape through `weakened_tls_escape_permitted[_here]` — the instance posture threaded into `AuthService` / `create_app`'s out-of-gate constructors — so on an enforcing production-PHI instance the escape is inert and an unverified/cleartext hop stays refused. The fifth cell the heading names (Direct SMTP) was already clamped in #323, so this converted the remaining four. > ⚠️ **AMENDED 2026-08-03 — the census is FOUR, not five: #323 landed and took the Direct SMTP cell.** The heading, the evidence table (*"Confirmed at HEAD"*) and Proposed §1 all still name `transports/direct.py:170` as an unclamped cell, but that file now holds **no call to the raw predicate at all** — it imports only `weakened_tls_escape_permitted_here` (`messagefoundry/transports/direct.py:63`) and gates both arms on it (`:197` cleartext SMTP, `:215` `tls_verify=false`), with the #323 rationale — including its own warning that this absence is scoped to that file and never repo-wide — at `:182-196`; `:170` is now unrelated cert-loading. The Scope note called this in future tense and the 2026-08-03 banner already enumerates only four cells while still calling them *"five per-site facts"*, so read the table as **at least four** sites still reading the unclamped `insecure_tls_allowed()`: the SFTP host key (`messagefoundry/transports/remotefile.py:375`, feeding `AutoAddPolicy`/`RejectPolicy` at `:392-394`), LDAPS (`messagefoundry/auth/ldap.py:113`), the webhook alert sink (`messagefoundry/pipeline/alert_sinks.py:291` — the item cites `:290`) and the AI broker (`messagefoundry/transports/ai_broker.py:140`). >