Skip to content

docs: correct six security and configuration documents that state things the shipped code refutes - #435

Merged
wshallwshall merged 6 commits into
mainfrom
docs-accuracy-crypto-mfa-retention-file-surface
Aug 19, 2026
Merged

docs: correct six security and configuration documents that state things the shipped code refutes#435
wshallwshall merged 6 commits into
mainfrom
docs-accuracy-crypto-mfa-retention-file-surface

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Six documents asserted things the shipped code refutes. Each correction was written by opening the
code first and citing it; where a claim could not be verified from public source it was weakened or
dropped rather than restated.

The one a deploying site could have been harmed by

docs/PHI.md and two companions described a PHI retention startup gate that does not fire. A site
reading that text and deliberately leaving a retention window unset -- expecting the engine to refuse
to start rather than silently apply a default -- would instead have got a started instance purging
message bodies at thirty days. The text now states what the gate actually does on both enforcement
dials.

The rest

MFA. The documented opt-out named a configuration key the loader rejects; following the page as
written produces exit 2. Scope, enforcement point and setting names are corrected together, because
they were wrong as a set rather than individually.

Password context deny-list. docs/SECURITY.md called it "app/vendor/HL7 terms" and showed four
examples. Five of the twelve enforced terms are changeme, bootstrap, admin, administrator and
password -- generic credential words belonging to no vendor and no protocol -- and eight of the
twelve appeared in no document at all. So the category description was wrong, not merely incomplete,
and an operator whose passphrase was refused could not tell which rule fired. The full list is now
enumerated, the hedge is gone, and the code is named as the authority on drift. Replacing four
illustrations with twelve illustrations would not have fixed this; the hedge was the defect.

Key management. The scope sentence overstated what the policy covers, and four shipped surfaces
were missing from the inventory.

File surface and DICOM. The file-handling inventory and the size ceilings did not match the
connectors.

How the corrections are written

Where a statement was false it is retired in place, not silently swapped: the text says what the
document previously asserted and that it was wrong. A reader who acted on the old sentence needs to
know it changed, and a silent correction hides that anyone was ever misled.

Datestamps were deliberately left off the retractions. Fourteen dated corrections across six security
documents read as a remediation log, which is a different artifact than a set of accurate documents,
and dates go stale while the corrections do not.

Verification

1,769 tests passed across four tranches, no failures. Several tests in this repository parse
documentation from disk, so a docs-only change here is not test-neutral; the affected suites were run
rather than assumed. Two suites SKIP because a file they read is not present in this checkout -- they
are reported as skipped and nothing was created to satisfy them.

ruff, ruff format --check and mypy are unaffected by this change and were confirmed unchanged
against the pre-change baseline.

Scope

Documentation only. No engine behaviour changes. No new files. No assessment record was touched --
correcting a document and grading anything against it are separate acts, and only the first is in
this branch.

…ampling it

Both places that described the local-password context check called it
"app/vendor/HL7 terms" and showed four of the twelve enforced terms behind a
"like" / "e.g." hedge. The category was wrong, not just partial: five of the
twelve members are changeme, bootstrap, admin, administrator and password,
which belong to no vendor and to no protocol. An operator sizing a password
standard against that sentence would expect Bootstrap-Winter-2026! to pass,
and it is refused with no indication of which rule fired.

SECURITY.md now lists all twelve verbatim from CONTEXT_WORDS in
auth/policy.py, states that the match is case-insensitive SUBSTRING
containment rather than equality or a prefix, and names the retired
description rather than quietly widening the sample. It also records what the
knob cannot do: password_check_context is whole-list on/off, no setting adds
or removes a term, and password_breach_corpus_file is not a substitute because
it matches the whole password only.

CONFIGURATION.md's settings row carries the same correction and links rather
than repeating the list, so the two cannot drift apart again.
…names

Four claims about the second-factor requirement were refuted by the shipped
code, and the document contradicted itself on two of them.

