diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index e65312821..de208e9a7 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -96,6 +96,69 @@ 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) + // 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:]) + 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 +296,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..b2bb6238c 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -4,8 +4,32 @@ import ( "errors" "strings" "testing" + "time" ) +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"}`, @@ -141,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)) + } +}