Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,17 @@ jobs:
# and the hardened XML/SOAP codec (#31) tests run their assertions instead of importorskip-skipping.
# [webauthn] (ADR 0068) so the passkey ceremony tests run real verify_* assertions instead of
# importorskip-skipping, and mypy sees the py.typed library rather than Any.
# [vault] (hvac + requests + urllib3) so the Vault hops' TLS cipher assertion (ASVS 12.1.2,
# BACKLOG #1317, ADR 0180) is EXECUTED rather than merely shipped. ADR 0180 declined to build
# that assertion precisely because "no CI leg installs the [vault] extra", so a control there
# could never be run — the silent-control shape ADR 0158 catalogues. This is the leg that
# closes it: `tests/` runs here and nowhere else, so this is the only install line that can
# exercise it, and the extra costs six small pure-Python wheels already pinned in the lock.
# It also lets mypy type-check the urllib3 replica against the real py.typed library.
# + the browser ops console as a second editable wheel (Option B) so the moved /ui tests
# (tests/test_webui.py etc. now import messagefoundry_webconsole) run on this full-suite leg.
# It is NOT a published extra yet (would break uv lock), so install it by path.
uv pip install --system --constraint constraints.lock -e ".[dev,harness,fhir,dicom,x12,xml,webauthn]" -e packaging/messagefoundry-webconsole
uv pip install --system --constraint constraints.lock -e ".[dev,harness,fhir,dicom,x12,xml,webauthn,vault]" -e packaging/messagefoundry-webconsole

# ruff (lint + format) and mypy are PLATFORM-INDEPENDENT results — a ruff/format outcome is
# identical on every OS, and mypy's platform-specific branches are covered by running it for
Expand Down
103 changes: 95 additions & 8 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5986,7 +5986,7 @@ complete result, not a stall.

## 1042. The `[vault]` key/secret/transit providers build a redirect-following HTTP client, so a diverted 3xx could carry `X-Vault-Token` off-path

