Skip to content

feat(security): close ASVS 4.2.5 and 9.2.2, and the guards that could not see them - #39

Merged
wshallwshall merged 7 commits into
mainfrom
claude/asvs-partials-pass-scores-2de207
Jul 29, 2026
Merged

feat(security): close ASVS 4.2.5 and 9.2.2, and the guards that could not see them#39
wshallwshall merged 7 commits into
mainfrom
claude/asvs-partials-pass-scores-2de207

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Closes two ASVS 5.0 L3 cells and fixes three defects found while building them. Every guard here was
mutation-verified: the fix was reverted and the test confirmed to red for its own reason, because a
green dot on a test that would pass either way is what let the original gaps ship.

ASVS 9.2.2 — assert the id_token's declared class (1ce3a1a1)

An access token (typ: at+jwt, RFC 9068), a back-channel logout token, and an RFC 8417 security event
token are minted by the same issuer under the same key as the id_token. Every key and signature
rung passes on them, so the only thing between an access token and an accepted federated login was
nonce equality.

Three rungs, each with its own closed-set slug so verify --section federation still indicts exactly
one rung:

  • wrong_token_type at the top of key selection, so a wrong-class token is not audited as
    unknown_kid. An absent typ is still accepted — RFC 7519 §5.1 makes it advisory and refusing
    it would lock out conforming IdPs. Normalisation lower-cases before stripping application/; the
    other order refuses a legal Application/JWT.
  • unexpected_events_claim ahead of the nonce compare. A logout token carries no nonce, so in
    ladder order it would be refused as nonce_mismatch — telling the operator the browser binding
    failed when the IdP actually sent the wrong token class.
  • claim_sub_missing, plus iat made mandatory. Both are REQUIRED of an id_token by OIDC Core 2.

Defect fixed in passing: sub was read with an else "" fallback, so a token without one minted
FederatedPrincipal(subject="") — and that empty string was written into the auth.login_success
audit as though it were evidence.

ASVS 4.2.5 — bound what actually ships (17d8c822, 8a61e4a4)

The 8 KiB bound ran only at connector construction, so it covered only statically configured
values. Three classes are added after it and were unbounded: per-message headers from message
metadata, the per-call URL a FHIR read/write builds, and the server-minted SMART bearer / detached-JWS
headers — stamped in after the point any naive fix would guard.

The failure class is computed, not guessed, because it decides the disposition:

  • message-derived → permanent NAK (the same message overflows on every retry; retrying is a
    guaranteed-futile loop that also holds the lane).
  • server-minted credential → permanent + credential_fault, and the provider is invalidated. The
    provider caches, so without invalidate() every retry re-sends a byte-identical bad token; with
    invalidate() but no credential_fault, every retry re-signs a client assertion and POSTs the IdP.
    credential_fault_policy exists to stop exactly that re-auth storm.

PHI egress: the message-derived arm emits the class and length only, never the header name.
At construction a name is operator-static; at send time it is derived from a message-metadata key, and
that string reaches last_error, message_events.detail, and — on the DeliveryError arm — the
webhook AlertSink, i.e. off-box.

Also closed, each a distinct gap rather than a missing call:

  • FhirLookupExecutor never called the gate at allfhir.py's only call site was the
    destination's __init__, so a lookup's base, its per-call read URL (the most message-derived URL
    in the engine) and its minted bearer were unmeasured on both sides.
  • dicomweb bounded base_url while _target_url is derived from it 33 lines later and is what
    actually ships — an ordering gap.
  • store/keyprovider_vault.py (the Vault token rides X-Vault-Token on every Transit call),
    apiclient/client.py, the SMART and OAuth2-CC token endpoints, ai_broker, the webhook sink, and
    the REST/FHIR probes.

apiclient's constants are duplicated rather than imported — ADR 0088 keeps that package
engine-free, so the sharing import is the coupling it exists to avoid — and pinned equal by a test
that imports both sides in a test process.

One residual is named rather than closed: tray/probe.py, documented in its own docstring with
the three conditions that make it proportionate (tokenless, local operator config, stdlib-only per ADR
0113) and the trigger that reopens it.

Rotation coverage (fdb3f194)

MEFOR_AI_API_KEY was a registered critical secret with a documented rotation cadence, and was
absent from _ENV_SECRET_CLASSES — so the watcher never fingerprinted the one credential the
documentation tells operators to rotate. Enumeration completeness was already guarded; rotation
coverage was not, which is why it sat green.

Adds the class plus the gate: every fixed MEFOR_* critical secret must be fingerprinted or
explicitly excused with its reason. The five exclusions are recorded rather than left as absences.

This does not move ASVS 13.3.4 — that cell is Partial because the enforce arm alerts where the
requirement says expire.

Guard-the-guard (54e02725)

Every assertion in the threat-model drift suite pins a value that is rendered, so a sentence
asserting a bound's absence was invisible by construction. THREAT-MODEL.md claimed there was no
aggregate HL7 escape-expansion budget for the entire life of peek.enforce_expansion_budget, and
re-inserting that exact sentence left all 81 tests green. Two guards added; the shipped symbol is
asserted rather than probed, because a guard that skipped when the control vanished would pass
hardest exactly when the bound disappeared.

Behaviour changes worth a release note

  • A message shipping a >8 KiB URL or header now dead-letters or retries instead of sending. No
    legitimate config is affected (a normal RS256 signature header is 364 chars).
  • An IdP stamping a non-jwt typ is refused after upgrade. Blast radius bounded by
    [auth].oidc_enabled defaulting off.

Verification

  • 9421 passed on the merged tree. The single failure (test_installed_metadata_matches_dunder_version)
    is a local-venv artifact — this worktree has no .venv, so the run used a stale 0.3.0 install
    against 0.3.2 source. It passes on a fresh CI install.
  • All 14 mutations re-run after merging ADR 0153 and still red for their own reason. That was the
    point of merging rather than deferring: a merge can leave tests passing while silently disarming the
    guard they cover.
  • ruff format / ruff check / mypy strict clean; bandit and the leak gate green with a real token
    source loaded.

Not claimed here

ADR 0153's effect on the ASVS score is unassessed. It is a default-flip wave on the cleartext-hop
decision — the class of change that moves cells — and it names no ASVS cells, so the mapping is real
work. The scorecard correction accompanying this branch is anchored before it and is a floor for
those cells, not a verdict.

🤖 Generated with Claude Code

…d as absent

Every assertion in the drift suite pins a value that IS rendered, so a sentence
asserting a bound's ABSENCE is invisible by construction. That is not
hypothetical: THREAT-MODEL.md stated "There is no aggregate per-message budget"
for the entire life of peek.enforce_expansion_budget, and re-inserting that exact
sentence left all 81 tests green. ASVS 15.1.3 scores the document, so the stale
claim was the control defect -- a false statement about the most
attacker-exposed bound in the product.

Two guards, each mutation-verified to red for the right reason:

- MAX_COUNTED_ESCAPE_OPENERS pinned into the escape-expansion row, so the
  measurement's own cap cannot drift doc-side (mutating 100,000 -> 250,000 reds).
- test_the_document_does_not_deny_a_bound_the_code_implements: a (shipped symbol,
  forbidden phrases) table. The symbol is ASSERTED rather than probed -- a guard
  that skipped when the control vanished would pass hardest exactly when the
  bound disappeared.

