Uh oh!
There was an error while loading. Please reload this page.
fix(telemetry): the redaction floor missed every env-var-shaped secret, and nothing could tell (backend#1908) - #799
Conversation
…t, and nothing could tell (backend#1908) Two findings, one of them live. THE CHECK COULD NOT FAIL. tests/telemetry_collector_test.yaml carried a test titled "carries all six of controller.py secret patterns". It greps the rendered config for six substrings it holds ITSELF; controller.py lives in client-runtime and is never read. It agreed with itself and reported that as agreement with a file it has no access to. A seventh redaction added over there leaves every assertion green while the Collector ships the secret it does not know about -- rules 1 and 9. Renamed to what it actually checks. THE FLOOR HAD A HOLE, found the first time anything fed it a real string. The generic assignment pattern anchored its keyword with \\b on BOTH sides, so it caught a bare password= and missed every environment-variable spelling: AZURE_CLIENT_SECRET=, MYSQL_PASSWORD=, DJANGO_SECRET_KEY=, AWS_SECRET_ACCESS_KEY=. An underscore is a word character, so a prefix or a suffix removes the boundary and the whole assignment falls out. Those are the shapes our logs actually print -- backend#2069 deliberately names which AZURE_* variable is unset when provisioning fails. A quoted value (SECRET_KEY=x9y8z7 in quotes) fell out too, because the value class excludes quotes and so matched nothing after the separator. Widened both sides with [\\w.-]* and allowed an opening quote. Over-redaction is the stated trade in controller.py own comment; this bounds it. THE GUARD IS BEHAVIOURAL, NOT TEXTUAL. scripts/tests/collector-redaction-floor.sh parses the replace_pattern regexes out of the RENDERED config -- derived, so a pattern added or removed is seen -- and applies them to specimens written down independently of the matcher. It asks the only question that matters: would this line leave the cluster with the secret still in it. And it asks the reverse, which nothing asked before: does the diagnosis survive. Class B exists so an ingestion failure is diagnosable at full fidelity, and a carelessly widened pattern that eats the traceback is not a win. Why this matters more than it looks: the Collector is Class A today -- containers this chart owns. #1908 opens Class B, which is training and ingestion output: customer cell values and customer credentials. The redaction floor is the whole thing standing between that and central storage. Mutation-proven three ways, anchor asserted applied each time and restored: reverting the pattern to its pre-fix form reddens the four env-var specimens; deleting a pattern trips the count floor; a reckless catch-all trips the diagnosis direction. STILL CANNOT SEE controller.py itself -- CI checks out one repository. Stated in the guard header rather than implied, and filed separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…edAccessKeyName (backend#1908) Caught by client-runtime tests/test_controller.py test_scrubs_servicebus_key_but_keeps_key_name, which the same widening broke over there. The guard added in the previous commit said green, because its diagnosis specimens did not include a key NAME -- the specimen list was the gap, not the mechanism. A trailing [\\w.-]* let SharedAccessKeyName= match as prefix Shared, keyword AccessKey, suffix Name, and redact a name that is not a secret and is deliberately kept for diagnosis. The compound endings now live in the alternation instead -- secret[_-]?key before secret, alternation being leftmost-first -- and there is no free suffix. Every env-var shape still redacts: AZURE_CLIENT_SECRET, MYSQL_PASSWORD, DJANGO_SECRET_KEY, AWS_SECRET_ACCESS_KEY, TRACEBLOC_API_KEY, quoted values. The specimen is now in the guard, and reintroducing the free suffix reddens it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…908) Same reason as client-runtime#372: a guard that proves redaction works has to contain secret-SHAPED strings, and gitleaks flagged five of them. Per-line markers, not a baseline entry or a file exclusion -- excluding the file would also suppress a real secret pasted into it later. None of these values exists anywhere. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t inline (backend#1908) Inline `gitleaks:allow` markers are not honoured by this pipeline -- pushed, measured, still five findings. .gitleaks.toml is the documented home: its own header calls it the right place for a canary fixture the scanner keeps re-finding, commit-independent unlike the baseline. Path-scoped to the one specimen file, with the cost written down rather than glossed: a real secret pasted into that file later would not be caught. The file is short, its entire content is specimens, and its diff is read on every change -- whereas suppressing the RULE, or baselining, reaches further and decays less visibly. client-runtime#372 names each value instead, because that repo refuses path allowlists for exactly this reason. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
saqlainsyed007
left a comment
There was a problem hiding this comment.
Verified against the regex, the test, and the gitleaks scope — this is a real security fix done carefully.
The leak was real. The generic assignment pattern anchored the keyword with \b on both sides, and since _ is a word char, every env-var spelling escaped redaction — AZURE_CLIENT_SECRET=, MYSQL_PASSWORD=, DJANGO_SECRET_KEY=, AWS_SECRET_ACCESS_KEY= — the exact shapes the logs print (backend#2069 names the unset AZURE_* var). The fix adds a leading [\w.-]* prefix and an optional opening quote, and — the part I most want to see — documents a rejected free-trailing-suffix fix that would have over-redacted SharedAccessKeyName (a non-secret key name kept for diagnosis), keeping the compound endings in the alternation instead. That's the right, precise call.
The floor test is derived, mutation-proof, fail-closed, and tests both directions. It parses the replace_pattern regexes out of the RENDERED config (never restates them), applies them to 14 independently-written secret specimens (rule 9 — not derived from the matcher, so they can't agree by construction), and separately asserts the DIAGNOSIS lines (epoch=, status:, Traceback) survive untouched so a widened pattern can't start eating the diagnosis. A MIN_PATTERNS count floor catches a removal another pattern masks; unrenderable config / zero patterns / an uncompilable pattern each fail closed. It even documents the two-level-escaping bug it hit (skipping the unescape made [^:/@\s] silently match and 8/10 specimens spuriously "leak") — exactly the "getting derivation wrong weakens the guard invisibly" awareness.
The vacuous test is correctly retired. The old "carries all six of controller.py's secret patterns" grepped six substrings it held itself against a file in another repo it never reads — agreeing with itself. Renamed to what it actually checks ("names all six redaction families in the rendered config"), with the behavioural floor moved to the new guard, and the residual cross-repo gap (controller.py) stated honestly, not hidden.
.gitleaks.toml is scoped right: path-anchored to the single fixture file, with the cost (a real secret pasted there later wouldn't be caught) weighed explicitly against the worse alternatives (suppressing the rule / baselining). Not a blanket disable. Chart version bumped (1.9.64), gate green.
CI green, Bugbot pass, no threads, added to DRIFT_GUARDS. This is the floor standing between Class B (customer credentials) and central storage, and it now actually holds. LGTM.
Groundwork for backend#1908 (Class B), and a live gap found on the way. Two findings, one of them a real leak.
1. The check could not fail
tests/telemetry_collector_test.yamlcarriedit: carries all six of controller.py's secret patterns. It greps the rendered config for six substrings it holds itself;controller.pylives inclient-runtimeand is never read. So it agreed with itself and reported that as agreement with a file it has no access to.Add a seventh redaction over there and every assertion stays green while the Collector ships the secret it does not know about. That is
CLAUDE.mdrule 1, and rule 9's corollary about testing a list against itself. Renamed to what it actually checks.2. The floor had a hole — found the first time anything fed it a real string
The generic assignment pattern anchored its keyword with
\bon both sides:_is a word character, so a prefix or suffix removes the boundary and the whole assignment falls out. Measured:password=s3cr3t-p4ssAZURE_CLIENT_SECRET=…MYSQL_PASSWORD=…DJANGO_SECRET_KEY=…AWS_SECRET_ACCESS_KEY=…SECRET_KEY='x9y8z7'It caught the bare form and missed every environment-variable spelling — which is the shape our logs actually print. backend#2069's Gap 1 deliberately names which
AZURE_*variable is unset when provisioning fails. The quoted value fell out separately: the value class excludes quotes, so it matched nothing after the separator.Widened with
[\w.-]*on both sides plus an optional opening quote. Over-redaction is the stated trade incontroller.py's own comment; the guard below bounds it.3. The guard is behavioural, not textual
scripts/tests/collector-redaction-floor.sh, added toDRIFT_GUARDS(now 15).It never compares pattern text. It parses the
replace_patternregexes out of the rendered config — derived, so a pattern added or removed is seen — and applies them to specimens written down independently of the matcher. The question is the only one that matters: would this line leave the cluster with the secret still in it?And it asks the reverse, which nothing asked before: does the diagnosis survive? Class B exists so an ingestion failure is diagnosable at full fidelity. A carelessly widened pattern that eats the traceback is not a win, and until now nothing would have noticed.
Three fail-closed paths: an unrenderable or unparseable config; zero patterns found; and a count floor, because specimens alone cannot see a removal that a broader pattern happens to mask.
Why this matters more than it looks
The Collector is Class A today — containers this chart owns. #1908 opens Class B: training and ingestion output, i.e. customer cell values and customer credentials. The redaction floor is the whole thing standing between that and central storage. A floor asserted by a check that cannot fail is not a floor.
Verification
Mutation-proven three ways, anchor asserted applied each time and restored:
replace_pattern5 pattern(s)… floor is 6[a-z]+=[^ ]+experiment=… -> '*** *** ***'One correction against myself, for the record. The guard's first run reported eight leaking specimens. Seven were my bug, not the product's: I compiled the regexes without OTTL's string-literal unescaping, so
\\sbecame a literal-backslash class that silently refused every specimen containing ans. Fixed, and the failure mode is written into the guard so the next person does not spend the cycle. Only the env-var finding survived scrutiny.What this still cannot see, stated rather than implied
controller.pyitself. CI checks out one repository, so a seventh redaction added inclient-runtimeis invisible here until someone adds its specimen. This PR therefore creates a divergence I am fixing in the same session:controller.pyhas the identical\bhole and needs the same widening, as a separate PR onclient-runtime. Closing the drift properly needs one declaration both repos consume — filed separately.This guard shrinks the gap from three copies, no checks to one cross-repo copy, with the behavioural floor pinned on this side.
Note
High Risk
Changes secret-redaction regexes on logs that will later include customer credentials (Class B). A too-narrow or too-wide pattern either leaks secrets or strips diagnosis.
Overview
Fixes a live leak in Collector log redaction: the generic assignment pattern used
\\bon both sides of keywords, so env-var spellings (AZURE_CLIENT_SECRET=,MYSQL_PASSWORD=,DJANGO_SECRET_KEY=,AWS_SECRET_ACCESS_KEY=) and quoted values never matched. The pattern now allows a prefix and an optional opening quote, without a free suffix that would eatSharedAccessKeyName.Adds
scripts/tests/collector-redaction-floor.shtoDRIFT_GUARDS. It derives regexes from the rendered config and applies them to independent secret specimens plus diagnosis lines that must stay intact (count floor of 6, fail-closed). The helm-unittest case is renamed so it no longer claims agreement withcontroller.py. Chart bump to 1.9.64; the fixture path is allowlisted in.gitleaks.toml.Reviewed by Cursor Bugbot for commit 4da2a3e. Bugbot is set up for automated code reviews on this repo. Configure here.