Scope. The pathway-strength paragraph said require_mfa "scopes to local
Administrator accounts". The shipped default is require_mfa_scope =
"every_local_account" (config/settings.py), whose own comment records that the
default widens the gate past the Administrator role. The Local pathway row
sixty lines earlier already said this correctly.

Enforcement point. The same paragraph, and the MFA-state row of the
enforcement table, placed the refusal "at the step-up boundary, not as an
access gate". api/security.py evaluates it above the permission loop and
refuses every authorized route with 403 + X-MFA-Required: 1.

Setting names. [auth].require_mfa and [auth].require_mfa_scope are in
_RELOCATED_TO_SECURITY; the loader raises on them from file and environment
alike and serve exits 2. The documented opt-out therefore named a config that
cannot start. All sites now name the [security] keys and say the old spelling
is rejected. [auth].oidc_require_mfa_claim is NOT relocated and keeps its
spelling.

Delegated pathways. "_complete_ad_login mints AD, Kerberos and OIDC sessions
mfa_verified=True unconditionally" was wrong about the mechanism: it is a
keyword argument decided per mechanism, and the federated leg passes
oidc_require_mfa_claim, reached only after the claim gate has refused a token
carrying no configured amr/acr. Under the shipped default the outcome is the
same, which is why the wrong mechanism survived; turning the claim gate off
does change it, and the old text implied otherwise.

Two smaller fixes in the same cells: browser sessions are redirected to
/ui/mfa, not confined to it (the account and enrolment routes are
MFA-pending-exempt, which is what stops a zero-factor user being stranded),
and an AD principal is exempt even with a factor enrolled, so the "always
required once enrolled" clause is scoped to local accounts.

PHI.md's control row asserted the same retired step-up/Administrator framing
and is corrected with it, so the two documents agree.

tests/test_docs_security_pathways.py pinned the literal "[auth].require_mfa
defaults on" inside the section being corrected. The model field is unchanged
(AuthSettings.require_mfa is the internal desugared field); only the rendered
operator-facing key moves to [security], because the guard was pinning the
documentation to a key the loader rejects.
…issing surfaces

The section opened by claiming "a single, change-controlled inventory of every
key, algorithm, and certificate the engine relies on". The word every was
false: four shipped crypto surfaces had no row. Rows are added for all four
and the opening is reworded to an at-least shape, so the next connector does
not falsify it again.

  - the verifying SMTP hop shared by the [alerts] sink and the EMAIL and
    DIRECT destinations, whose one build_smtp_tls_context factory is what
    stands between the engine and smtplib's own unverified fallback
  - XML-DSig verification via signxml, which appeared once in the whole
    document, inside the CI blockquote, with no algorithms or trust anchor
  - the SFTP connector's SSH host-key trust and its RSA client key
  - the SMART Backend Services client assertion

Storage/access for the store DEK said "environment only". That is refuted by
three further shipped routes: a DPAPI-protected key file, the vault key
provider (keyprovider_vault.py ships and is dispatched by name behind the
[vault] extra), and cipher_provider = vault_transit, where no local key
material exists at all. Only aws_kms, azure_kv, gcp_kms and pkcs11 are
unbuilt; the settings comment still says all five are, which is what the old
bullet was written from. A site hardening key handling from that sentence
would not learn it can keep the plaintext key out of the service environment
entirely.

A distribution bullet is added, and stated as the true asymmetry rather than
a cap: the engine needs one holder, escrow is a deliberate second holder with
a stated reason, and further copies are unaccounted for by construction.
Asserting "exactly one entity" would have contradicted the escrow mandate in
the very next bullet.

A new subsection covers the five asymmetric private keys the engine loads but
does not mint. They previously had a rotation cadence and nothing else: no
generation, storage, holder or destruction policy. The DEK's rules read across
badly in both directions, which is why the split is stated explicitly -
escrowing a signing key would copy a credential whose loss costs only a
re-issue, and the DEK's discard-is-erasure property describes no other key
here.

