fix(security): stop four gates re-seeding the doc drift they caused - #85
Merged
Conversation
The documentation pass (#83) corrected five operator docs against the enforced posture. Four of those corrections were treating symptoms: the code itself named a settings key that does not exist, described its own gate against the wrong axis, silently discounted two allow-lists, and validated alert routing only in the state an operator is least likely to be in. Left alone, each re-seeds the drift the moment someone reads the source. 1. DICOM peer-control refusal named a key that is silently discarded transports/dicom.py told the operator to set `[inbound].source_ip_allowlist`. InboundSettings has no such field and section models ignore unknown keys, so an operator following the engine's OWN error message writes a key into messagefoundry.toml that is accepted and dropped — leaving a non-loopback SCP with no peer-IP gate while believing it has one. Aggravated by the gate COUNTING controls, so a spoofable AE-title list plus the discarded key passes. The message now names the working surface (an `inbound(...)` keyword, the ONLY one for a DICOM SCP since DICOM() is not authorable in connections.toml), disambiguates it from the connections.toml `[[inbound]]` key that IS real, and leads with mTLS. Same spelling corrected in the module docstring, the gate comment, wiring.py, docs/SECURITY.md and docs/ASVS-L2-PHASE0-CHANGES.md. 2. Open-egress gate counted six of eight allow-lists `egress_open` omitted allowed_smtp and allowed_direct, both of which ARE enforced downstream by _allowlist_for. A mail-only or Direct-only PHI instance could enumerate every destination it uses and still be refused as "UNRESTRICTED", with nothing naming the two lists that did not count — and the gate's own docstring already claimed otherwise. Fixed in the FAIL-CLOSED direction only: the two lists count only when [security].block_unlisted_outbound was left UNSET, which is exactly the state the deny-by-default flip turns ON. Measured, prod PHI: allowed_smtp-only and allowed_direct-only go exit 2 -> start (deny-by-default flipped ON); the explicit `block_unlisted_outbound = false` + smtp/direct-only case STILL refuses. No shipped refusal stops firing. The refusal now names that override when it is the reason a declared allowlist did not satisfy the gate. 3. Two gate comments described the deployment tier, not the enforcement dial __main__.py said "refuse (production) / warn (non-production)" over branches that read `enforcing` — which is `enforce` by default on dev and staging as much as prod, so all three REFUSE. These comments are what two BLOCKING doc defects were copied from. Comment-only, no behaviour change. 4. Alert rules were validated everywhere except where it matters notifier_from_settings returned on zero transports BEFORE cross-validating rules, so a [[alerts.rules]] block routing to a transport the instance does not configure was accepted and silently never applied — while the identical rule alongside one transport was a hard ValueError. Validation now runs first. A rule naming no transport is unaffected (still starts, now with a warning when rules exist that cannot page anyone). BREAKING: an instance that starts today with an inert rules block naming a transport will now refuse until the transport is configured. In that state the rule has never routed a single alert, so this removes no working behaviour — it converts a permanent silent no-op into a startup refusal that names the keys. Also gated at AUTHORING time: `alert add` cross-checks the rule being added, so the editor can no longer write a config that only fails at the next boot. Scoped to the added rule so a file already containing a bad rule can still be repaired with `alert remove` rather than being wedged shut. Filed, not fixed: BACKLOG #252 — the DICOM gate counting a spoofable AE-title list as a sufficient peer control. That is an ADR 0025 §9 + SECURITY.md contract change and deserves its own decision, not a rider on a message correction. Verified with the unfiltered suite (a narrow -k filter is what let a guard go red in CI last time). New regression tests pin the egress narrow form (including the loosening that deliberately did NOT happen), the alert authoring refusal, and the no-transport rule still being allowed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o claude/doc-drift-code-defects
…o claude/doc-drift-code-defects # Conflicts: # docs/BACKLOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The documentation pass in #83 corrected five operator docs against the posture the engine actually enforces. Four of those corrections were treating symptoms. The code itself named a settings key that does not exist, described its own gate against the wrong axis, silently discounted two allow-lists, and validated alert routing everywhere except the state an operator is most likely to be in.
Left alone, each re-seeds the drift the moment someone reads the source — two of the doc defects in #83 were provably copied from the stale comments fixed here.
The four fixes
1. DICOM peer-control refusal named a key that is silently discarded
transports/dicom.pytold the operator to set[inbound].source_ip_allowlist.InboundSettingshas no such field (bind_host/ack_after/stream_inflight_budget_bytesonly) and_Sectionisextra="ignore"— so an operator following the engine's own error message writes a key intomessagefoundry.tomlthat is accepted and dropped, leaving a non-loopback C-STORE SCP with no peer-IP gate while believing it has one.Aggravated by the construction gate counting controls: a spoofable
calling_ae_allowlistplus the discarded key passes the fail-closed check.The message now names the working surface — an
inbound(...)keyword, the only one for a DICOM SCP sinceDICOM()is not authorable inconnections.toml— and explicitly distinguishes it from theconnections.toml [[inbound]]key that is real, so a site running MLLP alongside DICOM cannot read it as license to delete a working allowlist. It leads with mTLS and warns in-band that an AE Title is caller-asserted. Same spelling corrected in the module docstring, the gate comment,config/wiring.py(×2),config/settings.py,docs/SECURITY.md(×3) anddocs/ASVS-L2-PHASE0-CHANGES.md.2. Open-egress gate counted six of eight allow-lists — fixed in the fail-closed direction only
egress_openomittedallowed_smtpandallowed_direct, both of which are enforced downstream by_allowlist_for(wiring_runner.py:5979-5982,:6434,:6449). A mail-only or Direct-only PHI instance could enumerate every destination it uses and still be refused as "UNRESTRICTED" — while the gate's own docstring already claimed that declaring a list was sufficient.Deliberately implemented in the narrow form: the two lists count only when
[security].block_unlisted_outboundwas left unset, which is exactly the state the deny-by-default flip (__main__.py) turns ON. Measured on--env prod:allowed_smtponly (flip unset)allowed_directonly (flip unset)block_unlisted_outbound = false+allowed_smtponlyNo shipped refusal stops firing. The instance that explicitly opted out of deny-by-default still cannot satisfy the gate on smtp/direct alone, because there the other six transports stay allow-any. The refusal now names that override when it is the reason a declared allowlist did not satisfy the gate.
3. Two gate comments described the deployment tier, not the enforcement dial
__main__.pysaid "refuse (production) / warn (non-production)" over branches that readenforcing— which isenforceby default ondevandstagingas much asprod, so all three refuse. Comment-only, no behaviour change. These are the comments two BLOCKING doc defects in #83 were copied from.4. Alert rules were validated everywhere except where it matters
notifier_from_settingsreturned on zero transports before cross-validating rules, so a[[alerts.rules]]block routing to a transport the instance does not configure was accepted and silently never applied — while the identical rule alongside one transport was a hardValueError. The fail-loud guarantee held everywhere except the state an operator is in while first setting alerts up.Validation now runs first. Verified matrix:
webhookNoneNoneNone+ warningNoneNone(unchanged)webhookemailAlso gated at authoring time:
alert addcross-checks the rule being added, so the VS Code "New Alert" command can no longer persist a config that only fails at the next boot. Scoped to the rule being added (not every rule in the file) so a file already containing a bad rule can still be repaired withalert removerather than being wedged shut.An instance that starts today with an inert
[[alerts.rules]]block naming an unconfigured transport will now refuse to start until that transport is configured.Scope:
rules presentAND≥1 rule or escalation tier sets a non-empty transportsANDwebhook_url unsetANDnot all of email_smtp_host + email_from + email_to set.The mitigating fact, which I think decides it: in that state the rule has never routed a single alert. This removes no working behaviour — it converts a permanent silent no-op into a one-line-of-config startup refusal whose message names the exact keys. Rules that name no transport keep starting, so the ordinary "write rules first, wire the transport later" flow is unaffected.
Filed, not fixed
BACKLOG #252 — the DICOM gate counts a spoofable AE-title list as a sufficient peer control. An AE Title is a caller-asserted string with no cryptographic binding, so a non-loopback SCP can pass a "fail-closed peer controls" check while being reachable by anyone who guesses one AE Title. Demoting it is an ADR 0025 §9 +
docs/SECURITY.mdcontract change and a breaking change for anyone relying on it, so it needs its own decision rather than riding in on a message correction. Number allocated viascripts/coord/alloc.ps1.Verification
pytest: 9500 passed, 817 skipped, 0 failed (14:39). No-kfilter — a narrow filter is exactly what let a guard go red in CI previously. The +5 vs the prior baseline is exactly accounted for: 4 new tests here + the backlog test docs(backlog): items 232-239 declared no status, turning main red #82 repaired.test_alerts_edit.py): it round-tripped anescalatetier routing toemailagainst a from-scratch TOML with no transport. Seeded with a real email transport so it keeps testing field round-tripping rather than becoming an accidental refusal test.ruff check/ruff formatclean (0.15.22, matchingconstraints.lock).origin/main(5d93cfbc, includes quality: re-runnable Steps-view coverage scan (BACKLOG #239) #81).Review notes
docs/CONFIGURATION.mdanddocs/CONNECTIONS.mdphantom-key sites are deliberately not touched here; docs(security): reconcile five operator docs with the enforced posture #83 already owns and fixes those files.🤖 Generated with Claude Code