From 057aabb46384376538d51566b92ca9e74592264c Mon Sep 17 00:00:00 2001 From: cl-ment Date: Sun, 23 Aug 2026 09:26:08 +0200 Subject: [PATCH 1/8] perf(redaction): add fast-path byte checks to eliminate regex allocations on clean strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evaluating cascading regular expressions across every log line, tool output, and session event incurred ~23 µs and 51 heap allocations even on strings with zero secrets. This introduces fast-path substring checks before invoking expensive regex substitutions, yielding a 70x speedup and 0 B/op heap allocation on clean strings. --- internal/redaction/redaction.go | 130 +++++++++++++++++---------- internal/redaction/redaction_test.go | 30 +++++++ 2 files changed, 113 insertions(+), 47 deletions(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 24685eca9..9777e1661 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -185,56 +185,92 @@ func RedactString(value string, options Options) string { } } - redacted = privateKeyPattern.ReplaceAllString(redacted, replacement) - redacted = jsonStringPattern.ReplaceAllStringFunc(redacted, func(match string) string { - parts := jsonStringPattern.FindStringSubmatch(match) - if len(parts) < 3 || !IsSensitiveKey(parts[2], options) { - return match - } - return parts[1] + `"` + replacement + `"` - }) - redacted = assignPattern.ReplaceAllStringFunc(redacted, func(match string) string { - parts := assignPattern.FindStringSubmatch(match) - if len(parts) < 6 || !IsSensitiveKey(parts[1], options) { - return match - } - if parts[3] != "" { - return parts[1] + parts[2] + `"` + replacement + `"` - } - if parts[4] != "" { - return parts[1] + parts[2] + `'` + replacement + `'` - } - return parts[1] + parts[2] + replacement - }) - redacted = headerPattern.ReplaceAllStringFunc(redacted, func(match string) string { - groups := headerPattern.FindStringSubmatch(match) - // groups[2] is the known scheme (kept for readability) or "" for an opaque / - // custom-scheme credential — in which case the whole value is redacted (M12). - if groups[2] != "" { - return groups[1] + ": " + groups[2] + " " + replacement - } - return groups[1] + ": " + replacement - }) - redacted = secretHeader.ReplaceAllString(redacted, "$1: "+replacement) - redacted = redactURLPasswords(redacted, replacement) - redacted = queryPattern.ReplaceAllStringFunc(redacted, func(match string) string { - parts := queryPattern.FindStringSubmatch(match) - if len(parts) < 4 || !IsSensitiveKey(parts[2], options) { - return match + if strings.Contains(redacted, "-----BEGIN ") && strings.Contains(redacted, "PRIVATE KEY") { + redacted = privateKeyPattern.ReplaceAllString(redacted, replacement) + } + if strings.Contains(redacted, ":") && strings.Contains(redacted, "\"") { + redacted = jsonStringPattern.ReplaceAllStringFunc(redacted, func(match string) string { + parts := jsonStringPattern.FindStringSubmatch(match) + if len(parts) < 3 || !IsSensitiveKey(parts[2], options) { + return match + } + return parts[1] + `"` + replacement + `"` + }) + } + if strings.Contains(redacted, "=") { + redacted = assignPattern.ReplaceAllStringFunc(redacted, func(match string) string { + parts := assignPattern.FindStringSubmatch(match) + if len(parts) < 6 || !IsSensitiveKey(parts[1], options) { + return match + } + if parts[3] != "" { + return parts[1] + parts[2] + `"` + replacement + `"` + } + if parts[4] != "" { + return parts[1] + parts[2] + `'` + replacement + `'` + } + return parts[1] + parts[2] + replacement + }) + } + if strings.Contains(redacted, ":") { + if strings.Contains(strings.ToLower(redacted), "authorization") { + redacted = headerPattern.ReplaceAllStringFunc(redacted, func(match string) string { + groups := headerPattern.FindStringSubmatch(match) + if groups[2] != "" { + return groups[1] + ": " + groups[2] + " " + replacement + } + return groups[1] + ": " + replacement + }) } - return parts[1] + parts[2] + "=" + replacement - }) + redacted = secretHeader.ReplaceAllString(redacted, "$1: "+replacement) + } + if strings.Contains(redacted, "://") && strings.Contains(redacted, "@") { + redacted = redactURLPasswords(redacted, replacement) + } + if strings.Contains(redacted, "?") || strings.Contains(redacted, "&") { + redacted = queryPattern.ReplaceAllStringFunc(redacted, func(match string) string { + parts := queryPattern.FindStringSubmatch(match) + if len(parts) < 4 || !IsSensitiveKey(parts[2], options) { + return match + } + return parts[1] + parts[2] + "=" + replacement + }) + } // openai keys first so the filter can drop kebab-case false positives // before any other pattern rewrites nearby text. - redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string { - if !knownOpenAIKeyPrefix(match) && !secretMatchHasDigit(match) && - strings.Contains(strings.TrimPrefix(match, "sk-"), "-") { - return match - } - return replacement - }) - for _, pattern := range textSecretPatterns { - redacted = pattern.ReplaceAllString(redacted, replacement) + if strings.Contains(redacted, "sk-") { + redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string { + if !knownOpenAIKeyPrefix(match) && !secretMatchHasDigit(match) && + strings.Contains(strings.TrimPrefix(match, "sk-"), "-") { + return match + } + return replacement + }) + } + if strings.Contains(redacted, "sk-ant-") { + redacted = textSecretPatterns[0].ReplaceAllString(redacted, replacement) + } + if strings.Contains(redacted, "github_pat_") { + redacted = textSecretPatterns[1].ReplaceAllString(redacted, replacement) + } + if strings.Contains(redacted, "ghp_") || strings.Contains(redacted, "gho_") || strings.Contains(redacted, "ghu_") || strings.Contains(redacted, "ghs_") || strings.Contains(redacted, "ghr_") { + redacted = textSecretPatterns[2].ReplaceAllString(redacted, replacement) + } + if strings.Contains(redacted, "glpat-") { + redacted = textSecretPatterns[3].ReplaceAllString(redacted, replacement) + } + if strings.Contains(redacted, "AIza") { + redacted = textSecretPatterns[4].ReplaceAllString(redacted, replacement) + } + if strings.Contains(redacted, "xox") { + redacted = textSecretPatterns[5].ReplaceAllString(redacted, replacement) + } + if strings.Contains(redacted, "AKIA") || strings.Contains(redacted, "ASIA") { + redacted = textSecretPatterns[6].ReplaceAllString(redacted, replacement) + } + if strings.Contains(redacted, "eyJ") { + redacted = textSecretPatterns[7].ReplaceAllString(redacted, replacement) + redacted = textSecretPatterns[8].ReplaceAllString(redacted, replacement) } return redacted } diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 3ae228c8d..05e345e17 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -141,3 +141,33 @@ func containsCircular(v any) bool { } return false } + +func BenchmarkRedactString(b *testing.B) { + sample := strings.Join([]string{ + `{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"}`, + "authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz123456", + "https://zero:super-secret@example.test/path?token=glpat-abcdefghijklmnopqrstuvwxyz", + "export AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE", + "normal line with no secrets just log messages and numbers 123456789", + "another normal line about git commit status and file diff output", + }, "\n") + opts := Options{ExtraSecretValues: []string{"super-secret"}} + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = RedactString(sample, opts) + } +} + +func BenchmarkRedactStringClean(b *testing.B) { + cleanText := "normal line with no secrets just log messages and numbers 123456789\nanother line without secrets" + opts := Options{} + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = RedactString(cleanText, opts) + } +} + From d4813358a5593e6774f314e4f0930a17772c0b75 Mon Sep 17 00:00:00 2001 From: cl-ment Date: Sun, 23 Aug 2026 14:12:40 +0200 Subject: [PATCH 2/8] test(redaction): lengthen ghp_ fixtures and cover conditional gates GitHub classic tokens need 36 characters after ghp_. Benchmarks and shape tests now use a matching fixture. A table-driven test checks each fast-path gate, including near-misses and case-insensitive Authorization. --- internal/redaction/redaction_test.go | 182 ++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 4 deletions(-) diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 05e345e17..b00517847 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -9,7 +9,7 @@ import ( func TestRedactStringCoversCommonSecretShapes(t *testing.T) { input := strings.Join([]string{ `{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz"}`, - "authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz123456", + "authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz1234567890", "https://zero:super-secret@example.test/path?token=glpat-abcdefghijklmnopqrstuvwxyz", "-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----", }, "\n") @@ -18,7 +18,7 @@ func TestRedactStringCoversCommonSecretShapes(t *testing.T) { for _, leaked := range []string{ "sk-proj-abcdefghijklmnopqrstuvwxyz", - "ghp_abcdefghijklmnopqrstuvwxyz123456", + "ghp_abcdefghijklmnopqrstuvwxyz1234567890", "super-secret", "glpat-abcdefghijklmnopqrstuvwxyz", "abc123", @@ -57,7 +57,7 @@ func TestRedactValueHandlesSensitiveKeysAndCycles(t *testing.T) { func TestRedactErrorRedactsMessageStackAndFields(t *testing.T) { err := withFieldsError{ err: errors.New("request failed with api_key=sk-test-secret1234567890"), - Token: "ghp_abcdefghijklmnopqrstuvwxyz123456", + Token: "ghp_abcdefghijklmnopqrstuvwxyz1234567890", Detail: "safe", } @@ -145,7 +145,7 @@ func containsCircular(v any) bool { func BenchmarkRedactString(b *testing.B) { sample := strings.Join([]string{ `{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"}`, - "authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz123456", + "authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz1234567890", "https://zero:super-secret@example.test/path?token=glpat-abcdefghijklmnopqrstuvwxyz", "export AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE", "normal line with no secrets just log messages and numbers 123456789", @@ -171,3 +171,177 @@ func BenchmarkRedactStringClean(b *testing.B) { } } +func TestRedactStringConditionalGates(t *testing.T) { + const ghp = "ghp_abcdefghijklmnopqrstuvwxyz1234567890" + const gho = "gho_abcdefghijklmnopqrstuvwxyz1234567890" + const pat = "github_pat_abcdefghijklmnopqrstuv" + const ant = "sk-ant-api03-abcdefghijklmnopqrst" + const glpat = "glpat-abcdefghijkl" + const aiza = "AIza" + "01234567890123456789012345678901234" + const slack = "xoxb-1234567890-abcdefghij" + const akia = "AKIAIOSFODNN7EXAMPLE" + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signaturexx" + + tests := []struct { + name string + in string + leaked []string + keep []string + wantHit bool + }{ + { + name: "private key match", + in: "-----BEGIN RSA PRIVATE KEY-----\nabc123\n-----END RSA PRIVATE KEY-----", + leaked: []string{"abc123"}, + wantHit: true, + }, + { + name: "private key near-miss", + in: "-----BEGIN PUBLIC KEY-----\nabc123\n-----END PUBLIC KEY-----", + keep: []string{"abc123"}, + wantHit: false, + }, + { + name: "json sensitive key", + in: `{"apiKey":"hunter2secret"}`, + leaked: []string{"hunter2secret"}, + wantHit: true, + }, + { + name: "json non-sensitive key", + in: `{"name":"hunter2secret"}`, + keep: []string{"hunter2secret"}, + wantHit: false, + }, + { + name: "assign sensitive", + in: "AWS_SECRET_ACCESS_KEY=supersecretvalue", + leaked: []string{"supersecretvalue"}, + wantHit: true, + }, + { + name: "assign non-sensitive", + in: "PATH=/usr/bin", + keep: []string{"/usr/bin"}, + wantHit: false, + }, + { + name: "authorization header case-insensitive", + in: "AUTHORIZATION: Bearer " + ghp, + leaked: []string{ghp}, + wantHit: true, + }, + { + name: "authorization near-miss not a header name", + in: "X-Request-Id: Bearer not-a-token-value", + keep: []string{"not-a-token-value"}, + wantHit: false, + }, + { + name: "query token", + in: "https://example.test/x?token=" + glpat, + leaked: []string{glpat}, + wantHit: true, + }, + { + name: "query near-miss", + in: "https://example.test/x?page=42", + keep: []string{"page=42"}, + wantHit: false, + }, + { + name: "openai sk-proj", + in: "key=" + "sk-proj-abcdefghijklmnopqrstuvwxyz1234", + leaked: []string{"sk-proj-abcdefghijklmnopqrstuvwxyz1234"}, + wantHit: true, + }, + { + name: "sk-ant", + in: ant, + leaked: []string{ant}, + wantHit: true, + }, + { + name: "github_pat", + in: pat, + leaked: []string{pat}, + wantHit: true, + }, + { + name: "ghp classic", + in: ghp, + leaked: []string{ghp}, + wantHit: true, + }, + { + name: "gho oauth", + in: gho, + leaked: []string{gho}, + wantHit: true, + }, + { + name: "ghp too short unchanged", + in: "ghp_shorttoken", + keep: []string{"ghp_shorttoken"}, + wantHit: false, + }, + { + name: "unsupported prefix ghx", + in: "ghx_abcdefghijklmnopqrstuvwxyz1234567890", + keep: []string{"ghx_abcdefghijklmnopqrstuvwxyz1234567890"}, + wantHit: false, + }, + { + name: "glpat", + in: glpat, + leaked: []string{glpat}, + wantHit: true, + }, + { + name: "google aiza", + in: aiza, + leaked: []string{aiza}, + wantHit: true, + }, + { + name: "slack xoxb", + in: slack, + leaked: []string{slack}, + wantHit: true, + }, + { + name: "aws akia", + in: akia, + leaked: []string{akia}, + wantHit: true, + }, + { + name: "jwt", + in: jwt, + leaked: []string{jwt}, + wantHit: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := RedactString(tc.in, Options{}) + for _, leak := range tc.leaked { + if strings.Contains(got, leak) { + t.Fatalf("leaked %q in %q", leak, got) + } + } + for _, keep := range tc.keep { + if !strings.Contains(got, keep) { + t.Fatalf("near-miss changed: want %q still in %q", keep, got) + } + } + if tc.wantHit && !strings.Contains(got, RedactedSecret) { + t.Fatalf("expected %q, got %q", RedactedSecret, got) + } + if !tc.wantHit && strings.Contains(got, RedactedSecret) { + t.Fatalf("unexpected redaction: %q", got) + } + }) + } +} From a2a03320ceb5f54bdbcbb2acc8d84802b45fd60c Mon Sep 17 00:00:00 2001 From: cl-ment Date: Mon, 24 Aug 2026 18:30:01 +0200 Subject: [PATCH 3/8] perf(redaction): structure triggered patterns and avoid allocation in header case checks --- internal/redaction/redaction.go | 93 +++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 33 deletions(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 9777e1661..2603de8c9 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -75,7 +75,12 @@ var sensitiveKeys = map[string]struct{}{ // Applied via ReplaceAllStringFunc rather than the plain list below. var openaiKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`) -// textSecretPatterns mirror secrets.Scan for end-boundary behavior and the +type secretPatternTrigger struct { + prefixes []string + patterns []*regexp.Regexp +} + +// triggeredSecretPatterns mirror secrets.Scan for end-boundary behavior and the // shared high-confidence shapes. A leading \b keeps each pattern from firing // mid-word; a trailing \b is omitted so a secret followed by more word // characters outside its body class (e.g. AKIA…EXAMPLEEXTRA) still matches, @@ -84,16 +89,18 @@ var openaiKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`) // (not in secrets.Scan); ASIA temporary access keys are kept alongside AKIA. // openai keys are handled separately (digit filter). JWT has a strict form // (both segments start with eyJ) and a looser three-segment form. -var textSecretPatterns = []*regexp.Regexp{ - regexp.MustCompile(`\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}`), - regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`), - regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36,}`), - regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{12,}`), - regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`), - regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`), - regexp.MustCompile(`\b(?:AKIA|ASIA)[A-Z0-9]{16}`), - regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), - regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), +var triggeredSecretPatterns = []secretPatternTrigger{ + {prefixes: []string{"sk-ant-"}, patterns: []*regexp.Regexp{regexp.MustCompile(`\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}`)}}, + {prefixes: []string{"github_pat_"}, patterns: []*regexp.Regexp{regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`)}}, + {prefixes: []string{"ghp_", "gho_", "ghu_", "ghs_", "ghr_"}, patterns: []*regexp.Regexp{regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36,}`)}}, + {prefixes: []string{"glpat-"}, patterns: []*regexp.Regexp{regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{12,}`)}}, + {prefixes: []string{"AIza"}, patterns: []*regexp.Regexp{regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`)}}, + {prefixes: []string{"xox"}, patterns: []*regexp.Regexp{regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`)}}, + {prefixes: []string{"AKIA", "ASIA"}, patterns: []*regexp.Regexp{regexp.MustCompile(`\b(?:AKIA|ASIA)[A-Z0-9]{16}`)}}, + {prefixes: []string{"eyJ"}, patterns: []*regexp.Regexp{ + regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), + regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), + }}, } var ( @@ -213,7 +220,7 @@ func RedactString(value string, options Options) string { }) } if strings.Contains(redacted, ":") { - if strings.Contains(strings.ToLower(redacted), "authorization") { + if containsCaseInsensitive(redacted, "authorization") { redacted = headerPattern.ReplaceAllStringFunc(redacted, func(match string) string { groups := headerPattern.FindStringSubmatch(match) if groups[2] != "" { @@ -247,32 +254,52 @@ func RedactString(value string, options Options) string { return replacement }) } - if strings.Contains(redacted, "sk-ant-") { - redacted = textSecretPatterns[0].ReplaceAllString(redacted, replacement) - } - if strings.Contains(redacted, "github_pat_") { - redacted = textSecretPatterns[1].ReplaceAllString(redacted, replacement) - } - if strings.Contains(redacted, "ghp_") || strings.Contains(redacted, "gho_") || strings.Contains(redacted, "ghu_") || strings.Contains(redacted, "ghs_") || strings.Contains(redacted, "ghr_") { - redacted = textSecretPatterns[2].ReplaceAllString(redacted, replacement) - } - if strings.Contains(redacted, "glpat-") { - redacted = textSecretPatterns[3].ReplaceAllString(redacted, replacement) + for _, entry := range triggeredSecretPatterns { + hasPrefix := false + for _, p := range entry.prefixes { + if strings.Contains(redacted, p) { + hasPrefix = true + break + } + } + if hasPrefix { + for _, pat := range entry.patterns { + redacted = pat.ReplaceAllString(redacted, replacement) + } + } } - if strings.Contains(redacted, "AIza") { - redacted = textSecretPatterns[4].ReplaceAllString(redacted, replacement) + return redacted +} + +func containsCaseInsensitive(s, substr string) bool { + if len(substr) == 0 { + return true } - if strings.Contains(redacted, "xox") { - redacted = textSecretPatterns[5].ReplaceAllString(redacted, replacement) + if len(s) < len(substr) { + return false } - if strings.Contains(redacted, "AKIA") || strings.Contains(redacted, "ASIA") { - redacted = textSecretPatterns[6].ReplaceAllString(redacted, replacement) + for i := 0; i <= len(s)-len(substr); i++ { + match := true + for j := 0; j < len(substr); j++ { + c1 := s[i+j] + c2 := substr[j] + if c1 != c2 && toLowerASCII(c1) != toLowerASCII(c2) { + match = false + break + } + } + if match { + return true + } } - if strings.Contains(redacted, "eyJ") { - redacted = textSecretPatterns[7].ReplaceAllString(redacted, replacement) - redacted = textSecretPatterns[8].ReplaceAllString(redacted, replacement) + return false +} + +func toLowerASCII(c byte) byte { + if c >= 'A' && c <= 'Z' { + return c + ('a' - 'A') } - return redacted + return c } // knownOpenAIKeyPrefix is the redaction-side twin of secrets.knownOpenAIKeyPrefix: From 9463439dedcfb340272c53157ca03f2104ce034a Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sat, 29 Aug 2026 01:05:34 +0200 Subject: [PATCH 4/8] test(redaction): cover secret-header and URL-password gates Keep the redaction marker literal after url.URL rewrites userinfo, so callers still see [REDACTED] instead of a percent-encoded form. --- internal/redaction/redaction.go | 3 ++- internal/redaction/redaction_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 2603de8c9..19f02dec5 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -546,6 +546,7 @@ func redactURLPasswords(value string, replacement string) string { return candidate } parsed.User = url.UserPassword(parsed.User.Username(), replacement) - return parsed.String() + encoded := parsed.String() + return strings.ReplaceAll(encoded, url.PathEscape(replacement), replacement) }) } diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index b00517847..589ac6621 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -321,6 +321,30 @@ func TestRedactStringConditionalGates(t *testing.T) { leaked: []string{jwt}, wantHit: true, }, + { + name: "secret header x-api-key", + in: "X-API-Key: hunter2secret", + leaked: []string{"hunter2secret"}, + wantHit: true, + }, + { + name: "non-secret header unchanged", + in: "X-Request-Id: hunter2secret", + keep: []string{"hunter2secret"}, + wantHit: false, + }, + { + name: "url password", + in: "https://user:hunter2secret@example.test/x", + leaked: []string{"hunter2secret"}, + wantHit: true, + }, + { + name: "url user without password unchanged", + in: "https://user@example.test/x", + keep: []string{"https://user@example.test/x"}, + wantHit: false, + }, } for _, tc := range tests { From ceaf8ab60634a19a49fd80243a8b15d2da8d846c Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sat, 29 Aug 2026 01:40:15 +0200 Subject: [PATCH 5/8] fix(redaction): unescape only the generated URL password A path or query that already contains the encoded redaction marker must stay encoded. Only the userinfo password is rewritten in the clear [REDACTED] form. --- internal/redaction/redaction.go | 11 ++++++++--- internal/redaction/redaction_test.go | 7 +++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 19f02dec5..5de87a003 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -545,8 +545,13 @@ func redactURLPasswords(value string, replacement string) string { if _, hasPassword := parsed.User.Password(); !hasPassword { return candidate } - parsed.User = url.UserPassword(parsed.User.Username(), replacement) - encoded := parsed.String() - return strings.ReplaceAll(encoded, url.PathEscape(replacement), replacement) + username := parsed.User.Username() + parsed.User = nil + rest := parsed.String() + prefix := parsed.Scheme + "://" + if parsed.Scheme == "" || !strings.HasPrefix(rest, prefix) { + return candidate + } + return prefix + username + ":" + replacement + "@" + strings.TrimPrefix(rest, prefix) }) } diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 589ac6621..14da2148d 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -345,6 +345,13 @@ func TestRedactStringConditionalGates(t *testing.T) { keep: []string{"https://user@example.test/x"}, wantHit: false, }, + { + name: "url password does not unescape path marker", + in: "https://user:hunter2secret@example.test/%5BREDACTED%5D", + leaked: []string{"hunter2secret"}, + keep: []string{"/%5BREDACTED%5D"}, + wantHit: true, + }, } for _, tc := range tests { From 06265995e14008d8da79e99246448c5aa7d77214 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sat, 29 Aug 2026 10:14:26 +0200 Subject: [PATCH 6/8] fix(redaction): re-escape URL username after parse Username() is decoded. Re-encoding with url.User keeps control bytes out of the redacted URL so a %0AAuthorization userinfo cannot inject a header after the header pass. --- internal/redaction/redaction.go | 2 +- internal/redaction/redaction_test.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 5de87a003..2f4f85b40 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -545,7 +545,7 @@ func redactURLPasswords(value string, replacement string) string { if _, hasPassword := parsed.User.Password(); !hasPassword { return candidate } - username := parsed.User.Username() + username := url.User(parsed.User.Username()).String() parsed.User = nil rest := parsed.String() prefix := parsed.Scheme + "://" diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 14da2148d..0bae5dcb5 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -352,6 +352,17 @@ func TestRedactStringConditionalGates(t *testing.T) { keep: []string{"/%5BREDACTED%5D"}, wantHit: true, }, + { + name: "url encoded username does not inject header", + in: "https://%0AAuthorization%3A%20Bearer%20opaque-token:hunter2secret@example.test/x", + leaked: []string{ + "hunter2secret", + "Authorization: Bearer", + "\nAuthorization", + }, + keep: []string{"Authorization"}, + wantHit: true, + }, } for _, tc := range tests { From 7d66bcfecac2ffdb6d2f615ea80cd9b45e173de5 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sun, 30 Aug 2026 17:01:52 +0200 Subject: [PATCH 7/8] fix(redaction): fail closed on password URLs without a host --- internal/redaction/redaction.go | 16 +++-- internal/redaction/redaction_test.go | 95 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 2f4f85b40..278c0d56d 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -545,13 +545,15 @@ func redactURLPasswords(value string, replacement string) string { if _, hasPassword := parsed.User.Password(); !hasPassword { return candidate } - username := url.User(parsed.User.Username()).String() - parsed.User = nil - rest := parsed.String() - prefix := parsed.Scheme + "://" - if parsed.Scheme == "" || !strings.HasPrefix(rest, prefix) { - return candidate + username := parsed.User.Username() + userinfo := url.UserPassword(username, replacement) + parsed.User = userinfo + out := parsed.String() + encodedUserinfo := userinfo.String() + literalUserinfo := url.User(username).String() + ":" + replacement + if encodedUserinfo != literalUserinfo { + out = strings.Replace(out, encodedUserinfo, literalUserinfo, 1) } - return prefix + username + ":" + replacement + "@" + strings.TrimPrefix(rest, prefix) + return out }) } diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 0bae5dcb5..e9fbc6b28 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -387,3 +387,98 @@ func TestRedactStringConditionalGates(t *testing.T) { }) } } + +func TestRedactURLPasswords_HostlessFailClosed(t *testing.T) { + cases := []struct { + in string + leaked string + keep []string + }{ + {in: "http://admin:secret@", leaked: "secret", keep: []string{"admin"}}, + {in: "http://admin:secret@?q=1", leaked: "secret", keep: []string{"?q=1"}}, + {in: "http://admin:secret@#fragment", leaked: "secret", keep: []string{"#fragment"}}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got := redactURLPasswords(tc.in, RedactedSecret) + if strings.Contains(got, tc.leaked) { + t.Fatalf("leaked %q in %q", tc.leaked, got) + } + if !strings.Contains(got, RedactedSecret) { + t.Fatalf("expected literal %q in %q", RedactedSecret, got) + } + if strings.Contains(got, "%5BREDACTED%5D") { + t.Fatalf("marker was percent-encoded in %q", got) + } + for _, keep := range tc.keep { + if !strings.Contains(got, keep) { + t.Fatalf("want %q still in %q", keep, got) + } + } + }) + } +} + +func TestRedactString_URLPasswordHostlessFailClosed(t *testing.T) { + tests := []struct { + name string + in string + leaked []string + keep []string + }{ + { + name: "hostless trailing at", + in: "http://admin:secret@", + leaked: []string{"secret"}, + keep: []string{RedactedSecret, "admin"}, + }, + { + name: "hostless query", + in: "http://admin:secret@?q=1", + leaked: []string{"secret"}, + keep: []string{RedactedSecret, "?q=1"}, + }, + { + name: "hostless fragment", + in: "http://admin:secret@#fragment", + leaked: []string{"secret"}, + keep: []string{RedactedSecret, "#fragment"}, + }, + { + name: "embedded hostless", + in: "connecting to http://admin:hunter2@ now", + leaked: []string{"hunter2"}, + keep: []string{RedactedSecret, "connecting to ", " now"}, + }, + { + name: "https proxy env hostless", + in: "HTTPS_PROXY=http://proxyuser:pr0xyp4ss@", + leaked: []string{"pr0xyp4ss"}, + keep: []string{RedactedSecret, "HTTPS_PROXY=", "proxyuser"}, + }, + { + name: "host present keeps encoded path and query", + in: "https://user:secret@example.test/%5BREDACTED%5D?q=a%20b", + leaked: []string{"secret"}, + keep: []string{RedactedSecret, "/%5BREDACTED%5D", "q=a%20b"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := RedactString(tc.in, Options{}) + if strings.Contains(got, "%5BREDACTED%5D") && !strings.Contains(tc.in, "%5BREDACTED%5D") { + t.Fatalf("marker was percent-encoded: %q", got) + } + for _, leak := range tc.leaked { + if strings.Contains(got, leak) { + t.Fatalf("leaked %q in %q", leak, got) + } + } + for _, keep := range tc.keep { + if !strings.Contains(got, keep) { + t.Fatalf("want %q still in %q", keep, got) + } + } + }) + } +} From 3330effe8d4293c25c1f81a1eb24207aab84072a Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sun, 30 Aug 2026 18:21:24 +0200 Subject: [PATCH 8/8] test(redaction): isolate query-gate coverage from assign and text secrets --- internal/redaction/redaction_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index e9fbc6b28..240b4d51c 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -482,3 +482,26 @@ func TestRedactString_URLPasswordHostlessFailClosed(t *testing.T) { }) } } + +func TestRedactString_QueryGateIsolatedFromAssignAndTextSecrets(t *testing.T) { + const opaque = "opaque-query-fixture-value" + in := "https://example.test/x?[password]=" + opaque + + if !IsSensitiveKey("[password]", Options{}) { + t.Fatal("fixture key [password] must normalize to a sensitive key") + } + if assignPattern.MatchString(in) { + t.Fatal("fixture must not satisfy assignPattern; otherwise the query gate is not isolated") + } + if kept := RedactString(opaque, Options{}); kept != opaque { + t.Fatalf("opaque value must not match text-secret patterns, got %q", kept) + } + + got := RedactString(in, Options{}) + if strings.Contains(got, opaque) { + t.Fatalf("query gate missed the value: %q", got) + } + if !strings.Contains(got, RedactedSecret) { + t.Fatalf("query gate did not insert marker: %q", got) + } +}