The JWS row listed three of the five algorithms the enum ships, so a partner
requiring SHA-384 looked unsupported.

tests/test_key_usage_scope_inventory.py's curated registry classifies every
parsed row; the four new labels are added to the key-material set or the
guard fails on unclassified rows.
…ize ceilings

The ASVS 5.1.1 policy block opened with a closed count - "MessageFoundry's
file surface has three parts" - that omitted the DICOM C-STORE SCP, a fourth
surface on which a remote modality pushes whole objects into the engine. A
reader auditing the file-receiving surface from that sentence would have
missed it, and would have had no signal that the drop-directory policy below
does not govern it. Reworded to an at-least shape with the SCP named and its
controls pointed at the DICOM section.

The decompression bullet claimed that with no decompress= set "there is no
unpacked-size surface". False for a shipped payload type: a Deflated Explicit
VR LE DICOM object carries its own DEFLATE stream, the drop's content sniff
accepts it on the DICM magic alone, and the inflate is bounded elsewhere - at
16 MiB with no per-connection knob when a Router or Handler parses it, and at
max_object_bytes when an outbound SCU forwards it. Neither ceiling appeared
anywhere in operator documentation. Measured over all tracked paths under
docs/ with per-file grep -c: guard_part10_deflate, MAX_INFLATED and "Deflated
Explicit VR" each matched exactly one file, the ledger, against a positive
control of max_object_bytes matching five files in the same run.

The inbound max_object_bytes row now records the second duty that setting
carries and the counter-intuitive consequence of disabling it: 0/None does not
widen the limit, it removes the object-size check and tightens the inflate
ceiling to the 16 MiB codec default the guard falls back to.

The bullet stops short of claiming an exposure: max_file_bytes still caps the
compressed bytes at ingest where that cap is set, and whether a Handler parses
the object is site-specific, so the text says where the bound lives.
…cement dials

Three documents stated the refuse / auto-bound split backwards, and this is
the one correction in the set where a site acting on the old text loses PHI.

They said a PHI instance under [security].enforcement = enforce refuses to
start (exit 2) when a PHI-body window is unbounded, and that the 30-day
auto-bound applied only to a non-enforcing instance. The shipped gate in
__main__.py does the opposite for an UNSET window: the defaulting loop is
guarded by `if not settings.retention.allow_unbounded_phi:` alone, with no
enforcement branch, so an unset window is defaulted to 30 days and the
instance starts on both dials. tests/test_cli.py already pins this at --env
prod on the shipped enforce dial, asserting rc == 0 and a 30-day window.

The practical harm is specific: a site could deliberately leave a window unset
as a hold, expecting the boot to stop until someone chose a number, and would
instead get a started instance that begins purging PHI bodies at 30 days. The
old sentences are named as retired rather than silently swapped, because a
reader who acted on them needs to see that they changed.

What survives is the fail-closed path for an EXPLICIT 0, which is not
auto-bounded and still refuses under enforce (warns under warn). The closing
claim that a PHI instance "cannot run with PHI-body retention off without a
loud, audited opt-out" is narrowed accordingly - it holds under enforce, but
under warn an explicitly-zeroed window warns and starts with no opt-out.

Related corrections in the same gate:

  - the set of auto-bounded windows is three, not the two the prose implied,
    and reference_snapshot_days was missing. The windows that carry no
    auto-bound are described as an open set with the two clearest cases and
    their reason, rather than a count: six of the nine classified windows
    carry auto_bound_days=None, each for its own recorded reason, so naming
    two as "the" exclusions would have been a fresh false enumeration.
  - the [retention] block is no longer one of the blocks a stock PHI instance
    must configure to boot, so the worked example's gate count and its
    dead_letter_days comment (REQUIRED, "its own exit 2") are corrected to
    RECOMMENDED with the reason to set it anyway.
  - the loosening register credited enforcement = warn with the auto-bound.
    It is dial-independent; what warn actually changes for retention is that
    an explicit 0 warns instead of refusing.

