From 2bb824568b320cccbc27e38185c6387bf0011cdb Mon Sep 17 00:00:00 2001 From: Marlowe <321669285+cairn-intern@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:24:41 -0400 Subject: [PATCH 1/2] fix(redaction): redact adjacent AWS keys glued end to end Two AWS keys with no separator between them (AKIA...AKIA...) only had the first redacted: the leading \b in the secret patterns sees a word character on the left of the second key and misses it. Apply each textSecretPattern via a helper that also retries at every redacted span end with a \b-stripped, start-anchored variant of the pattern, so a credential starting exactly where the previous one ended is still caught. Chained runs (three or more glued credentials) are followed to the end. A mid-word occurrence that does not abut a redacted span still does not match. Extracted from #1075 per Vasanthdev2004's suggestion on #969; #1067 remains the fix for the RedactString leak. --- internal/redaction/redaction.go | 62 +++++++++++++++++++++++++++- internal/redaction/redaction_test.go | 23 +++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index e65312821..bb02a9dd3 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -96,6 +96,64 @@ var textSecretPatterns = []*regexp.Regexp{ regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), } +// anchoredSecretPatterns mirrors textSecretPatterns with the leading \b +// replaced by \A, so each shape can be tested at an exact byte offset. A +// credential that starts exactly where a redacted span ends (two AWS keys +// glued end to end, e.g. AKIA...AKIA...) has no word boundary on its left, +// so the plain pattern misses it; the anchored variant catches it. +var anchoredSecretPatterns = func() []*regexp.Regexp { + out := make([]*regexp.Regexp, len(textSecretPatterns)) + for i, p := range textSecretPatterns { + src := p.String() + if strings.HasPrefix(src, `\b`) { + src = `\A(?:` + src[len(`\b`):] + `)` + } + out[i] = regexp.MustCompile(src) + } + return out +}() + +// redactAdjacent redacts every match of pattern, plus any match of anchored +// that begins exactly where a redacted span ends. This catches credentials +// glued end to end (AKIA...AKIA...) without weakening the leading-boundary +// rule elsewhere: a mid-word occurrence that does not abut a redacted span +// still does not match. Chained runs (three or more glued credentials) are +// followed to the end. +func redactAdjacent(s string, pattern, anchored *regexp.Regexp, replacement string) string { + spans := pattern.FindAllStringIndex(s, -1) + for i := 0; i < len(spans); i++ { + end := spans[i][1] + for end < len(s) { + loc := anchored.FindStringIndex(s[end:]) + if loc == nil || loc[0] != 0 || loc[1] <= 0 { + break + } + newEnd := end + loc[1] + spans = append(spans, []int{end, newEnd}) + end = newEnd + } + } + if len(spans) == 0 { + return s + } + sort.Slice(spans, func(i, j int) bool { return spans[i][0] < spans[j][0] }) + var out strings.Builder + pos := 0 + for _, sp := range spans { + if sp[0] < pos { + continue // overlapping span, already covered + } + out.WriteString(s[pos:sp[0]]) + // s[sp[0]:sp[1]] is a match (anchored spans match at \b when taken + // alone), so ReplaceAllString expands $ references exactly as the + // plain loop below would. + out.WriteString(pattern.ReplaceAllString(s[sp[0]:sp[1]], replacement)) + pos = sp[1] + } + out.WriteString(s[pos:]) + return out.String() +} + var ( privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`) jsonStringPattern = regexp.MustCompile(`("([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*)"([^"\\]*(?:\\.[^"\\]*)*)"`) @@ -233,8 +291,8 @@ func RedactString(value string, options Options) string { } return replacement }) - for _, pattern := range textSecretPatterns { - redacted = pattern.ReplaceAllString(redacted, replacement) + for i, pattern := range textSecretPatterns { + redacted = redactAdjacent(redacted, pattern, anchoredSecretPatterns[i], replacement) } return redacted } diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 3ae228c8d..90ae87750 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -6,6 +6,29 @@ import ( "testing" ) +func TestRedactStringRedactsAdjacentAWSKeys(t *testing.T) { + // Two AWS keys glued end to end have no word boundary between them, so a + // plain \b-anchored pattern only matches the first. Both must be redacted. + // gitleaks:allow -- synthetic redaction fixtures below + key1 := "AKIAIOSFODNN7EXAMPLE" + key2 := "ASIAIOSFODNN7EXAMPLE" + input := key1 + key2 + if got := RedactString(input, Options{}); got != RedactedSecret+RedactedSecret { + t.Fatalf("adjacent AWS keys not both redacted: got %q", got) + } + + // Three glued keys chain as well. + input3 := key1 + key2 + key1 + if got := RedactString(input3, Options{}); got != RedactedSecret+RedactedSecret+RedactedSecret { + t.Fatalf("three adjacent AWS keys not all redacted: got %q", got) + } + + // A mid-word AKIA that does not abut a redacted span still must not match. + if got := RedactString("prefixAKIAIOSFODNN7EXAMPLE", Options{}); got != "prefixAKIAIOSFODNN7EXAMPLE" { + t.Fatalf("mid-word AKIA should not be redacted: got %q", got) + } +} + func TestRedactStringCoversCommonSecretShapes(t *testing.T) { input := strings.Join([]string{ `{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz"}`, From f4c168f0d401ed1fd56b8e419e162f6074f7ff14 Mon Sep 17 00:00:00 2001 From: Marlowe <321669285+cairn-intern@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:28:54 -0400 Subject: [PATCH 2/2] Address CodeRabbit critical: bound redactAdjacent span-chaining loop The outer loop re-read len(spans) while the inner loop appended chained spans to the same slice, so appended spans were re-chained and the span count grew exponentially on long glued-key runs (RedactString handles untrusted content). Bound the loop to the original match count and add a regression test with 64 glued AWS keys asserting 64 markers promptly. --- internal/redaction/redaction.go | 7 ++++++- internal/redaction/redaction_test.go | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index bb02a9dd3..de208e9a7 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -121,7 +121,12 @@ var anchoredSecretPatterns = func() []*regexp.Regexp { // followed to the end. func redactAdjacent(s string, pattern, anchored *regexp.Regexp, replacement string) string { spans := pattern.FindAllStringIndex(s, -1) - for i := 0; i < len(spans); i++ { + // Bound the outer loop to the original match count. The inner loop + // appends chained spans to the same slice; re-reading len(spans) would + // re-chain every appended span, growing the span count (and the + // FindStringIndex calls) exponentially on long glued runs. + n := len(spans) + for i := 0; i < n; i++ { end := spans[i][1] for end < len(s) { loc := anchored.FindStringIndex(s[end:]) diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 90ae87750..b2bb6238c 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -4,6 +4,7 @@ import ( "errors" "strings" "testing" + "time" ) func TestRedactStringRedactsAdjacentAWSKeys(t *testing.T) { @@ -164,3 +165,23 @@ func containsCircular(v any) bool { } return false } + +func TestRedactStringLongAdjacentKeyRunCompletesPromptly(t *testing.T) { + // A long run of glued AWS keys must not blow up the span-chaining loop in + // redactAdjacent: the outer loop is bounded to the original match count, + // so appended spans are never re-chained. On the old code this input + // grows the span list exponentially and never finishes. + // The key is assembled at runtime so the literal never appears in source. + // gitleaks:allow -- synthetic redaction fixture below + key := "AKIA" + strings.Repeat("A", 16) + const count = 64 + input := strings.Repeat(key, count) + start := time.Now() + got := RedactString(input, Options{}) + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("RedactString took %v on %d glued keys", elapsed, count) + } + if want := strings.Repeat(RedactedSecret, count); got != want { + t.Fatalf("expected %d redaction markers, got %d", count, strings.Count(got, RedactedSecret)) + } +}