Skip to content

Commit 9d5b368

Browse files
authored
fix(redaction): replace only the JWT when it is followed by a JSON escape (#3508)
1 parent 78d1a9b commit 9d5b368

4 files changed

Lines changed: 172 additions & 6 deletions

File tree

‎internal/redaction/betterleaks.go‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,46 @@ func byteSpans(text string, ranges []lineSpan) []span {
332332
// appendSecret records a locatable secret. A finding without one cannot be
333333
// searched for in the document, so it is dropped rather than reported.
334334
func appendSecret(dst []Finding, ruleID, secret string) []Finding {
335+
if ruleID == jwtRuleID {
336+
secret = cutAtTerminatorEscape(secret)
337+
}
335338
if secret == "" {
336339
return dst
337340
}
338341
return append(dst, Finding{RuleID: ruleID, Secret: secret})
339342
}
340343

344+
// jwtRuleID is the default ruleset's rule for JSON Web Tokens.
345+
const jwtRuleID = "jwt"
346+
347+
// cutAtTerminatorEscape removes the text from the first whitespace or quote
348+
// escape onwards.
349+
//
350+
// The jwt rule allows a backslash in its last two segments, so in JSON-encoded
351+
// text its match runs through an escape such as `\n` and into the word after
352+
// it. Replacing that match would remove text that is not part of the token. A
353+
// JWT is base64url, so the part before such an escape is the whole token. Other
354+
// escapes, such as `\/` or `\\`, are kept: they are not proof that the token has
355+
// ended, and keeping them can only over-redact.
356+
//
357+
// Escapes are read as units from the left, so the second backslash of a `\\`
358+
// escape is never taken as the start of another escape.
359+
func cutAtTerminatorEscape(secret string) string {
360+
for i := 0; i+1 < len(secret); i++ {
361+
if secret[i] != '\\' {
362+
continue
363+
}
364+
switch secret[i+1] {
365+
case '"', 'n', 'r', 't':
366+
return secret[:i]
367+
}
368+
// Skip the escaped character, so that it is not read as the start of
369+
// an escape.
370+
i++
371+
}
372+
return secret
373+
}
374+
341375
// span is a half-open byte range [start, end) of a text.
342376
type span struct {
343377
start, end int

‎internal/redaction/betterleaks_test.go‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package redaction
1717

1818
import (
1919
"context"
20+
"encoding/json"
2021
"regexp"
2122
"strconv"
2223
"strings"
@@ -42,6 +43,7 @@ const (
4243
fakeAWSKey = "AKIA" + "4G7TI63VCBIRS4GW"
4344
fakeAWSSecret = "kQ7zXn2VbW9pLm4RtY6" + "uHs3JdF8gA1cE5oPzQwXn"
4445
fakeGitHubPAT = "ghp_erOZlZv0B1e3amrQ" + "ugdwZ8Ro2W4kDql9WPTf"
46+
fakeJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiJmYWtlLXVwbG9hZCIsImV4cCI6MTc5MDAwMDAwMH0" + ".c2lnbmF0dXJlLWZha2UtZm9yLXJlcHJv"
4547
)
4648

4749
var fakeAnthropicKey = "sk-ant-api03-" + strings.Repeat("a", 93) + "AA"
@@ -279,6 +281,101 @@ func TestRedactCredentialInURIConverges(t *testing.T) {
279281
assert.False(t, report.Changed())
280282
}
281283

284+
// TestRedactJWTKeepsSurroundingText covers a JWT that is followed by a JSON
285+
// escape sequence in its string leaf. The jwt rule allows a backslash in its
286+
// last segments, so the scanner reports the secret with the backslash that
287+
// starts the escape. Only the JWT must be replaced: the text around it, such as
288+
// the host of a presigned URL, is what lets policies tell a short-lived URL
289+
// signature from a leaked credential.
290+
func TestRedactJWTKeepsSurroundingText(t *testing.T) {
291+
scanner, err := DefaultScanner()
292+
require.NoError(t, err)
293+
294+
testCases := []struct {
295+
name string
296+
leaf string
297+
want string
298+
}{
299+
{
300+
// A tool result that holds a JSON document as text: the quote after
301+
// the JWT is escaped in the leaf.
302+
name: "followed by an escaped quote in a nested JSON string",
303+
leaf: `{"url":"https://uploads.example.com/o/a/b?signature=` + fakeJWT + `"}`,
304+
want: `{"url":"https://uploads.example.com/o/a/b?signature=[REDACTED:jwt]"}`,
305+
},
306+
{
307+
name: "followed by an escaped newline",
308+
leaf: "https://uploads.example.com/o/a/b?signature=" + fakeJWT + "\nnext line",
309+
want: "https://uploads.example.com/o/a/b?signature=[REDACTED:jwt]\nnext line",
310+
},
311+
{
312+
name: "followed by an escaped carriage return",
313+
leaf: "https://uploads.example.com/o/a/b?signature=" + fakeJWT + "\rnext line",
314+
want: "https://uploads.example.com/o/a/b?signature=[REDACTED:jwt]\rnext line",
315+
},
316+
{
317+
name: "followed by an escaped tab",
318+
leaf: "https://uploads.example.com/o/a/b?signature=" + fakeJWT + "\tnext line",
319+
want: "https://uploads.example.com/o/a/b?signature=[REDACTED:jwt]\tnext line",
320+
},
321+
{
322+
// The literal backslash is a complete `\\` escape, which is kept in
323+
// the secret: the leaf loses that one character but not its context.
324+
name: "followed by a literal backslash",
325+
leaf: `https://uploads.example.com/o/a/b?signature=` + fakeJWT + `\"`,
326+
want: `https://uploads.example.com/o/a/b?signature=[REDACTED:jwt]"`,
327+
},
328+
}
329+
330+
for _, tc := range testCases {
331+
t.Run(tc.name, func(t *testing.T) {
332+
doc, err := json.Marshal(map[string]string{"text": tc.leaf})
333+
require.NoError(t, err)
334+
335+
once, report, err := New(scanner).Redact(context.Background(), doc)
336+
require.NoError(t, err)
337+
require.True(t, report.Changed())
338+
assert.Equal(t, map[string]int{"jwt": 1}, report.ByRule)
339+
340+
var got map[string]string
341+
require.NoError(t, json.Unmarshal(once, &got))
342+
assert.Equal(t, tc.want, got["text"], "only the JWT is replaced")
343+
344+
twice, report, err := New(scanner).Redact(context.Background(), once)
345+
require.NoError(t, err)
346+
assert.Equal(t, string(once), string(twice))
347+
assert.False(t, report.Changed())
348+
})
349+
}
350+
}
351+
352+
// TestCutAtTerminatorEscape checks that the cut is made only at a real escape.
353+
// In JSON-encoded text a backslash that belongs to a `\\` escape does not start
354+
// another escape, so `\\n` is a literal backslash followed by the letter n.
355+
func TestCutAtTerminatorEscape(t *testing.T) {
356+
testCases := []struct {
357+
name string
358+
secret string
359+
want string
360+
}{
361+
{name: "no escape", secret: `plain`, want: `plain`},
362+
{name: "escaped quote", secret: `quote\"after`, want: `quote`},
363+
{name: "escaped newline", secret: `newline\nafter`, want: `newline`},
364+
{name: "escaped carriage return", secret: `return\rafter`, want: `return`},
365+
{name: "escaped tab", secret: `tab\tafter`, want: `tab`},
366+
{name: "escaped backslash then n is not a newline", secret: `bs\\nafter`, want: `bs\\nafter`},
367+
{name: "escaped backslash then quote escape", secret: `bsquote\\\"after`, want: `bsquote\\`},
368+
{name: "escaped slash is kept", secret: `slash\/kept\nafter`, want: `slash\/kept`},
369+
{name: "trailing lone backslash is left to the engine", secret: `lone\`, want: `lone\`},
370+
}
371+
372+
for _, tc := range testCases {
373+
t.Run(tc.name, func(t *testing.T) {
374+
assert.Equal(t, tc.want, cutAtTerminatorEscape(tc.secret))
375+
})
376+
}
377+
}
378+
282379
// TestDefaultScannerUsesRE2 pins the regex engine. The library default is the
283380
// standard library engine, which is several times slower on the default ruleset
284381
// and made large sessions take tens of minutes to redact.

‎internal/redaction/redaction.go‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -312,23 +312,24 @@ func pendingSecrets(findings []Finding, skip map[string]struct{}, isPlaceholder
312312
out := make([]secretRule, 0, len(findings))
313313

314314
for _, f := range findings {
315-
if f.Secret == "" {
315+
secret := trimPartialEscape(f.Secret)
316+
if secret == "" {
316317
continue
317318
}
318-
if _, skipped := skip[f.Secret]; skipped {
319+
if _, skipped := skip[secret]; skipped {
319320
continue
320321
}
321322
// A finding whose whole secret is a placeholder is a rule matching the
322323
// position it sits in, not a credential. Rewriting it would churn the
323324
// document on every run and never terminate.
324-
if isPlaceholder != nil && isPlaceholder(f.Secret) {
325+
if isPlaceholder != nil && isPlaceholder(secret) {
325326
continue
326327
}
327-
if _, dup := seen[f.Secret]; dup {
328+
if _, dup := seen[secret]; dup {
328329
continue
329330
}
330-
seen[f.Secret] = struct{}{}
331-
out = append(out, secretRule{secret: f.Secret, ruleID: f.RuleID})
331+
seen[secret] = struct{}{}
332+
out = append(out, secretRule{secret: secret, ruleID: f.RuleID})
332333
}
333334

334335
sort.Slice(out, func(i, j int) bool {
@@ -340,6 +341,24 @@ func pendingSecrets(findings []Finding, skip map[string]struct{}, isPlaceholder
340341
return out
341342
}
342343

344+
// trimPartialEscape removes a trailing backslash that starts a JSON escape
345+
// sequence the secret does not include.
346+
//
347+
// The scanner sees JSON-encoded text, where every backslash starts or completes
348+
// an escape. A rule whose character class allows a backslash can end its match
349+
// on the first character of an escape such as `\"` or `\n`. Replacing that
350+
// secret would leave the rest of the escape behind, the leaf would no longer
351+
// decode, and the whole leaf would be replaced. An odd run of trailing
352+
// backslashes is the sign of that: an even run is a set of complete `\\`
353+
// escapes, which are part of the secret.
354+
func trimPartialEscape(secret string) string {
355+
run := len(secret) - len(strings.TrimRight(secret, `\`))
356+
if run%2 == 1 {
357+
return secret[:len(secret)-1]
358+
}
359+
return secret
360+
}
361+
343362
// rewriter walks a decoded JSON value tree replacing secrets in eligible string
344363
// leaves.
345364
type rewriter struct {

‎internal/redaction/redaction_test.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,22 @@ func TestRedact(t *testing.T) {
150150
mustNotContain: []string{"before", "after"},
151151
mustContain: []string{"[REDACTED:r1]"},
152152
},
153+
{
154+
name: "secret ending on the backslash of an escape keeps the leaf",
155+
doc: `{"a":"{\"url\":\"https://h/x?sig=SEC\"}"}`,
156+
findings: []Finding{{RuleID: "r1", Secret: `SEC\`}},
157+
wantReplacements: 1,
158+
wantByRule: map[string]int{"r1": 1},
159+
mustContain: []string{`{"a":"{\"url\":\"https://h/x?sig=[REDACTED:r1]\"}"}`},
160+
},
161+
{
162+
name: "secret ending on a complete escaped backslash keeps it",
163+
doc: `{"a":"SEC\\ after"}`,
164+
findings: []Finding{{RuleID: "r1", Secret: `SEC\\`}},
165+
wantReplacements: 1,
166+
wantByRule: map[string]int{"r1": 1},
167+
mustContain: []string{`{"a":"[REDACTED:r1] after"}`},
168+
},
153169
{
154170
name: "protected path is left alone and recorded",
155171
doc: `{"keepme":"SEC","other":"plain"}`,

0 commit comments

Comments
 (0)