Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions internal/redaction/betterleaks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,48 @@ func TestRedactCredentialInURIConverges(t *testing.T) {
assert.False(t, report.Changed())
}

// fakeJWT is an unsigned, syntactically-shaped token: the ruleset matches on
// structure, so nothing here is a credential. It is assembled from fragments the
// same way the AWS pair above is, to keep the literal out of a single string.
var fakeJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
"." + "eyJ1cmwiOiJodHRwczovL3VwbG9hZHMubGluZWFyLmFwcC9maWxlIn0" +
"." + "ZmFrZXNpZ25hdHVyZS1ub3QtYS1jcmVkZW50aWFs"

// TestRedactJWTAtEndOfNestedJSONLeafKeepsTheLeaf covers the shape an MCP tool
// result has: a JSON document carried inside a text block, so the leaf the
// scanner walks is the JSON-encoded form and the closing quote of the nested
// document arrives as `\"`. The jwt rule admits backslashes in the second and
// third segments, so it reports the token together with the backslash that opens
// that escape. It is outer encoding rather than credential material: dropping it
// unbalances the escapes, the leaf no longer re-encodes, and the fail-closed path
// used to replace the whole transcript with a single placeholder — taking the
// presigned URL's host with it, which is what the policies read.
func TestRedactJWTAtEndOfNestedJSONLeafKeepsTheLeaf(t *testing.T) {
scanner, err := DefaultScanner()
require.NoError(t, err)

doc := []byte(`{"type":"text","text":"{\"issue\":{\"url\":\"https://uploads.linear.app/file/abc?signature=` +
fakeJWT + `\"}}"}`)

out, report, err := New(scanner).Redact(context.Background(), doc)
require.NoError(t, err)
require.True(t, report.Changed())

assert.NotContains(t, string(out), fakeJWT)
assert.Contains(t, string(out), "[REDACTED:jwt]")
// The context around the secret survives, so a policy can still see which
// host issued the signature.
assert.Contains(t, string(out), "uploads.linear.app")
assert.Contains(t, string(out), "issue")
assert.Equal(t, 1, report.Replacements)

// Re-running is a no-op rather than a second round of damage.
again, secondReport, err := New(scanner).Redact(context.Background(), out)
require.NoError(t, err)
assert.Equal(t, string(out), string(again))
assert.False(t, secondReport.Changed())
}

func BenchmarkDefaultScannerInit(b *testing.B) {
for b.Loop() {
if _, err := newBetterleaksScanner(); err != nil {
Expand Down
80 changes: 75 additions & 5 deletions internal/redaction/redaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,13 +379,13 @@ func (w *rewriter) redactLeaf(s string) string {
lastRule string
)
for _, sr := range w.secrets {
c := strings.Count(body, sr.secret)
if c == 0 {
occurrences := replaceOccurrences(body, sr.secret, w.placeholder(sr.ruleID))
if occurrences.count == 0 {
continue
}
body = strings.ReplaceAll(body, sr.secret, w.placeholder(sr.ruleID))
n += c
w.byRule[sr.ruleID] += c
body = occurrences.body
n += occurrences.count
w.byRule[sr.ruleID] += occurrences.count
w.located[sr.secret] = struct{}{}
lastRule = sr.ruleID
}
Expand All @@ -403,6 +403,76 @@ func (w *rewriter) redactLeaf(s string) string {
return out
}

// replacement is the outcome of one substitution pass over a leaf.
type replacement struct {
body string
count int
}

// replaceOccurrences substitutes every non-overlapping occurrence of secret with
// placeholder, the way strings.ReplaceAll does, except that a match ending on the
// backslash that introduces the escape sequence right after it is shortened by
// that backslash.
//
// Leaves are scanned in their JSON-encoded form, and a rule whose character class
// admits a backslash — betterleaks' jwt rule does — therefore reports a credential
// sitting at the end of a nested-JSON string together with the `\` of the closing
// `\"`. Removing it along with the secret leaves a bare quote, so re-encoding the
// leaf fails and the caller loses the whole leaf: the host of a presigned URL is
// precisely the context the ai-config-no-secrets policies need in order to tell a
// short-lived signature from a leaked credential. That backslash is outer encoding
// rather than credential material, so it survives.
func replaceOccurrences(body, secret, placeholder string) replacement {
var (
out strings.Builder
count int
pos int
)
for {
i := strings.Index(body[pos:], secret)
if i < 0 {
break
}
i += pos
end := i + len(secret)
if isOuterEscapeIntroducer(body, i, end) {
end--
}
out.WriteString(body[pos:i])
out.WriteString(placeholder)
pos = end
count++
}
if count == 0 {
return replacement{body: body}
}
out.WriteString(body[pos:])
return replacement{body: out.String(), count: count}
}

// isOuterEscapeIntroducer reports whether the character just before end is the
// backslash opening the escape sequence that starts at end, rather than part of
// the secret itself. Shortening a match that only spans that backslash would
// redact nothing, so a one-character match is never shortened.
func isOuterEscapeIntroducer(body string, i, end int) bool {
if end <= i+1 || end >= len(body) || body[end-1] != '\\' {
return false
}
switch body[end] {
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u':
Comment thread
migmartri marked this conversation as resolved.
default:
return false
}

// A run of backslashes of even length is a sequence of escaped backslashes, so
// its last character is literal credential material and has to be removed.
run := 0
for p := end - 1; p >= i && body[p] == '\\'; p-- {
run++
}
return run%2 == 1
}

// decodeObject parses doc into a value tree, keeping numbers in their original
// textual form so re-encoding does not reformat them.
func decodeObject(doc []byte) (map[string]any, error) {
Expand Down
13 changes: 13 additions & 0 deletions internal/redaction/redaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ func TestRedact(t *testing.T) {
mustNotContain: []string{"before", "after"},
mustContain: []string{"[REDACTED:r1]"},
},
{
// The jwt rule permits backslashes inside a token, so a credential at
// the very end of a nested-JSON string is reported together with the
// backslash of the closing `\"`. That backslash belongs to the outer
// encoding, not to the secret.
name: "secret ending on an escape introducer keeps the leaf context",
doc: `{"a":"{\"url\":\"https://uploads.linear.app/file/abc?signature=` + fakeJWT +
`\"}"}`,
findings: []Finding{{RuleID: "jwt", Secret: fakeJWT + `\`}},
wantReplacements: 1,
mustNotContain: []string{fakeJWT},
mustContain: []string{"uploads.linear.app", "file/abc?signature=[REDACTED:jwt]"},
},
{
name: "protected path is left alone and recorded",
doc: `{"keepme":"SEC","other":"plain"}`,
Expand Down
Loading