Both files also now say plainly that 30 days is the engine's floor against an
accidentally unbounded window and not the site's retention policy, since an
auto-bounded window is a number nobody decided.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 17, 2026 21:01
@wshallwshall
wshallwshall disabled auto-merge August 18, 2026 04:19
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Do not merge without rebasing first: this would silently DELETE content from main

Measured with git merge-tree --write-tree origin/main <head>, then diffing the resulting tree against main -- i.e. what the merge would actually produce, not what the branch contains.

259 deletions across 8 files:

  • docs/SECURITY.md (-79)
  • tests/test_docs_security_pathways.py, tests/test_key_usage_scope_inventory.py

Why nothing would warn you

This merges CLEAN. Git conflicts on concurrent edits, never on invalidated claims -- a stale branch silently drops work it never touched, no marker appears, and every check stays green. The deletions above are main's content that this branch predates.

The remedy

Rebase onto origin/main, then re-review. The rebase is where you decide, line by line, which of these deletions were intended and which are the base being old. Do not clear the BEHIND state with update-branch and merge on green -- green does not see this.

Found during a sweep of all open PRs; five of twelve show this shape. Not a judgement on the work, only on the base.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Correction to my previous comment: I quoted the wrong side of the stat

My earlier comment said 259 deletions. That number is what this branch ADDS, not what merging would lose. I read git diff --stat in the wrong direction.

The corrected figure: merging would lose 56 line(s) that main currently has.

git diff --shortstat origin/main <merge-tree>
  -> 259 insertions (this branch's work, correctly added)
  -> 56 deletions  (main's content, silently dropped)

The finding itself stands and so does the remedy -- rebase before merging, do not clear BEHIND and merge on green -- because a clean merge still drops those 56 lines with no marker. But the magnitude was overstated and the number in my first comment should not be quoted.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

RETRACTION: my "would silently delete content" comments on this PR were wrong

Both of my earlier comments are withdrawn. The finding does not survive a discriminator, and the remedy I attached to it was justified by a defect that is not there.

What I did wrong

I counted removed lines in a merge result and reported them as lost content. Those are different questions. A line-for-line REPLACEMENT scores identically to a DELETION under a line count -- and this branch's changes are overwhelmingly rewrites.

The discriminator, run on this PR

For every line main has that the merge result lacks, I extracted a distinctive identifier and asked whether it survives anywhere in the merged tree:

removed lines carrying an identifier : 39
payload SURVIVES in the merged tree  : 39
genuinely absent                     :  0

Zero. Every removed line's payload is present in the merged result.

The concrete case that broke my own claim

I said this branch deletes initial_password_expiry_hours (ASVS 6.4.1). Measured occurrence counts:

initial_password_expiry_hours   main: 10   after merge: 25
bootstrap_expiry_hours          main: 35   after merge: 65
password_claimed_at             main:  0   after merge: 52

Not deleted -- expanded, plus a new mechanism (ADR 0164, password_claimed_at: recorded rather than inferred from mutable credential state).

What still stands, and what does not

Does not stand: any claim that merging this drops content, and the "never update-branch-and-merge-on-green" remedy insofar as it rested on that.

Still true, but unremarkable: this branch is BEHIND, and branch protection is strict: true, so it must re-sync before merging regardless.

Credit where due: a peer session caught this by verifying one file in #437 -- apt-get install lines I scored as removed were present in the merged tree wrapped in timeout and a conditional, which is that branch's entire subject. Same shape as errors I flagged in others today: the instrument answered truthfully, about the neighbouring question.

@wshallwshall
wshallwshall enabled auto-merge (squash) August 19, 2026 13:45
@wshallwshall
wshallwshall merged commit 10ed7a7 into main Aug 19, 2026
54 of 57 checks passed
@wshallwshall
wshallwshall deleted the docs-accuracy-crypto-mfa-retention-file-surface branch August 19, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant