fix: two guards that could not fail — an empty OIDC secret, and a KEX pin that never pinned (ASVS 11.6.2) - #56
Merged
Conversation
…fig validation)
The guard that makes a missing OIDC client secret fail at config load read
if self.oidc_client_secret is None and self.oidc_client_secret_ref is None
which is not the shape a missing secret usually takes. `MEFOR_AUTH_OIDC_CLIENT_SECRET=`
in a service wrapper, or an NSSM environment entry with an empty value, yields
`""` -- and `"" is not None`, so oidc_enabled loaded happily with no client
secret at all.
Reproduced before the fix, with a working control so the repro proves something:
secret='client-s3cret' ACCEPTED (control)
secret='' ACCEPTED oidc_client_secret=''
secret=' ' ACCEPTED oidc_client_secret=' '
secret absent refused "oidc_enabled requires a client secret"
Only the ABSENT case fired. Note the `missing` list ten lines above in the same
validator already uses `if not value` for all five pinned endpoints -- this one
line was the only place in the validator that disagreed with its own siblings,
which is why it read as correct.
Severity, stated accurately rather than inflated: this is NOT an auth bypass.
An empty client_secret reaches the IdP at the token exchange and is rejected
there, so the effect is a federated login that is broken instead of refused,
diagnosed from a proxy log rather than from a startup message. The defect is
that a fail-at-load guard silently became a fail-at-first-login one.
Whitespace is stripped for the emptiness TEST only; the value is never
rewritten, and a secret with meaningful leading/trailing whitespace still binds
byte-for-byte (asserted). The same test now applies to oidc_client_secret_ref,
or the fix would be half a fix -- an empty ref would otherwise satisfy
"one of the two is set" while naming no provider entry.
Both mutations killed against the shipped, formatted source:
M1 restore `is None` -> RED (4 failed) on the empty cases
M2 make the guard reject EVERY secret -> RED on the POSITIVE CONTROL
M2 is the one that matters: without a positive control, both refusal tests
would stay green under a guard that refuses everything, and "a raise happened"
is not the same claim as "the right input was refused".
…they were (ASVS 11.6.2)
harden_kex_groups pins SSLContext.set_groups "where available (Python 3.13+)".
set_groups is a Python 3.15 API. typeshed guards it at
sys.version_info >= (3, 15), alongside the get_groups that will finally make the
pin assertable. Measured on this tree -- Python 3.14.6 / OpenSSL 3.5.7,
hasattr(ctx, "set_groups") is False -- so APPROVED_KEX_GROUPS reaches ZERO of
its six call sites and every built context inherits OpenSSL's default group
list.
That much was already suspected. What was not:
client pinned to result (real handshakes vs build_api_ssl_context,
identical at tls_min_version 1.2 and 1.3)
X25519 accepted <- positive control
secp384r1 accepted <- positive control
prime256v1 accepted <- positive control
ffdhe2048 ACCEPTED
ffdhe3072 ACCEPTED
secp521r1 ACCEPTED
secp224r1 refused (NO_SUITABLE_GROUPS)
sect571r1 refused (NO_SUITABLE_GROUPS)
ADR 0092 section 4(b) asserts the opposite: "a real handshake test proves a
client offering only a non-approved FFDHE group is refused -- runtime
enforcement". That test reached its assertion only through a CLIENT-side
set_groups, so it hit pytest.skip on every interpreter this project runs on. A
skip was concealing a false assertion, and the ADR cited it as proof. That is
worse than an untested claim, because it reads as evidence.
Method, stated because the probe could have lied too: the client pins ONE group
via set_ecdh_curve, which was validated as a genuine constraint FIRST -- a
server pinned to prime256v1 refuses a client pinned to secp384r1
(NO_SUITABLE_KEY_SHARE) while the unpinned-server control accepts it. Without
that check, a probe that constrained nothing would have reported "everything
accepted" for a context that was in fact restrictive.
SEVERITY, stated accurately rather than inflated: the inherited list is
forward-secret, so the property the TLS 1.2+ floor exists to guarantee still
holds, and the genuinely weak curves are refused. This is a conformance gap
against an internal allow-list -- wider than policy, not weak. ASVS 11.6.2 is
Partial in the scorecard of record and stays Partial. The headline count does
not move. What changes is whether it can be defended.
What lands:
harden_kex_groups now RETURNS the list it actually pinned (None today). A
security control that cannot report whether it did anything reports success
forever -- that is how a call at six sites with zero effect survived three
assessments. The failure path returns None too: previously a pin that RAISED
logged a warning and then fell off the end, so a caller could not distinguish
"pinned" from "tried and failed". Its `# pragma: no cover` is gone, because a
stand-in context now drives that branch instead of leaving it to an unusual
OpenSSL build.
The three unit tests that stood over this asserted (a) the call does not raise,
(b) it no-ops on an object without the API, (c) a string constant has three
colon-separated names. All three pass identically whether or not a single group
is ever pinned. Replaced with an unconditional receipt asserting the None, whose
failure message is the re-derivation instruction for whoever trips it. Written
unconditionally on purpose: an `if hasattr(ctx, "set_groups")` branch would
restore exactly the property being removed.
The skipping handshake test is replaced by one that MEASURES the accepted-group
set and cannot skip. Asserted as invariants rather than an exact table, since
the accepted set comes from the linked OpenSSL and a CI leg may differ: every
approved group must get in (else the server is over-restricted and the next
assertion would pass for the wrong reason) and some non-approved group must get
in (proving the list is not enforced). The measured table prints on failure.
Five mutations, both directions:
M1 report a pin never made -> inertness receipt RED
M2 revert to the old no-return shape -> reporting test RED
M3 failed pin reports the list -> raising test RED
M4 probe accepts everything -> measurement RED
M5 probe refuses everything -> measurement RED
M2 is the one that justifies its own test: under M2 the INERTNESS test stays
GREEN (measured: rc=0), because a function with no return statement also yields
None. Asserting `is None` therefore does not prove the reporting works, which is
why the pinning path is driven separately with a stand-in context.
Documents corrected in the same change, because the claim had spread to five:
- config/tls_policy.py -- the "3.13+" docstring and the module summary.
- docs/PHI.md section 4 -- "Approved groups pinned where supported" -> inherited,
with the measured accepted set.
- docs/ASVS-L2-PHASE0-CHANGES.md -- the same sentence, same error.
- docs/adr/0092 section 4(b) -- struck through in place and WITHDRAWN by a dated
amendment naming all three wrong assertions. The ADR's decision is unaffected:
the forward secrecy it relies on comes from the TLS 1.2+ floor, which IS
enforced. Sections 4(a) and 4(c) were re-confirmed and stand.
- docs/BACKLOG.md -- the "runtime-KEX enforcement ... handshake-tested" clause
inside a shipped banner. Prose-only edit, banner invariant untouched,
test_backlog_status_check.py green.
Deliberately NOT touched: docs/security/ASVS-L3-ASSESSMENT.md and the other
dated assessments carry the same claim and are marked "Retained unmodified for
diffing and audit history". They stay unmodified; the scorecard-of-record and
register corrections are vaulted separately (docs/security/ is git-ignored here).
Also NOT done, and it is a real option rather than an oversight:
SSLContext.set_ecdh_curve exists and genuinely pins, but takes exactly ONE
OpenSSL curve short name -- it cannot express a preference list, and pinning
through it would refuse two of the three approved groups. It is a costed
hardening knob for a future lane, not a drop-in, and shipping it as though it
closed 11.6.2 would be the kind of lenient reading this project has already
retracted twice. Also note secp256r1 is a valid group-list alias but NOT a valid
EC curve name (that spelling is prime256v1) -- do not "normalise" the constant.
Swept the sibling helpers, because "is anything else inert?" is the obvious next
question and a claim is not an answer. tls_policy.py has exactly three
getattr-guarded best-effort call sites; all three were driven on this
interpreter:
set_groups (harden_kex_groups) INERT -- the subject of this commit
VERIFY_X509_STRICT (harden_verify_flags) LIVE -- present (32), and the flag
is verifiably ORed into
verify_flags (32768 -> 32800)
_hashlib.get_fips_mode (fips_attestation) LIVE -- returns False, not None;
None would have meant
undeterminable, i.e. a vacuous
attestation
So this was the only one of the three, and that is measured rather than assumed.
….1 TLS probe CodeQL alert 145 -- py/insecure-protocol, HIGH, messagefoundry/config/tls_probe.py -- was raised by PR #53 and dismissed `won't fix` on the repo. ADR 0034's policy is that every finding is triaged to Fix or Dismiss-with-a-recorded-reason, with the per-alert rationale in the GitHub comment and the class rationale in the ADR. The GitHub comment is capped at 280 characters, so the ADR is where the real argument has to live; this is that entry. The rule is factually right and does not apply: tls_probe.py offers TLS 1.0/1.1 BECAUSE a successful handshake at a withdrawn version is the finding it exists to produce. Two flagged settings are load-bearing and mutation-proven -- SECLEVEL=0 (without it OpenSSL sends no ClientHello, so the probe would measure our refusal to ask rather than their refusal to answer) and CERT_NONE (an untrusted internal CA would abort before the version was settled, reporting "TLS 1.0 refused" for a door never knocked on). What makes the dismissal safe is scope, so the row states it: client contexts only, built in that module, one handshake, never returned to a caller, no application data, no PHI. Not a data path, and the settings must never be reused for one -- which the module docstring and the crypto-inventory row both already say, at the two places a future author would actually look. Also recorded per this ADR's own convergence rule: the anchor is tls_probe.py:146 inside _offer_context, and that module is new and small, so this WILL re-fire as a fresh alert number the first time anything is inserted above line 146. Re-dismiss with this rationale rather than re-triaging from scratch.
wshallwshall
added a commit
that referenced
this pull request
Jul 29, 2026
… instead (#58) main is RED. `test (windows-2022, py3.14)` failed at be2fc08, the squash-merge of PR #56, whose own head was 33/33 green: assert harness.frames, "expected at least one stats frame before revocation" AssertionError: assert [] Not the merged change -- PR #56 touched TLS key-exchange policy, an OIDC settings validator and docs, none of which this route reaches. The test is defective, and this is deliberately NOT being treated as a flake to re-run: this repo has already mistaken a livelock for one, and no rerun or timeout ever fixed that. The defect: the first frame cannot be sent until `store.stats()` returns -- the route awaits it, builds the frame, then sends -- and the fixed 50 ms sleep had to cover that query PLUS scheduling the harness task, the handshake, and the handshake-time authorize. None of which these tests measure. It passes locally 3/3 in 10.06s, 12.33s and 3.89s, and that spread is the tell. Replaced the sleep with `_wait_for_first_frame`, a bounded poll on the ACTUAL asserted condition. This does not weaken the precondition: a frame is still required, and still required BEFORE the revocation. It also distinguishes a dead task from a slow one -- `task.result()` re-raises the real failure, because a route that blew up would otherwise be indistinguishable from a loaded runner, and "timed out waiting for a frame" is the least useful description of a traceback. A SECOND, quieter defect fixed at the same time. test_disabled_account_is_closed also slept 50 ms and asserts only `close_code == 1008` -- which BOTH the mid-stream revalidation and the pre-first-send re-check produce. So on a slow box the account was disabled before the first send, the close came from the wrong path, the assertion still held, and the test silently stopped covering what its comment claims while duplicating what test_revoke_before_first_send_yields_no_frames already owns. A coverage move with no symptom. It now waits for a frame, so "mid-stream" is a fact. New test_a_slow_first_stats_build_does_not_break_the_precondition makes the CI failure DETERMINISTIC: it stalls `store.stats()` to 0.4s, 8x the old budget, so the old form fails on any machine. That is the property the original failure never had -- it only reproduced on a loaded Windows runner, which is not something you can iterate against. Two mutations, and the second half of the first is the interesting one: M1 restore `await asyncio.sleep(0.05)` -> the slow-stats regression test RED (1 failed) -> and test_disabled_account_is_closed stays GREEN (1 passed), which is the silent-coverage-move demonstrated rather than argued M2 `_FIRST_FRAME_TIMEOUT = 0.0` -> 3 failed, so the wait is load-bearing and not decorative Timeouts: the first-frame wait is 10s and the harness budget is raised 5s -> 20s, because the harness must outlive the wait PLUS the close it then waits for, or it would time out mid-wait and the failure would point at the route instead of the clock. Both sit inside the 60s pytest timeout, which stays the real backstop against a hang. Full suite 9636 passed / 719 skipped; the one failure is the pre-existing environmental test_installed_metadata_matches_dunder_version (editable metadata 0.3.0 vs __version__ 0.3.2 -- its own comment names the case and the remedy).
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.
Wave-0 corrections — two guards that could not fail, and the documents that believed them
Three commits, two unrelated defects, one register entry. Neither defect moves the ASVS headline count;
both change whether it can be defended.
1 ·
fix(auth)— an EMPTYoidc_client_secretis a MISSING oneThe guard that makes a missing OIDC client secret fail at config load read:
That is not the shape a missing secret usually takes.
MEFOR_AUTH_OIDC_CLIENT_SECRET=in a servicewrapper — or an NSSM environment entry with an empty value — yields
"", and"" is not None.Reproduced before the fix, with a working control so the repro proves something:
MEFOR_AUTH_OIDC_CLIENT_SECRETclient-s3cret""oidc_client_secret == ''" "Only the absent case fired. The
missinglist ten lines above in the same validator already usesif not valuefor all five pinned endpoints — this one line was the only place in the validator thatdisagreed with its own siblings, which is why it read as correct.
Severity, stated accurately: this is not an auth bypass. An empty
client_secretreaches the IdP atthe token exchange and is rejected there, so the effect is a federated login that is broken instead of
refused — diagnosed from a proxy log rather than a startup message. The defect is that a fail-at-load
guard silently became a fail-at-first-login one.
Whitespace is stripped for the emptiness test only; a secret with meaningful surrounding whitespace
still binds byte-for-byte (asserted). The same test now covers
oidc_client_secret_ref, or an empty refwould satisfy "one of the two is set" while naming no provider entry.
Mutations: restore
is None→ red (4 failed). Make the guard reject every secret → red on thepositive control. The second is the one that matters — without it, both refusal tests stay green under
a guard that refuses everything, and "a raise happened" is not the claim being made.
2 ·
fix(tls)— the approved KEX groups were never pinned (ASVS 11.6.2)harden_kex_groupspins viaSSLContext.set_groups"where available (Python 3.13+)".set_groupsis a Python 3.15 API — typeshed guards it atsys.version_info >= (3, 15), alongsidethe
get_groupsthat will finally make the pin assertable. Measured here: Python 3.14.6 / OpenSSL3.5.7,
hasattr(ctx, "set_groups")isFalse, soAPPROVED_KEX_GROUPSreaches zero of its six callsites.
That much was suspected. This was not — real handshakes against the actual
build_api_ssl_context,identical at
tls_min_version1.2 and 1.3:X25519,secp384r1,prime256v1ffdhe2048ffdhe3072secp521r1secp224r1NO_SUITABLE_GROUPSsect571r1NO_SUITABLE_GROUPSADR 0092 §4(b) asserts the opposite: "a real handshake test proves a client offering only a
non-approved FFDHE group is refused — runtime enforcement". That test reached its assertion only through
a client-side
set_groups, so it hitpytest.skipon every interpreter this project runs on. A skipwas concealing a false assertion, and the ADR cited it as proof — worse than an untested claim, because it
reads as evidence.
Method, stated because the probe could have lied too. The client pins one group via
set_ecdh_curve,which was validated as a genuine constraint first: a server pinned to
prime256v1refuses a clientpinned to
secp384r1(NO_SUITABLE_KEY_SHARE) while the unpinned-server control accepts it. Without thatstep, a probe that constrained nothing would have reported "everything accepted" for a context that was in
fact restrictive — the two answers look identical.
Severity, bounded. The inherited list is forward-secret, so the property the TLS 1.2+ floor guarantees
still holds, and the genuinely weak curves are refused. This is a conformance gap against an internal
allow-list — wider than policy, not weak. 11.6.2 is Partial and stays Partial.
What changes in code
harden_kex_groupsnow returns the list it actually pinned (Nonetoday). A control that cannotreport whether it did anything reports success forever — that is how a call at six sites with zero effect
survived three assessments. The failure path returns
Nonetoo: previously a pin that raised logged awarning and then fell off the end, so a caller could not distinguish "pinned" from "tried and failed". Its
# pragma: no coveris gone, because a stand-in context now drives that branch.The three tests standing over this asserted (a) the call does not raise, (b) it no-ops without the API,
(c) a string constant has three names. All three pass identically whether or not a group is ever pinned.
Replaced with an unconditional receipt whose failure message is the re-derivation instruction — an
if hasattr(...)branch here would restore exactly the property being removed. The skipping handshaketest is replaced by one that measures the accepted set and cannot skip, asserted as invariants rather
than an exact table (the set comes from the linked OpenSSL and a CI leg may differ): every approved group
must get in, and some non-approved group must get in.
Five mutations, both directions:
M2 is why it needs its own test: under M2 the inertness test stays green (measured
rc=0),because a function with no
returnalso yieldsNone.assert pinned is Nonetherefore proves nothingabout the reporting.
Swept the siblings, because "is anything else inert?" deserves a measurement, not a claim.
tls_policy.pyhas exactly threegetattr-guarded best-effort sites; all three were driven here:set_groups(harden_kex_groups)VERIFY_X509_STRICT(harden_verify_flags)_hashlib.get_fips_mode(fips_attestation)False, notNone(None= undeterminable = a vacuous attestation)Documents corrected, because the claim had spread to five
config/tls_policy.py— the "3.13+" docstring and the module summary.docs/PHI.md§4 — bullet rewritten; the section marker changed from[BUILT — WP-L3-10 code half]to[PARTIAL — the 1.2+ floor and the cipher validator are enforced; the group pin is INERT until Python 3.15].docs/ASVS-L2-PHASE0-CHANGES.md— the same sentence, the same error.docs/adr/0092§4(b) — struck through in place and withdrawn by a dated amendment naming all threewrong assertions. The ADR's decision is unaffected (its forward-secrecy dependency is the TLS 1.2+
floor, which is enforced); §4(a) and §4(c) were re-confirmed and stand.
docs/BACKLOG.md— the "runtime-KEX enforcement … handshake-tested" clause inside a shipped banner.Prose-only; banner invariant untouched,
test_backlog_status_check.pygreen.Deliberately not touched:
docs/security/ASVS-L3-ASSESSMENT.mdand the other dated assessments carrythe same claim and say on their face "Retained unmodified for diffing and audit history." The
scorecard-of-record and register corrections are vaulted (
docs/security/is git-ignored here).Deliberately not built, and it is an option rather than an oversight:
set_ecdh_curveexists andgenuinely pins, but takes exactly one curve name — it cannot express a preference list, and pinning
through it would refuse two of the three approved groups. A
[tls]single-curve knob is a real hardeningoption with a real interop bill (an MLLP client pinned to X25519 refuses a P-256-only server — a realistic
shape for older Java/.NET interface engines). Shipping it as though it closed 11.6.2 would be the lenient
reading this project has already retracted twice.
3 ·
docs(adr-0034)— record the CodeQL dismissal from PR #53py/insecure-protocol, HIGH,messagefoundry/config/tls_probe.py, dismissedwon't fixon the repo.The rule is factually right and does not apply: the probe offers TLS 1.0/1.1 because a successful
handshake at a withdrawn version is the finding it exists to produce. The GitHub dismissal comment is
capped at 280 characters, so ADR 0034 is where the argument has to live. Includes the convergence note —
the anchor is
tls_probe.py:146, so this will re-fire as a new alert number on any line drift aboveit; re-dismiss with the recorded rationale rather than re-triaging.
Verification
ruff check·ruff format --check(976 files) ·mypy --strict(257 modules) · full suite on thepre-rebase tree 9614 passed, 719 skipped; targeted re-run after rebasing onto the merged
main343 passed.
The one failure in the full run is the known environmental
test_installed_metadata_matches_dunder_version— the editable install's metadata (
0.3.0) vs__version__(0.3.2); the test's own comment names thecase and the remedy. CI installs fresh.