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
67 changes: 65 additions & 2 deletions internal/redaction/redaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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*)"([^"\\]*(?:\\.[^"\\]*)*)"`)
Expand Down Expand Up @@ -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
}
Expand Down
44 changes: 44 additions & 0 deletions internal/redaction/redaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`,
Expand Down Expand Up @@ -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))
}
}
Loading