Limits stated in the docstrings rather than implied: it matches a fixed phrase
list, not paraphrase; and docs/security/** is untracked here, so _doc_text()
takes its accessor skip and both guards are inert on origin/main. They run in
the vault and in a local checkout with the docs materialized.

The document correction itself is vault-bound and not in this commit.
…ure (ASVS 9.2.2)

An access token (typ: at+jwt, RFC 9068), a back-channel logout token
(logout+jwt) and an RFC 8417 security event token are minted by the SAME
issuer under the SAME key as the id_token. Every key and signature rung passes
on them, so the only thing standing between an access token and an accepted
federated login was nonce equality.

Three rungs added, each with its own closed-set slug so `verify --section
federation` still indicts exactly one rung:

- wrong_token_type, at the TOP of key selection (before the kid guard and the
  alg pin, so a wrong-class token is not audited as unknown_kid). An ABSENT typ
  is still accepted: RFC 7519 5.1 makes the header advisory and refusing it
  would lock out conforming IdPs. Normalisation lower-cases BEFORE stripping
  the application/ prefix -- the other order refuses a legal Application/JWT.
- unexpected_events_claim, ahead of every other claim check. A logout token
  carries no nonce, so in ladder order it would be refused as nonce_mismatch,
  telling the operator the browser binding failed when the IdP actually sent
  the wrong token class.
- claim_sub_missing, plus iat made mandatory (reusing claim_not_numeric). Both
  are REQUIRED of an id_token by OIDC Core 2. sub was previously read with an
  `else ""` fallback, so a token without one minted FederatedPrincipal(subject="")
  and that empty string was written into the auth.login_success audit as though
  it were evidence.

Six mutations, each verified red for its own reason: delete the typ call ->
DID NOT RAISE; swap the normalisation order -> Application/JWT refused; move
the events check below the nonce compare -> 'nonce_mismatch' ==
'unexpected_events_claim'; restore the optional-iat guard -> DID NOT RAISE;
restore the empty-sub fallback -> DID NOT RAISE; map a slug to two rungs ->
the closed-set guard names both.

The hand-mint helper signs the real header bytes rather than splicing, because
CompactJwtSigner hardcodes typ:"JWT" and a retargeted signature would make
every test here pass for the wrong reason and stay green if the assertion were
deleted. A guard-the-guard test pins that the helper mints a VALID token.

ADR 0142 AC-3 amended, with a forward note: a back-channel-logout receiver
(cell 10.5.5) needs its own logout-token ladder -- relaxing
unexpected_events_claim to reuse validate_id_token would silently reopen this.

Fail-closed on a live login path: an IdP stamping a non-jwt typ is refused
after upgrade. Blast radius is bounded by [auth].oidc_enabled defaulting off.

The scorecard flip for this cell is vault-bound and not in this commit.
…(ASVS 4.2.5)

The 8 KiB URL/header bound was imposed only at connector CONSTRUCTION, so it
covered only statically configured values. Three classes are added after it and
were entirely unbounded:

- per-message headers merged from message metadata,
- the per-call URL a FHIR read or write builds (_FHIR_TYPE_RE bounds that
  grammar but not its LENGTH),
- the server-minted SMART bearer and the detached-JWS headers, stamped in just
  before the request is built -- i.e. AFTER the point any naive fix would guard.

Split into a pure detector (find_outbound_length_violation) plus two enforcers,
so the same measurement serves both gates under different disclosure rules.

The failure CLASS is computed, not guessed, because it decides the disposition:

- message-derived -> permanent NAK. The same message overflows on every retry,
  so retrying is a guaranteed-futile loop that also holds the lane.
- server-minted credential -> permanent + credential_fault, and the provider is
  invalidated. The provider caches: without invalidate() every retry re-sends a
  byte-identical over-length token forever; with invalidate() but no
  credential_fault, every retry re-signs a client assertion and POSTs the IdP
  forever. credential_fault_policy exists to stop exactly that re-auth storm.
- anything else -> a plain DeliveryError.

PHI egress: the message-derived arm emits the class and the length ONLY, never
the header name. At construction a name is operator-static; at send time
outbound_headers_from_metadata derives it from a message-metadata key suffix,
and that string reaches last_error, message_events.detail and -- on the
DeliveryError arm -- the webhook AlertSink, i.e. off-box. redaction.py concedes
single-token identifiers are its acknowledged residual.

Also closed, each a distinct gap rather than a missing call:

- FhirLookupExecutor never called the gate AT ALL (fhir.py's only call site was
  the destination's __init__), so a lookup's base, its per-call read URL and its
  minted bearer were unmeasured on both sides. Raises FhirLookupError, not a
  delivery error: the read runs inside a Handler, where there is no message to
  dead-letter.
- dicomweb bounded base_url but _target_url is derived from it 33 lines later
  and is what actually ships -- an ordering gap.
- store/keyprovider_vault.py: the Vault token ships as X-Vault-Token on every
  Transit call and the address becomes the URL; both from MEFOR_* env, neither
  ever measured.
- apiclient/client.py: base_url + path and the session bearer. Constants are
  DUPLICATED rather than imported -- ADR 0088 keeps that package engine-free, so
  the sharing import is the coupling it exists to avoid -- and pinned equal by a
  test that imports both sides in a test process.
- The SMART and OAuth2-CC token endpoints.

Signature headers are bounded at CONSTRUCTION because their length is
message-independent: the JWS is detached, so the body contributes only its
fixed-width hash. test_signature_header_length_is_message_independent pins that,
and is what legitimises the placement.

Six mutations verified red for their own reason: neutralise the send-time gate
-> DID NOT RAISE; restore the header name -> the PHI assertion; drop
credential_fault -> assert False is True; delete the signature bound -> DID NOT
RAISE; shrink the limit to 64 -> the byte-identity control reds, proving the
gate is on the ordinary path; delete the FHIR read gate -> the match= clause
(mandatory, not decorative: without the guard the oversize GET reaches the
opener and _parse raises FhirLookupError anyway, so a bare raises() would pass
either way).

Behaviour change, not pure hardening: a message that today ships a >8 KiB URL or
header will dead-letter or retry instead. No legitimate config is affected -- a
normal RS256 signature header is 364 chars -- but it belongs in the release note.

Residual: one engine-wide 8 KiB constant, not a per-receiver negotiated limit --
the same bar already credited at construction. Not yet covered: the three
_probe paths, alert_sinks, ai_broker and tray/probe.py.

The scorecard flip for this cell is vault-bound and not in this commit.
…he sixth

Completes the change set begun in 17d8c82. Each of these ships an outbound URL
or header that nothing measured.

- ai_broker: the endpoint is the request line and the api_key rides x-api-key on
  every provider call, both from env(). Raised as AiBrokerError (already a
  ValueError subclass) so the API surface keeps its single error type, and the
  key is never echoed -- the test pins that too.
- The REST and FHIR probe paths. Both MINT a real bearer so reachability
  reflects the actual credentials, and that token is the one value the
  construction gate could not see. Bounded so an operator's "test connection"
  fails with the real reason instead of an opaque wire error.

  The SOAP probe deliberately gets NO gate, and says so where it would have
  gone. It mints nothing -- it ships self.url and self._headers verbatim, both
  already bounded at construction -- so a gate there could not fire on any
  input. The first draft of this commit did add one, with an Authorization arm
  that would have called invalidate() on a provider the probe never touches; a
  guard that cannot fail reads as coverage without being it, which is the exact
  failure mode the rest of this work exists to remove.
- The webhook AlertSink URL. Construction-only here is sufficient and not a
  shortcut: the URL is operator config and the sole header is a fixed
  Content-Type, so nothing is added between construction and the wire. The
  import is lazy to keep the module's cost unchanged for the commoner sinks.

tray/probe.py is deliberately NOT closed, and says so in its own docstring
rather than being silently skipped. Three facts make that proportionate and all
three must stay true: the client is tokenless (no credential can overflow), the
URL is local operator config pointing at this host's own engine (neither
attacker-influenceable nor message-derived), and tray/ is stdlib+httpx only
(ADR 0113) -- importing transports/ to share the constant would breach the same
layering ADR 0088 protects for apiclient, and a third unpinned copy of an 8192
is worse than a documented absence. If the tray ever carries a token or takes a
remote URL, close it.

Two new mutations, both verified red for their own reason: delete the ai_broker
bound -> DID NOT RAISE AiBrokerError; delete the webhook bound -> DID NOT RAISE
ValueError. Each has a byte-identity control beside it that reds when the limit
is shrunk, so neither guard can pass as dead code. The harness now runs eight.

Docs reconciled where they enumerate the shared rest.py helpers and would
otherwise still describe a construction-only bound: ADR 0022 (twice -- the FHIR
per-call URL is message-derived, so the send-time arm is load-bearing there in a
way it is not for REST), ADR 0025 (the DERIVED _target_url, not just base_url),
and FEATURE-COVERAGE-PLAN's HTTPFHIR-6 row, whose "no own assertion" gap note is
now accurate rather than stale.
…gerprinted

MEFOR_AI_API_KEY was a registered critical secret WITH a documented rotation
cadence in the ASVS-L2-PHASE0-CHANGES schedule ("Per provider / org policy; on
compromise"), and yet was absent from _ENV_SECRET_CLASSES. So the rotation
watcher never fingerprinted it: the documentation told operators to rotate the
credential, and nothing in the engine could ever emit the reminder.

Enumeration completeness and rotation COVERAGE are different properties.
test_secret_rotation_inventory.py already guarded the first -- the secret was
correctly registered and correctly documented -- and nothing guarded the second,
which is why this sat green.

Adds the class, and adds the gate that would have caught it: every fixed MEFOR_*
critical secret must be either fingerprinted or explicitly excused WITH its
reason. Three mutations, each red for its own reason:

- un-fingerprint MEFOR_AI_API_KEY (recreates the live gap) -> named as
  "neither fingerprinted nor excused"
- fingerprint a name the registry does not know -> named as unregistered, so the
  alert cannot cite a cadence that does not exist
- park a real secret in the excuse list while also tracking it -> "both excused
  AND fingerprinted -- the excuse is false"

The five exclusions are recorded with reasons rather than left as absences,
because each is a decision a later reader would otherwise re-litigate:

- MEFOR_STORE_ENCRYPTION_KEY: the DEK has its own arm (_maybe_escalate_dek),
  which reasons over the wrapped key's age, not an env fingerprint. Listing it
  here as well would double-count it.
- MEFOR_STORE_ENCRYPTION_KEYS_RETIRED: a decrypt-only tail. Rotating it is
  meaningless -- it exists so old ciphertext stays readable -- and flagging it
  "due" would tell an operator to destroy their own recovery path.
- MEFOR_STORE_TRANSIT_KEY / _AUDIT_KEY / MEFOR_STORE_VAULT_TRANSIT_KEY: Vault
  Transit key NAMES, not secret values. The secret lives in Vault and rotates
  there; the engine holds only the label.
- MEFOR_PFX_PASSWORD: a one-shot passphrase for the `cert import` CLI. The
  running service never holds it, so there is nothing to fingerprint.

This does NOT move ASVS 13.3.4. That cell is Partial because the enforce arm
only alerts where the requirement says EXPIRE -- _maybe_escalate_dek emits
secret_rotation_due(enforced=True) past the grace window and nothing expires or
refuses. This is coverage parity within the detect-and-remind half. Anyone
re-scoring 13.3.4 on the strength of this commit is wrong.

Second-order note kept honest: under vault_transit no non-DEK class is
fingerprinted at all (crypto_transit returns no MAC key), so on that provider
this new class is inert along with the rest.
main moved from c66f6be to 3ecceb0 while this branch was in flight, and
3ecceb0 (ADR 0153 -- no data label may allow a cleartext hop, plus the
shipped-posture default flips) touches the same three transports this branch
does. Merging rather than leaving it stale: branch protection is strict:false,
so a stale green would merge without revalidation.

Three conflicts, all collisions rather than disagreements -- the two change sets
are orthogonal (cleartext-hop posture vs outbound length bounds):

- smart.py, http_auth.py: import-block collisions in the shared
  `from messagefoundry.transports.rest import (...)`. Both sides additive; kept
  both, sorted.
- fhir.py: ADR 0153 rewrote the refuse_cleartext_egress call in
  FhirLookupExecutor's per-connection loop from one line to a six-argument form.
  My enforce_outbound_length_limits call sat immediately after it and is
  unaffected by the rewrite, so main's version of the call is taken verbatim and
  the length gate follows it unchanged.

Verified against the merged tree, not assumed:

- ruff format / ruff check / mypy strict: clean.
- 1261 tests across every suite touching the merged regions.
- BOTH mutation harnesses re-run: all 8 ASVS 4.2.5 mutations and all 6 ASVS
  9.2.2 mutations still red for their own reason. That is the check that
  matters -- a merge can leave tests passing while silently disarming the guard
  they cover, and reverting each fix to confirm it still reds is the only way to
  know it did not.

Recorded, not resolved: ADR 0153 is a default-flip wave on the cleartext-hop
decision, i.e. exactly the lever that moves ASVS cells, and its effect on the
open Partial set has NOT been assessed. The correction doc's 237/47 is anchored
at c66f6be and is a floor for those cells, not a verdict. Noted in
docs/security/ASVS-L3-RESCORE-CORRECTION-2026-07-28.md section 8 with a
candidate cell list for the re-score writer.
…(ASVS 15.2.4)

Six shipped sites told users to install the web console in a way that either
fails outright or resolves a distribution nobody has registered.

The one that matters is the second shape. `messagefoundry-webconsole` has never
been published, so the name is UNCLAIMED. An install instruction naming an
unclaimed distribution is a dependency-confusion primitive: whoever registers it
on PyPI first gets code executed at install time -- an sdist runs its build
backend during `pip install` -- on every user who follows OUR OWN documentation,
before any engine process exists and therefore beyond the reach of every runtime
control in the product. README.md:105 is the front-page quick-start, so that is
the first install command a new user meets.

The other shape is simply broken: api/app.py's serve_ui RuntimeError told the
operator to run `pip install messagefoundry[webconsole]`, an extra pyproject
DELIBERATELY withholds until the wheel is published (see the note beside
[project.optional-dependencies]). Shipped code, not docs -- the operator hits it
at startup and the remedy it prints does not work.

Corrected to the path install, which resolves no index and is what CI has always
used: README.md, docs/INSTALL-GUIDE.md, docs/SERVICE.md, docs/USER-GUIDE.md,
docs/MENTAL-MODEL.md, packaging/messagefoundry-webconsole/README.md, and the
runtime error in api/app.py.

The guard derives both properties from source rather than hardcoding them:

- every extra named in shipped text must exist in [project.optional-dependencies]
- no unpublished distribution may appear in an INDEX-resolving install command

The path-vs-index distinction is the whole value, so it is pinned in both
directions by a no-I/O parametrized test: `-e packaging/...` stays green,
`pip install messagefoundry-webconsole` reds. A detector that flagged both would
be turned off within a week; one that flagged neither would be decorative.

Writing the guard immediately found two sites the manual sweep had missed --
README.md:105 and packaging/.../README.md:19 -- plus, pleasingly, my own first
correction note, which explained the hazard using a literal pasteable copy of
the bad command. Three mutations verified red: restore the non-existent extra in
shipped code; restore the index install in README; restore it in INSTALL-GUIDE.

Two exclusions, each recorded rather than silent: f-string `[{extra}]`
placeholders name no extra at rest (excluded by regex shape, not an allow-list
needing upkeep), and docs/BACKLOG.md is a historical ledger -- it records what
past items PROPOSED, including a `[console]` extra never declared, and rewriting
history to satisfy a lint would destroy the record.

Scope, stated honestly in the module docstring: this removes OUR contribution to
the risk. It does not remove the risk. Only claiming the name does -- and
reserving it is sufficient, since an empty project cannot be squatted. ASVS
15.2.4 therefore stays Partial until the reservation happens; remove the entry
from _UNPUBLISHED_DISTRIBUTIONS that day and this guard stops flagging index
installs of it.
@wshallwshall
wshallwshall enabled auto-merge July 29, 2026 15:35
@wshallwshall
wshallwshall merged commit 646a86c into main Jul 29, 2026
33 checks passed
@wshallwshall
wshallwshall deleted the claude/asvs-partials-pass-scores-2de207 branch July 29, 2026 15:56
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