From 289fb01dc3cbd1449d60460d529accf981859417 Mon Sep 17 00:00:00 2001 From: cl-ment Date: Sun, 23 Aug 2026 09:26:08 +0200 Subject: [PATCH 1/9] 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 | 128 +++++++++++++++++---------- internal/redaction/redaction_test.go | 29 ++++++ 2 files changed, 111 insertions(+), 46 deletions(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 1ff8da37f..55baf4719 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -248,55 +248,91 @@ 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 !openAIShapeIsSecret(match) { - return match - } - return replacement - }) - for i, pattern := range textSecretPatterns { - redacted = redactAdjacent(redacted, pattern, anchoredSecretPatterns[i], replacement) + if strings.Contains(redacted, "sk-") { + redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string { + if !openAIShapeIsSecret(match) { + return match + } + return replacement + }) + } + if strings.Contains(redacted, "sk-ant-") { + redacted = redactAdjacent(redacted, textSecretPatterns[0], anchoredSecretPatterns[0], replacement) + } + if strings.Contains(redacted, "github_pat_") { + redacted = redactAdjacent(redacted, textSecretPatterns[1], anchoredSecretPatterns[1], replacement) + } + if strings.Contains(redacted, "ghp_") || strings.Contains(redacted, "gho_") || strings.Contains(redacted, "ghu_") || strings.Contains(redacted, "ghs_") || strings.Contains(redacted, "ghr_") { + redacted = redactAdjacent(redacted, textSecretPatterns[2], anchoredSecretPatterns[2], replacement) + } + if strings.Contains(redacted, "glpat-") { + redacted = redactAdjacent(redacted, textSecretPatterns[3], anchoredSecretPatterns[3], replacement) + } + if strings.Contains(redacted, "AIza") { + redacted = redactAdjacent(redacted, textSecretPatterns[4], anchoredSecretPatterns[4], replacement) + } + if strings.Contains(redacted, "xox") { + redacted = redactAdjacent(redacted, textSecretPatterns[5], anchoredSecretPatterns[5], replacement) + } + if strings.Contains(redacted, "AKIA") || strings.Contains(redacted, "ASIA") { + redacted = redactAdjacent(redacted, textSecretPatterns[6], anchoredSecretPatterns[6], replacement) + } + if strings.Contains(redacted, "eyJ") { + redacted = redactAdjacent(redacted, textSecretPatterns[7], anchoredSecretPatterns[7], replacement) + redacted = redactAdjacent(redacted, textSecretPatterns[8], anchoredSecretPatterns[8], replacement) } // Credentials broken up by invisible characters last, over what the // contiguous matchers left: a whole credential is already claimed by its own diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index b2bb6238c..e58b5fd1e 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -185,3 +185,32 @@ func TestRedactStringLongAdjacentKeyRunCompletesPromptly(t *testing.T) { t.Fatalf("expected %d redaction markers, got %d", count, strings.Count(got, RedactedSecret)) } } + +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 1d9fa9f697019756bb825a6d15c71c614aa91762 Mon Sep 17 00:00:00 2001 From: cl-ment Date: Sun, 23 Aug 2026 14:12:40 +0200 Subject: [PATCH 2/9] 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 | 183 ++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 4 deletions(-) diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index e58b5fd1e..24e151850 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -33,7 +33,7 @@ func TestRedactStringRedactsAdjacentAWSKeys(t *testing.T) { 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") @@ -42,7 +42,7 @@ func TestRedactStringCoversCommonSecretShapes(t *testing.T) { for _, leaked := range []string{ "sk-proj-abcdefghijklmnopqrstuvwxyz", - "ghp_abcdefghijklmnopqrstuvwxyz123456", + "ghp_abcdefghijklmnopqrstuvwxyz1234567890", "super-secret", "glpat-abcdefghijklmnopqrstuvwxyz", "abc123", @@ -81,7 +81,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", } @@ -189,7 +189,7 @@ func TestRedactStringLongAdjacentKeyRunCompletesPromptly(t *testing.T) { 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", @@ -214,3 +214,178 @@ func BenchmarkRedactStringClean(b *testing.B) { _ = RedactString(cleanText, opts) } } + +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 36ac08ca4d73f616f940cd452a5b2ac40f356f2d Mon Sep 17 00:00:00 2001 From: cl-ment Date: Mon, 24 Aug 2026 18:30:01 +0200 Subject: [PATCH 3/9] perf(redaction): structure triggered patterns and avoid allocation in header case checks --- internal/redaction/redaction.go | 85 +++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 55baf4719..3ff612079 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -96,6 +96,21 @@ var textSecretPatterns = []*regexp.Regexp{ regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), } +// textSecretPrefixes lists the mandatory literal prefixes for each entry in +// textSecretPatterns so RedactString can skip the regex scan when none of the +// trigger prefixes appear in the input. +var textSecretPrefixes = [][]string{ + {"sk-ant-"}, + {"github_pat_"}, + {"ghp_", "gho_", "ghu_", "ghs_", "ghr_"}, + {"glpat-"}, + {"AIza"}, + {"xox"}, + {"AKIA", "ASIA"}, + {"eyJ"}, + {"eyJ"}, +} + // 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 @@ -276,7 +291,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] != "" { @@ -309,30 +324,10 @@ func RedactString(value string, options Options) string { return replacement }) } - if strings.Contains(redacted, "sk-ant-") { - redacted = redactAdjacent(redacted, textSecretPatterns[0], anchoredSecretPatterns[0], replacement) - } - if strings.Contains(redacted, "github_pat_") { - redacted = redactAdjacent(redacted, textSecretPatterns[1], anchoredSecretPatterns[1], replacement) - } - if strings.Contains(redacted, "ghp_") || strings.Contains(redacted, "gho_") || strings.Contains(redacted, "ghu_") || strings.Contains(redacted, "ghs_") || strings.Contains(redacted, "ghr_") { - redacted = redactAdjacent(redacted, textSecretPatterns[2], anchoredSecretPatterns[2], replacement) - } - if strings.Contains(redacted, "glpat-") { - redacted = redactAdjacent(redacted, textSecretPatterns[3], anchoredSecretPatterns[3], replacement) - } - if strings.Contains(redacted, "AIza") { - redacted = redactAdjacent(redacted, textSecretPatterns[4], anchoredSecretPatterns[4], replacement) - } - if strings.Contains(redacted, "xox") { - redacted = redactAdjacent(redacted, textSecretPatterns[5], anchoredSecretPatterns[5], replacement) - } - if strings.Contains(redacted, "AKIA") || strings.Contains(redacted, "ASIA") { - redacted = redactAdjacent(redacted, textSecretPatterns[6], anchoredSecretPatterns[6], replacement) - } - if strings.Contains(redacted, "eyJ") { - redacted = redactAdjacent(redacted, textSecretPatterns[7], anchoredSecretPatterns[7], replacement) - redacted = redactAdjacent(redacted, textSecretPatterns[8], anchoredSecretPatterns[8], replacement) + for i, pattern := range textSecretPatterns { + if hasAnyPrefix(redacted, textSecretPrefixes[i]) { + redacted = redactAdjacent(redacted, pattern, anchoredSecretPatterns[i], replacement) + } } // Credentials broken up by invisible characters last, over what the // contiguous matchers left: a whole credential is already claimed by its own @@ -342,6 +337,46 @@ func RedactString(value string, options Options) string { return redacted } +func hasAnyPrefix(s string, prefixes []string) bool { + for _, p := range prefixes { + if strings.Contains(s, p) { + return true + } + } + return false +} + +func containsCaseInsensitive(s, substr string) bool { + if len(substr) == 0 { + return true + } + if len(s) < len(substr) { + return false + } + 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 + } + } + return false +} + +func toLowerASCII(c byte) byte { + if c >= 'A' && c <= 'Z' { + return c + ('a' - 'A') + } + return c +} + // knownOpenAIKeyPrefix is the redaction-side twin of secrets.knownOpenAIKeyPrefix: // known OpenAI-issued forms redact even with an alphabet-only body. func knownOpenAIKeyPrefix(match string) bool { From f58ce60af20063f392d45a7646c1aca36b9299dd Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sat, 29 Aug 2026 01:05:34 +0200 Subject: [PATCH 4/9] 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 3ff612079..d49c823ff 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -629,6 +629,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 24e151850..4870c3a66 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -365,6 +365,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 fe5cc07249652336be71630894ce60dfc0f69046 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sat, 29 Aug 2026 01:40:15 +0200 Subject: [PATCH 5/9] 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 d49c823ff..e72992115 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -628,8 +628,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 4870c3a66..50762dff1 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -389,6 +389,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 c6982a7d6140be03e283f039675acb260f33e0b3 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sat, 29 Aug 2026 10:14:26 +0200 Subject: [PATCH 6/9] 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 e72992115..6e8c312b5 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -628,7 +628,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 50762dff1..db1718601 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -396,6 +396,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 2546fdef77a2755ef042c1e6bbd0a4ab780f03fc Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sun, 30 Aug 2026 17:01:52 +0200 Subject: [PATCH 7/9] 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 6e8c312b5..85d0f7322 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -628,13 +628,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 db1718601..c2a773548 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -431,3 +431,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 46332e44ec3f7d6d144496c1dae6535dab7d345e Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sun, 30 Aug 2026 18:21:24 +0200 Subject: [PATCH 8/9] 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 c2a773548..cd991505a 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -526,3 +526,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) + } +} From 5d8990267a137f3fe1e21179704727c8bbedb023 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Thu, 15 Oct 2026 11:16:32 +0200 Subject: [PATCH 9/9] fix(redaction): fold Unicode U+017F and U+212A in containsCaseInsensitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go (?i) regular expressions apply Unicode simple case folding, where U+017F (ſ) folds to 's' and U+212A (K) folds to 'k'. Teach containsCaseInsensitive to fold these runes to ASCII lowercase with zero allocations so case-insensitive header gates cannot be bypassed by Unicode-folded equivalents. --- internal/redaction/redaction.go | 51 ++++++++++++++++++++++------ internal/redaction/redaction_test.go | 51 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 85d0f7322..9c4b2d36d 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -9,6 +9,7 @@ import ( "sort" "strings" "unicode" + "unicode/utf8" ) const ( @@ -354,22 +355,52 @@ func containsCaseInsensitive(s, substr string) bool { return false } 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 { + if hasFoldedASCIIPrefix(s[i:], substr) { return true } } return false } +// hasFoldedASCIIPrefix reports whether s starts with substr under Unicode +// simple case folding (assuming substr is lowercase ASCII). This ensures that +// non-ASCII runes that fold to ASCII in (?i) regular expressions (U+017F 'ſ' -> +// 's' and U+212A 'K' -> 'k') pass the fast-path gate with zero allocations. +func hasFoldedASCIIPrefix(s, substr string) bool { + si := 0 + for j := 0; j < len(substr); j++ { + if si >= len(s) { + return false + } + c2 := toLowerASCII(substr[j]) + c1 := s[si] + if c1 < utf8.RuneSelf { + if c1 != c2 && toLowerASCII(c1) != c2 { + return false + } + si++ + continue + } + r, width := utf8.DecodeRuneInString(s[si:]) + if foldRuneToLowerASCII(r) != c2 { + return false + } + si += width + } + return true +} + +func foldRuneToLowerASCII(r rune) byte { + switch r { + case '\u017f': // LATIN SMALL LETTER LONG S + return 's' + case '\u212a': // KELVIN SIGN + return 'k' + default: + return 0 + } +} + func toLowerASCII(c byte) byte { if c >= 'A' && c <= 'Z' { return c + ('a' - 'A') diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index cd991505a..84a9388a0 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -549,3 +549,54 @@ func TestRedactString_QueryGateIsolatedFromAssignAndTextSecrets(t *testing.T) { t.Fatalf("query gate did not insert marker: %q", got) } } + +func TestRedactString_UnicodeFoldedHeaderNames(t *testing.T) { + tests := []struct { + name string + in string + leaked string + }{ + { + name: "x-api-key with kelvin sign U+212A", + in: "X-API-\u212aey: opaque-header-secret-1", + leaked: "opaque-header-secret-1", + }, + { + name: "set-cookie with long s U+017F", + in: "\u017fet-cookie: session=opaque-cookie-secret-2", + leaked: "opaque-cookie-secret-2", + }, + { + name: "containsCaseInsensitive kelvin sign U+212A", + in: "Authori\u212aation", + leaked: "", + }, + { + name: "containsCaseInsensitive long s U+017F", + in: "\u017fet-cookie", + leaked: "", + }, + } + + if !containsCaseInsensitive("Authori\u212aation", "authorikation") { + t.Fatal("containsCaseInsensitive failed to fold U+212A KELVIN SIGN to 'k'") + } + if !containsCaseInsensitive("\u017fet-cookie", "set-cookie") { + t.Fatal("containsCaseInsensitive failed to fold U+017F LONG S to 's'") + } + + for _, tc := range tests { + if tc.leaked == "" { + continue + } + t.Run(tc.name, func(t *testing.T) { + got := RedactString(tc.in, Options{}) + if strings.Contains(got, tc.leaked) { + t.Fatalf("leaked %q in %q", tc.leaked, got) + } + if !strings.Contains(got, RedactedSecret) { + t.Fatalf("expected %q in %q", RedactedSecret, got) + } + }) + } +}