Repository navigation
Redact compound secret key names and doubled-quote JSON - #274
Sanjay Singh (san360) merged 3 commits into
Conversation
redactSecrets masks transcript text before it is sent to a language model, but two common shapes slipped through it unchanged. Both assignment patterns anchor the key name with \b, which does not match between an underscore and a letter. `client_secret`, `refresh_token`, `app_token` and `db_password` therefore never matched, while the bare `secret` and `token` forms did. Match an optional compound prefix so the qualified names are covered too. The quote group also accepted a single quote character only. JSON embedded in a shell command is routinely escaped by doubling its quotes, as Claude Code does when recording a curl invocation in a permission allow rule, so `""client_secret"":""…""` matched neither pattern. Accept quote runs of one or two characters. Add the Context7 key prefix alongside the other vendor tokens; its `ctx7sk-` form is not reachable by the existing `sk-` pattern because the leading digit suppresses the word boundary. Checked against a real settings file: two credential-shaped strings survived redaction before, none do now, and prose mentioning "token", "secret" or "password" is still left intact.
cspell flagged 'ijklmnop' and 'bursty' in the new tests, which fails npm run check. Replace them without changing what the tests assert.
Sanjay Singh (san360)
left a comment
There was a problem hiding this comment.
Thank you mostafaseyedan for this one, and for the very clear write-up! The \b-between-_-and-a-letter explanation and the doubled-quote example made the problem easy to follow, and it's a real gap: compound names like client_secret, refresh_token and AZURE_CLIENT_SECRET= went to the model unredacted. We appreciate the false-positive checks in the description and the prose test too.
What we verified
- Your three new behavioural tests fail on current
mainand pass with the PR; the full unit suite passes onmain+ this PR (typecheck, lint and knip are clean, and the bundle stays within budget). - On a corpus of ~35 strings, the PR redacts every new case (
client_secret,refresh_token,app_token,db_password,AZURE_CLIENT_SECRET=…,id_token: …,ctx7sk-…, doubled-quote JSON) and leaves the prose,tokenizer,max_tokens, camelCaseinputTokens,token_urlandsecret_namecases unchanged. - With real Claude Code and Copilot Chat sessions in throwaway repos (fake credentials in the prompts), transcript text that goes through
spotlight()no longer carries any of the fake values; onmainall of them got through. The dashboard is unchanged on every page, as expected for an egress-only change.
Suggestions (details and a ready-to-apply version inline)
-
The motivating
settings.local.jsoncase still gets through. Inside a JSON file the doubled quotes are themselves backslash-escaped, so the file contains\"\"client_secret\"\":\"\"…\"\", not""client_secret"":""…"". Context Health sends the raw file text (getContextReviewPayloadinsrc/core/analyzer-config.tsreads it withreadFileSync), so neither pattern matches there. We reproduced it with a.claude/settings.local.jsonholding twoBash(curl …)allow rules: both secret values still reached the review prompt with the PR. The same shape appears in chat tool results, which areJSON.stringify'd before redaction (textResultinsrc/mcp/tools.ts), so\"client_secret\":\"…\"inside a string isn't caught either. Allowing an optional backslash before each quote fixes both. -
An empty value can swallow the fields after it. With
["']{1,2},{"token": "", "user": "bob", "note": ""}becomes{"token": ""[REDACTED]""}: the empty""is taken as a doubled opening quote and everything up to the next""is masked. It only over-redacts, but it removes non-secret context and breaks the JSON shape. Giving the doubled-quote form its own pattern (doubled quotes around the key and the value) avoids this. -
A small one: a lookbehind instead of the compound prefix.
\b(?:[A-Za-z0-9]+[_-])*is retried from every word boundary inside a long hyphen- or underscore-joined run, so the cost grows quadratically with the run length (about 2.3 s for 50 KB ofa-a_on our machine versus a few ms onmain).(?<![A-Za-z0-9])in front of the key name matches the same cases (theclient_part simply stays outside the match, so the replacement output is identical) and stays linear. Real text rarely has such long runs, so this is minor.
With all three applied, our runs give identical output to the PR on every case above, catch the settings-file and stringified forms, and keep the empty-value JSON intact. Since redactSecrets applies the patterns as a .replaceAll chain, the new doubled-quote pattern also needs one line in it, just before QUOTED_SECRET_ASSIGNMENT (that line isn't part of the diff, so here it is):
.replaceAll(DOUBLED_QUOTE_SECRET_ASSIGNMENT, '$1$2$3[REDACTED]$3')
.replaceAll(QUOTED_SECRET_ASSIGNMENT, '$1$2$3[REDACTED]$3')And two tests that pin the new behaviour (they fail on the current PR and pass with the changes):
it('keeps the fields after an empty secret value', () => {
const text = '{"token": "", "user": "bob", "note": ""}';
expect(redactSecrets(text)).toBe(text);
});
it('redacts a doubled-quote rule as it is stored in settings.local.json', () => {
const settings = JSON.stringify({ permissions: { allow: [
'Bash(curl -d \'{""client_secret"":""8bavjPwL9uoXDp""}\')',
] } }, null, 2);
const out = redactSecrets(settings);
expect(out).not.toContain('8bavjPwL9uoXDp');
expect(JSON.parse(out)).toBeTruthy();
});Next steps: the CI runs on this branch didn't start (the runs from September show no jobs), so a maintainer will re-run them once the branch is updated. Separately, for workspaces opened only in Claude Code, Context Health reaches these files once the workspace root resolves, which is what your #273 addresses.
Thanks again for tightening this up. Redaction is one of those places where every caught shape matters, and this is a clear improvement.
| const SECRET_KEY_NAME = String.raw`(?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|access[_-]?key|secret|token|password|passwd|credentials?)`; | ||
| const QUOTED_SECRET_ASSIGNMENT = new RegExp( | ||
| String.raw`\b(${SECRET_KEY_NAME})(["']{0,2}\s*[:=]\s*)(["']{1,2})((?:(?!\3).){4,512})\3`, 'gi'); | ||
| const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; | ||
| const AUTH_HEADER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,512}/gi; | ||
| const KEY_VALUE_ASSIGNMENT = /\b(api[_-]?key|access[_-]?key|secret|token|password|passwd|credentials?)(["']?\s*[:=]\s*)(["']?)[^\s"'`;,]{8,512}\3/gi; | ||
| const KEY_VALUE_ASSIGNMENT = new RegExp( | ||
| String.raw`\b(${SECRET_KEY_NAME})(["']{0,2}\s*[:=]\s*)(["']{0,2})[^\s"'\`;,]{8,512}\3`, 'gi'); |
There was a problem hiding this comment.
Inside a JSON file the doubled quotes are escaped again, so .claude/settings.local.json contains \"\"client_secret\"\":\"\"…\"\". Context Health sends that raw text, and neither pattern matches it, so the two curl-rule secrets in our test file still reached the review prompt. A related case: {"token": "", "user": "bob", "note": ""} becomes {"token": ""[REDACTED]""} because the empty "" is read as a doubled opening quote.
One option that handles both: a lookbehind for the key name (same matches, and it stays linear on long a-b-c… runs), optional backslashes before quotes, and a separate pattern for the doubled form. It needs one extra .replaceAll(DOUBLED_QUOTE_SECRET_ASSIGNMENT, '$1$2$3[REDACTED]$3') before the QUOTED_SECRET_ASSIGNMENT line (see the review body).
| const SECRET_KEY_NAME = String.raw`(?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|access[_-]?key|secret|token|password|passwd|credentials?)`; | |
| const QUOTED_SECRET_ASSIGNMENT = new RegExp( | |
| String.raw`\b(${SECRET_KEY_NAME})(["']{0,2}\s*[:=]\s*)(["']{1,2})((?:(?!\3).){4,512})\3`, 'gi'); | |
| const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; | |
| const AUTH_HEADER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,512}/gi; | |
| const KEY_VALUE_ASSIGNMENT = /\b(api[_-]?key|access[_-]?key|secret|token|password|passwd|credentials?)(["']?\s*[:=]\s*)(["']?)[^\s"'`;,]{8,512}\3/gi; | |
| const KEY_VALUE_ASSIGNMENT = new RegExp( | |
| String.raw`\b(${SECRET_KEY_NAME})(["']{0,2}\s*[:=]\s*)(["']{0,2})[^\s"'\`;,]{8,512}\3`, 'gi'); | |
| // The key name may follow `_` or `-` (client_secret, refresh_token) but not a letter or digit. | |
| // Quotes may be backslash-escaped, as in raw JSON files and JSON.stringify output. | |
| const SECRET_KEY_NAME = String.raw`(?<![A-Za-z0-9])(api[_-]?key|access[_-]?key|secret|token|password|passwd|credentials?)`; | |
| // JSON escaped by doubling its quotes inside a shell command: ""client_secret"":""value"". | |
| const DOUBLED_QUOTE_SECRET_ASSIGNMENT = new RegExp( | |
| String.raw`${SECRET_KEY_NAME}((?:\\?"){2}\s*:\s*)((?:\\?"){2})((?:(?!\3).){4,512})\3`, 'gi'); | |
| const QUOTED_SECRET_ASSIGNMENT = new RegExp( | |
| String.raw`${SECRET_KEY_NAME}((?:\\?["'])?\s*[:=]\s*)(\\?["'])((?:(?!\3).){4,512})\3`, 'gi'); | |
| const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; | |
| const AUTH_HEADER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,512}/gi; | |
| const KEY_VALUE_ASSIGNMENT = new RegExp( | |
| String.raw`${SECRET_KEY_NAME}(["']?\s*[:=]\s*)(["']?)[^\s"'\`;,]{8,512}\3`, 'gi'); |
| * Key names are matched with an optional compound prefix, so `client_secret` | ||
| * and `refresh_token` are caught as well as bare `secret` and `token`. Quote | ||
| * runs of one or two characters are accepted, because JSON embedded in a | ||
| * shell command is commonly escaped by doubling its quotes. |
There was a problem hiding this comment.
If the patterns change as suggested, the header could describe them like this:
| * Key names are matched with an optional compound prefix, so `client_secret` | |
| * and `refresh_token` are caught as well as bare `secret` and `token`. Quote | |
| * runs of one or two characters are accepted, because JSON embedded in a | |
| * shell command is commonly escaped by doubling its quotes. | |
| * Key names may follow `_` or `-`, so `client_secret` and `refresh_token` are | |
| * caught as well as bare `secret` and `token`. JSON embedded in a shell command | |
| * is commonly escaped by doubling its quotes, so that form has its own pattern. |
|
Thank you so much, Sanjay Singh (@san360), for this very careful review! The I applied all three suggestions, the extra Checked locally against the previous commit of this branch:
Thanks again for the time you put into this! |
- Allow an optional backslash before each quote, so raw JSON files (settings.local.json) and JSON.stringify output are redacted. - Give the doubled-quote form its own pattern, so an empty value no longer swallows the fields after it. - Use a lookbehind for the key name instead of a compound prefix, so matching stays linear on long hyphen or underscore runs. Suggested by @san360 in review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLDXsjE1DMDor8E6cSLBpH
|
Thank you mostafaseyedan for the quick and careful update, and for the kind words! We re-checked
This looks good to merge from our side once CI passes. The workflow runs for the new commit are waiting for a maintainer to approve them, and that's the only remaining step. Thanks again for finding this and seeing it through! |
Problem
redactSecretsmasks credential-shaped text before it is sent to a language model — bycallLlm/callLlmJsonviaredactMessages, so it guards every AI feature. Two common shapes pass through it unchanged.1. Compound key names. Both assignment patterns anchor the key with
\b:/\b(api[_-]?key|access[_-]?key|secret|token|password|passwd|credentials?)…/gi\bdoes not match between_and a letter, soclient_secretnever matches while baresecretdoes:2. Doubled-quote JSON. The quote group accepts exactly one quote character. JSON embedded in a shell command is routinely escaped by doubling its quotes — which is how Claude Code records a
curlinvocation in a permission allow rule:(["'])captures one", the content class then immediately hits the second", and the match fails. Neither pattern fires.These combine badly in practice. A
.claude/settings.local.jsonaccumulatesBash(curl …)allow rules verbatim, so OAuth client secrets end up stored in exactly the shape that defeats both patterns, and Context Health reads those files and sends them to the model.3. Separately, a Context7 key (
ctx7sk-…) is not reachable by the existingsk-pattern, because the preceding digit suppresses the word boundary.Fix
SECRET_KEY_NAMEwith an optional(?:[A-Za-z0-9]+[_-])*prefix, so qualified names are covered.ctx7sk-alongside the other vendor prefixes.client_idis deliberately still not redacted — it is an identifier rather than a credential, and adding it would start redacting ordinary config.Testing
Four tests added. Verified against a real 122-rule
settings.local.json: two credential-shaped strings survived redaction before, none do after, 18 redaction markers inserted.False-positive checked — these are still returned unchanged:
All 25 tests in
redact-secrets.test.tspass (21 pre-existing, unmodified).npm run typecheckandnpm run lintclean (0 errors). The 7github-app-analyticsfailures are pre-existing onmain.