Repository navigation
fix(redaction): JWT redaction replaces the whole string leaf when the JWT is followed by an escaped quote #3481
Description
Summary
When the crafter redacts an AI coding session, a JWT inside an escaped JSON string can make the redactor replace the whole string leaf with [REDACTED:jwt]. The redactor should replace only the JWT.
This removes all the context around the secret. For example, the host of a presigned URL is lost. Downstream policies (ai-config-no-secrets) then cannot tell a short-lived presigned-URL signature from a leaked credential, and they report false positives.
Where we see it
MCP tools that return a JSON document inside a text block. For example, the Linear MCP get_issue returns the issue as JSON text. When the issue contains a presigned URL (https://uploads.linear.app/...?signature=eyJ...), the full tool result in the uploaded session is:
[{"type": "text", "text": "[REDACTED:jwt]"}]The chainloop.material.redaction.count annotation shows that the redactor found 1 to 3 JWTs in these results. But it replaced the full issue body.
Root cause
-
The redactor scans the document as indented JSON. A string leaf that holds nested JSON is rendered with escaped quotes, so the text after the JWT is
\". -
The betterleaks
jwtrule allows a backslash in the second and third segments:ey[a-zA-Z0-9]{17,}\.ey[a-zA-Z0-9\/\\_-]{17,}\.(?:[a-zA-Z0-9\/\\_-]{10,}={0,2})?The secret capture group is greedy, so it takes the backslash of
\". The suffix(?:\\?['"\x60]|...)then matches the"with an empty\\?. The reported secret iseyJ...xyz\. -
redactLeaf(internal/redaction/redaction.go) replaces that secret in the JSON-encoded leaf body. The result is...[REDACTED:jwt]"..., which has an unescaped quote. -
json.Unmarshalfails, and the fallback atredaction.go:397-402replaces the whole leaf with the placeholder:if err := json.Unmarshal([]byte(`"`+body+`"`), &out); err != nil { // A replacement cut through a JSON escape sequence. Drop the whole leaf return w.placeholder(lastRule) }
The fallback is correct as a fail-closed guard. The problem is that the rule's normal output triggers it for any JWT that is followed by an escaped quote (or \n, \r, \t) in a string leaf.
Reproduction
import json, re
body = r"ey[a-zA-Z0-9]{17,}\.ey[a-zA-Z0-9\/\\_-]{17,}\.(?:[a-zA-Z0-9\/\\_-]{10,}={0,2})?"
rule = re.compile(r"\b(" + body + r")(?:\\?['\"`]|[\s;]|\\[nr]|$)")
jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmYWtlLXVwbG9hZCIsImV4cCI6MTc5MDAwMDAwMH0.c2lnbmF0dXJlLWZha2UtZm9yLXJlcHJv"
leaf = json.dumps({"url": f"https://uploads.linear.app/o/a/b?signature={jwt}"})
secret = rule.search(json.dumps({"text": leaf}, indent=2)).group(1)
print(repr(secret[-4:])) # ends with a backslash
enc = json.dumps(leaf)[1:-1].replace(secret, "[REDACTED:jwt]")
json.loads('"' + enc + '"') # fails -> whole leaf is replacedA Go test in internal/redaction with a leaf such as {"url":"https://h/x?sig=<jwt>"} (a nested JSON string) shows the same result.
Proposed fix
Before the replacement, normalize each finding so that the secret does not end inside an escape sequence:
- In
pendingSecrets(or inbetterleaksScanner.Scan), remove trailing backslashes fromFinding.Secret. A JWT never ends with\, so this is safe for thejwtrule. It is also safe for any other rule whose character class allows\. - Optional: apply the same trim to a leading backslash, and collapse a trailing
\n/\r/\tescape into the terminator. - Keep the whole-leaf fallback as the fail-closed guard for cases that still cannot be decoded.
Add tests that cover:
- A JWT followed by
\"in a nested JSON string leaf: only the JWT is replaced and the URL host stays. - A JWT followed by
\nin a multi-line string leaf. - Idempotency: redacting the output again changes nothing.
Impact
- Evidence loss: full MCP tool results are removed from the session, not only the credential.
- False positives:
ai-config-no-secretssupportsallowed_signed_url_hostsso that presigned-URL JWTs are not reported. That check needs the URL host next to the placeholder. When the whole leaf is replaced, the host is gone and the check cannot suppress the finding.
More occurrences
- platform PR chainloop-dev/platform#6628: the
opa execcheck shows that theallowed_signed_url_hostsallowlist works. The redaction removes the host before the policy can see it. - platform PR chainloop-dev/platform#6674 (PFM-7551):
ai-config-no-secretsreportsSecret (jwt) detected in session content [turn=46, source=tool_result, line=1]: [REDACTED:jwt]. Turn 46 is a Linearget_issuecall on an issue that has a screenshot attachment.
Each session that reads a Linear issue with an image fails this check. This makes the check noise on most PRs.
🤖 Posted by Maximus bot (Claude Code) on behalf of @migmartri
Activity
Merge pull request #3 from orbi-build/orbi/orbi-build-chainloop-issue-1
fix(redaction): redact only the JWT when an escape sequence follows it
fix(redaction): redact only the JWT when an escape sequence follows it