> ✅ **SHIPPED 2026-08-10 — all three `[vault]` clients now refuse redirects.** Value **4/10** · Difficulty **2/10** · _fill-in_. `allow_redirects=False` at both `hvac.Client` construction points (`store/keyprovider_vault.py` and `config/secretprovider_vault.py` `_build_client`); `store/crypto_transit.py` already reuses the first, so the Transit cipher inherits the policy rather than growing a second construction point. Verified against the library rather than assumed: hvac 2.4.0's `Client.__init__` takes `allow_redirects` (default `True`), stores it on the adapter, and the adapter passes `allow_redirects=self.allow_redirects` to `requests.Session.request` — recorded in the test module's docstring because CI never installs the `[vault]` extra and no test can reach it there. Four tests in `tests/test_vault_client_redirect_policy.py`: three watched RED (each reported the constructed kwargs as `dict_keys(['url', 'token'])`), and the fourth is a live positive control that the recording stand-in reports an absent policy rather than swallowing an unrecognised kwarg. The Transit client is driven end to end through `build_transit_cipher`, so a private client construction there would red rather than pass an identity check.
> ✅ **SHIPPED 2026-08-10 — all three `[vault]` clients now refuse redirects.** Value **4/10** · Difficulty **2/10** · _fill-in_. `allow_redirects=False` at both `hvac.Client` construction points (`store/keyprovider_vault.py` and `config/secretprovider_vault.py` `_build_client`); `store/crypto_transit.py` already reuses the first, so the Transit cipher inherits the policy rather than growing a second construction point. Verified against the library rather than assumed: hvac 2.4.0's `Client.__init__` takes `allow_redirects` (default `True`), stores it on the adapter, and the adapter passes `allow_redirects=self.allow_redirects` to `requests.Session.request` — recorded in the test module's docstring because CI installed the `[vault]` extra nowhere and no test could reach it there. **That premise expired on 2026-09-03** ([#1317](#1317)): the `test` leg installs `[vault]` now, so a live assertion against hvac's real construction IS reachable in CI and a future revision of this policy should make one rather than inherit the docstring. Four tests in `tests/test_vault_client_redirect_policy.py`: three watched RED (each reported the constructed kwargs as `dict_keys(['url', 'token'])`), and the fourth is a live positive control that the recording stand-in reports an absent policy rather than swallowing an unrecognised kwarg. The Transit client is driven end to end through `build_transit_cipher`, so a private client construction there would red rather than pass an identity check.

**Cluster:** Egress / secret handling. **Priority:** P3. **Verdict:** build (small). **Severity:** conditional, not an exposure on the shipping config. The vault provider is behind an optional pip extra and off by default; when selected it points at operator-trusted infrastructure. On first deployment, an on-path 3xx (absent TLS integrity) or a spoofed Vault could divert the bearer token, while every default egress refuses redirects.

Expand Down Expand Up @@ -15800,8 +15800,8 @@ so a decoy call cannot satisfy it. Deleting the call from any one site reds that
for -- admitting only suites present on every current candidate list, rejecting anything unnamed -- is
not built. This records a call site gaining an assertion, not the allowlist changing shape.

***THAT PARAGRAPH IS FALSE, AND IT WAS FALSE ON THE DAY IT WAS WRITTEN. Superseded by the 2026-09-03
amendment below, which measures it.*** Left standing rather than deleted because it is a dated record
***THAT PARAGRAPH IS FALSE, AND IT WAS FALSE ON THE DAY IT WAS WRITTEN. Superseded by the FIRST
2026-09-03 amendment below, which measures it.*** Left standing rather than deleted because it is a dated record
of what a reader believed, and because the same reader's other claim on this row is sound. The
allowlist landed eight days EARLIER, on 2026-08-22 in `ae72f5828` (PR 519), and this row's own BUILT
paragraph says so a few paragraphs up. The banner is therefore not held open by the allowlist.
Expand Down Expand Up @@ -15850,7 +15850,10 @@ SAID SO.** Both rulings are re-measured here and both hold.
**The ADR measured `store/sqlserver.py` alone and generalised to "ODBC Driver 18".** The ODBC
*transport* -- the DATABASE destination and the ADR 0010 `db_lookup` hop, which is where message
content crosses -- was never named in it. It is measured now.
- **`hvac` -- NOT BUILT, deliberately, and the ADR names its own trigger.** hvac delegates to
- **`hvac` -- NOT BUILT, deliberately, and the ADR names its own trigger.** ***Superseded by the
SECOND 2026-09-03 amendment below, which installed the extra and built it.*** Left standing because
it is why the CI leg had to come first, and because its own trigger is what reported the change.
hvac delegates to
requests, which delegates to urllib3, which builds and owns the context per connection, so the
engine holds no object -- the same shape as `ldap3`. Unlike `ldap3` the gap cannot be closed by
measurement: `urllib3` is absent from the interpreter and **no CI leg installs the `[vault]` extra**
Expand All @@ -15869,14 +15872,22 @@ control and each proved non-vacuous by mutation.
| `test_the_odbc_scope_out_premise_still_holds` | neither ODBC module reaches for an `ssl` context | appended an `ssl.create_default_context()` to `transports/database.py` |
| (its control) | the scan finds contexts where they exist (`store/postgres.py`) | pointed the control at a module with none |
| `test_the_sqlserver_hop_asserts_no_suites_and_pins_what_it_can_control` | the DSN path asserts nothing, and pins `Encrypt=yes` / `TrustServerCertificate=no`, which it CAN control | changed the emitted `Encrypt` value |
| `test_the_hvac_scope_out_premise_still_holds` | `urllib3` absent AND no leg installs `[vault]` | made `urllib3` resolvable; separately added `vault` to a workflow's extras |
| `test_the_hvac_scope_out_premise_still_holds` *(FIRED; converted 2026-09-04 to `test_the_hvac_arm_stays_built_and_stays_executable`)* | `urllib3` absent AND no leg installs `[vault]` | made `urllib3` resolvable; separately added `vault` to a workflow's extras |
| (its control) | the workflow scan reads real install lines | removed `webauthn` from both workflows that install it |

**These are TRIGGERS, not guards.** A red does not mean the engine got worse. It means the reason an
arm was left unasserted has stopped being true and the arm is now buildable -- build it and amend
ADR 0180, never delete the test.

**5. THE RESIDUAL, and it is the only one left.** Install the `[vault]` extra on a CI leg; then
***THE hvac TRIGGER FIRED, AND THAT SENTENCE IS WHAT WAS DONE WITH IT.*** The second 2026-09-03
amendment below installed the extra and built the assertion, so both halves of that test went red by
design. It was converted rather than deleted, and it now GUARDS the opposite failure: that the arm,
once built, keeps being executed. **The two ODBC tests are still triggers and read exactly as written
above.** They are no longer the same shape as the hvac one, so do not read them as a set.

**5. THE RESIDUAL, and it is the only one left.** ***MET on the same day, by the amendment below.***
Preserved as the dated statement of the precondition, because naming it is what made it satisfiable.
Install the `[vault]` extra on a CI leg; then
urllib3's own `urllib3.util.ssl_.create_urllib3_context()` -- the function urllib3 itself calls, not a
look-alike -- becomes executable by a test, and the assertion belongs in both `_build_client`
factories (`store/keyprovider_vault.py` and `config/secretprovider_vault.py`, which between them cover
Expand All @@ -15885,8 +15896,84 @@ none is allocated. **The trigger test above is what will report the day that pre

**PROPOSED CLOSURE, not taken here.** Every claim this row still carries as open is now either built
(the allowlist, the `ldap3` arm), permanently out with evidence (ODBC Driver 18), or a named residual
gated on a CI change (`hvac`). A builder must not flip a banner while a residual stands, so the banner
is left PARTIAL for the owner to rule on with the residual in view.
gated on a CI change (`hvac`). *The `hvac` residual was met the same day -- see below -- so the
banner is now held open by the allowlist SCOPE question alone.* A builder must not flip a banner while
a residual stands, so the banner is left PARTIAL for the owner to rule on with the residual in view.

**AMENDMENT 2026-09-03 (SECOND, same day; written after the one above and landed after it) -- THE
VAULT ARM IS BUILT, AND THE CI LEG IS WHAT MAKES IT HONEST.** Owner-ruled
the same day: install the `[vault]` extra on a CI job, *then* build the hvac assertion. A Builder
earlier that day had refused to build it alone and was right to -- hvac is absent from every local
interpreter and no CI leg installed the extra, so the assertion could have shipped without ever being
executed once. See [ADR 0180](adr/0180-asserting-tls-suites-on-a-library-that-exposes-no-sslcontext.md)
Amendment A for the full measurement; what this row records is what it now measures.

**The order is the deliverable, not a preamble.** `[vault]` now installs on `ci.yml`'s `test` leg --
the only leg that runs `tests/`, so the only install line that can exercise the control. Six small
pure-Python wheels, every one already pinned in `requirements.lock` and `constraints.lock`, so **no
re-lock was needed**. `tests/_extras_probe.py` gained a `vault` row, so an interpreter without the
extra now announces the run as INCOMPLETE rather than reporting a quiet green over skipped security
tests.

**IT IS ASSERTABLE, AND THE MEASUREMENT SAYS WHY A REPLICA IS THE ONLY INSTRUMENT.** Measured against
the locked pins (hvac 2.4.0, requests 2.34.2, urllib3 2.7.0) by driving a real client at a real socket,
not by reading source: `hvac.Client` carries zero `SSLContext` attributes, its `requests.Session`
carries none, its `PoolManager` has `connection_pool_kw == {'maxsize': 10, 'block': False}` with no
`ssl_context` key, and the module-level `requests.adapters._preloaded_ssl_context` that requests 2.32
carried is **gone in 2.34**. urllib3 builds the context lazily, per connection, calling
`create_urllib3_context` **once** and yielding **17 suites -- zero NULL, zero anonymous, zero
non-forward-secret**. So `assert_hvac_tls_suites` replicates that construction with urllib3's OWN
public constructor and refuses any `hvac.Client` argument it cannot replicate -- `session=` above all,
since that is the documented way to hand this hop a different context.

**ONE GUESS THIS ROW WOULD OTHERWISE HAVE INHERITED, corrected by measurement.**
`create_urllib3_context()` and stdlib `create_default_context()` produce the **identical** 17 suites on
the current OpenSSL. The reason to use urllib3's own constructor is therefore *not* that a stdlib
look-alike is measurably wrong today -- it is that only the real function tracks urllib3 if urllib3
narrows its own defaults. The satisfying version of that argument would have been false.

**THE PROOF IT CARRIES, which is the shape this row demanded of its own first half.** Seven tests
pinning the refusal AND a positive control -- the real 17-suite list is required to be non-empty and
clean on all three shipped predicates, because a refusal pinned alone cannot tell a working control
from one that refuses everything. Five mutations, each applied alone with the tree restored between,
**every one RED**: delete the assertion from either construction point, make the unreplicable-argument
refusal inert, drop `harden_cipher_suites` from the replica, and make the replica build a context that
*differs* from urllib3's. The last is the one that matters for a replica -- it proves the equivalence
test detects drift rather than merely passing.

**TWO CONSTRUCTION POINTS COVER THREE CLIENTS, and that is now pinned by identity rather than prose:**
a test asserts `crypto_transit._build_client is keyprovider_vault._build_client`. `_build_client` also
moved OUT of its callers' `try` blocks in all three callers, whose `except Exception` would have
relabelled this configuration refusal as a connectivity failure -- the same mistake ADR 0180 records
for the LDAPS site. Fail-closed is unchanged: both paths propagate and the subsystem refuses to start.

**THE TRIGGER THE AMENDMENT ABOVE LEFT BEHIND IS CONVERTED, NOT DELETED (2026-09-04).** That amendment
shipped `test_the_hvac_scope_out_premise_still_holds`, asserting the two premises this one falsifies:
`urllib3` absent, and no leg installing `[vault]`. Both halves went red on the merge, which is the
trigger working rather than a defect, and that amendment's own text says what to do with it -- *"build
it and amend ADR 0180, never delete the test."* It is now
`test_the_hvac_arm_stays_built_and_stays_executable`, guarding the failure a fired trigger cannot see:
that the built arm keeps being EXECUTED. Two halves again. A CI leg must still install `[vault]`, or
all seven tests above skip and a suite reports green over a security control nothing ran; and both
`_build_client` factories must still reach `assert_hvac_tls_suites`, checked at source level because
on an interpreter without the extra those seven tests are exactly the ones that would not run.

**WHERE THE ROW NOW STANDS. Every library named in the residual is closed or recorded with evidence:**
`ldap3` asserts (2026-08-30), `hvac` asserts (this amendment), and **ODBC Driver 18 is
un-assertable with the reason in ADR 0180** -- TLS is terminated inside the native driver, the suite
list belongs to the driver and the OS TLS stack rather than to the interpreter's OpenSSL, and no
Python-side context exists to hold or replicate. That is a finding, not a deferral.

**THE BANNER STILL DOES NOT FLIP, AND THE TWO AMENDMENTS AGREE ON THAT WITHOUT AGREEING ON WHY. Read
them together or you will read a contradiction that is not there.** The one above measures the strict
positive allowlist as BUILT and finds nothing left to build. This one adds where it REACHES: it
governs the operator KNOB (`validate_tls_ciphers`), and an inherited default context is still checked
by the three property predicates alone. Build-state and reach are different questions about one
measurement, so neither reading corrects the other. What is left is therefore a SCOPE question --
should the positive allowlist extend to contexts no operator configured? -- and it is the row's only
remaining ask. **Closure is proposed by both amendments and taken by neither.** The library half this
row declared open is fully accounted for. A seat with the authority should rule on whether the
allowlist scope survives as this row or as a new one.

## 1315. prose path:line citations carry no token, so nothing can verify them

Expand Down
Loading
Loading