From a35b553ddb0a48af7d9882e5aaa1fbb3d5294d45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:35:30 +0000 Subject: [PATCH 1/9] Initial plan From 32e7d57a4cf9a671c8b122d1db03f30744510959 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:38:45 +0000 Subject: [PATCH 2/9] Add comprehensive test coverage for secrets package Co-authored-by: hyp3rd <62474964+hyp3rd@users.noreply.github.com> --- pkg/secrets/secrets_test.go | 511 +++++++++++++++++++++++++++++++++++- 1 file changed, 510 insertions(+), 1 deletion(-) diff --git a/pkg/secrets/secrets_test.go b/pkg/secrets/secrets_test.go index 6c77a06..da1d3b2 100644 --- a/pkg/secrets/secrets_test.go +++ b/pkg/secrets/secrets_test.go @@ -1,6 +1,9 @@ package secrets -import "testing" +import ( + "strings" + "testing" +) func TestSecretDetectorDetectAny(t *testing.T) { detector, err := NewSecretDetector() @@ -76,3 +79,509 @@ func TestRedactorDetector(t *testing.T) { t.Fatalf("expected detector to redact note") } } + +// TestSecretDetectorInputTooLong tests ErrSecretInputTooLong error +func TestSecretDetectorInputTooLong(t *testing.T) { + detector, err := NewSecretDetector(WithSecretMaxLength(10)) + if err != nil { + t.Fatalf("expected detector, got %v", err) + } + + longInput := strings.Repeat("a", 11) + _, err = detector.Detect(longInput) + if err != ErrSecretInputTooLong { + t.Fatalf("expected ErrSecretInputTooLong, got %v", err) + } + + err = detector.DetectAny(longInput) + if err != ErrSecretInputTooLong { + t.Fatalf("expected ErrSecretInputTooLong for DetectAny, got %v", err) + } + + _, _, err = detector.Redact(longInput) + if err != ErrSecretInputTooLong { + t.Fatalf("expected ErrSecretInputTooLong for Redact, got %v", err) + } +} + +// TestSecretDetectorInvalidConfig tests invalid detector configurations +func TestSecretDetectorInvalidConfig(t *testing.T) { + tests := []struct { + name string + opts []SecretDetectOption + }{ + { + name: "invalid max length", + opts: []SecretDetectOption{WithSecretMaxLength(0)}, + }, + { + name: "negative max length", + opts: []SecretDetectOption{WithSecretMaxLength(-1)}, + }, + { + name: "empty mask", + opts: []SecretDetectOption{WithSecretMask("")}, + }, + { + name: "whitespace mask", + opts: []SecretDetectOption{WithSecretMask(" ")}, + }, + { + name: "empty patterns", + opts: []SecretDetectOption{WithSecretPatterns()}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewSecretDetector(tt.opts...) + if err == nil { + t.Fatalf("expected error for %s, got nil", tt.name) + } + }) + } +} + +// TestRedactorInvalidConfig tests invalid redactor configurations +func TestRedactorInvalidConfig(t *testing.T) { + tests := []struct { + name string + opts []RedactorOption + }{ + { + name: "empty redaction mask", + opts: []RedactorOption{WithRedactionMask("")}, + }, + { + name: "whitespace redaction mask", + opts: []RedactorOption{WithRedactionMask(" ")}, + }, + { + name: "nil detector", + opts: []RedactorOption{WithRedactionDetector(nil)}, + }, + { + name: "zero max depth", + opts: []RedactorOption{WithRedactionMaxDepth(0)}, + }, + { + name: "negative max depth", + opts: []RedactorOption{WithRedactionMaxDepth(-1)}, + }, + { + name: "empty keys", + opts: []RedactorOption{WithRedactionKeys()}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewRedactor(tt.opts...) + if err == nil { + t.Fatalf("expected error for %s, got nil", tt.name) + } + }) + } +} + +// TestSecretDetectorEdgeCases tests edge cases like empty and nil values +func TestSecretDetectorEdgeCases(t *testing.T) { + detector, err := NewSecretDetector() + if err != nil { + t.Fatalf("expected detector, got %v", err) + } + + t.Run("empty string", func(t *testing.T) { + matches, err := detector.Detect("") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if matches != nil { + t.Fatalf("expected nil matches, got %v", matches) + } + }) + + t.Run("whitespace only", func(t *testing.T) { + matches, err := detector.Detect(" ") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if matches != nil { + t.Fatalf("expected nil matches, got %v", matches) + } + }) + + t.Run("no secrets", func(t *testing.T) { + matches, err := detector.Detect("hello world") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(matches) != 0 { + t.Fatalf("expected no matches, got %v", matches) + } + }) +} + +// TestRedactorEdgeCases tests edge cases for redactor +func TestRedactorEdgeCases(t *testing.T) { + redactor, err := NewRedactor() + if err != nil { + t.Fatalf("expected redactor, got %v", err) + } + + t.Run("nil fields", func(t *testing.T) { + result := redactor.RedactFields(nil) + if result != nil { + t.Fatalf("expected nil result, got %v", result) + } + }) + + t.Run("empty map", func(t *testing.T) { + fields := map[string]any{} + result := redactor.RedactFields(fields) + if len(result) != 0 { + t.Fatalf("expected empty map, got %v", result) + } + }) + + t.Run("empty string value", func(t *testing.T) { + result := redactor.RedactString("") + if result != "" { + t.Fatalf("expected empty string, got %q", result) + } + }) +} + +// TestWithSecretPattern tests the WithSecretPattern option +func TestWithSecretPattern(t *testing.T) { + detector, err := NewSecretDetector( + WithSecretPattern("custom-pattern", `custom-[0-9]{4}`), + ) + if err != nil { + t.Fatalf("expected detector, got %v", err) + } + + input := "My custom token is custom-1234" + matches, err := detector.Detect(input) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + found := false + for _, match := range matches { + if match.Pattern == "custom-pattern" && match.Value == "custom-1234" { + found = true + break + } + } + + if !found { + t.Fatalf("expected to find custom-pattern match") + } +} + +// TestWithSecretPatterns tests the WithSecretPatterns option +func TestWithSecretPatterns(t *testing.T) { + patterns := []SecretPattern{ + {Name: "test-pattern-1", Pattern: `test-[0-9]{3}`}, + {Name: "test-pattern-2", Pattern: `secret-[a-z]{3}`}, + } + + detector, err := NewSecretDetector(WithSecretPatterns(patterns...)) + if err != nil { + t.Fatalf("expected detector, got %v", err) + } + + input := "Found test-123 and secret-abc" + matches, err := detector.Detect(input) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(matches) != 2 { + t.Fatalf("expected 2 matches, got %d", len(matches)) + } +} + +// TestWithSecretMaxLength tests the WithSecretMaxLength option +func TestWithSecretMaxLength(t *testing.T) { + maxLen := 20 + detector, err := NewSecretDetector(WithSecretMaxLength(maxLen)) + if err != nil { + t.Fatalf("expected detector, got %v", err) + } + + t.Run("within limit", func(t *testing.T) { + input := "short text" + _, err := detector.Detect(input) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("exceeds limit", func(t *testing.T) { + input := strings.Repeat("a", maxLen+1) + _, err := detector.Detect(input) + if err != ErrSecretInputTooLong { + t.Fatalf("expected ErrSecretInputTooLong, got %v", err) + } + }) +} + +// TestWithSecretMask tests the WithSecretMask option +func TestWithSecretMask(t *testing.T) { + customMask := "***HIDDEN***" + detector, err := NewSecretDetector(WithSecretMask(customMask)) + if err != nil { + t.Fatalf("expected detector, got %v", err) + } + + input := "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890" + output, matches, err := detector.Redact(input) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(matches) == 0 { + t.Fatalf("expected matches, got none") + } + + if !strings.Contains(output, customMask) { + t.Fatalf("expected output to contain custom mask %q, got %q", customMask, output) + } +} + +// TestNestedStructureRedaction tests redaction of nested structures +func TestNestedStructureRedaction(t *testing.T) { + detector, err := NewSecretDetector() + if err != nil { + t.Fatalf("expected detector, got %v", err) + } + + redactor, err := NewRedactor(WithRedactionDetector(detector)) + if err != nil { + t.Fatalf("expected redactor, got %v", err) + } + + t.Run("maps within maps", func(t *testing.T) { + fields := map[string]any{ + "user": "alice", + "auth": map[string]any{ + "password": "secret123", + "token": "ghp_abcdefghijklmnopqrstuvwxyz1234567890", + "metadata": map[string]any{ + "api_key": "sensitive", + "name": "test", + }, + }, + } + + redacted := redactor.RedactFields(fields) + + if redacted["user"] != "alice" { + t.Fatalf("expected user intact") + } + + auth, ok := redacted["auth"].(map[string]any) + if !ok { + t.Fatalf("expected auth to be map[string]any") + } + + if auth["password"] == "secret123" { + t.Fatalf("expected password redacted in nested map") + } + + if auth["token"] == "ghp_abcdefghijklmnopqrstuvwxyz1234567890" { + t.Fatalf("expected token redacted in nested map") + } + + metadata, ok := auth["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected metadata to be map[string]any") + } + + if metadata["api_key"] == "sensitive" { + t.Fatalf("expected api_key redacted in deeply nested map") + } + + if metadata["name"] != "test" { + t.Fatalf("expected name intact in deeply nested map") + } + }) + + t.Run("slices within maps", func(t *testing.T) { + fields := map[string]any{ + "users": []any{ + map[string]any{ + "name": "alice", + "password": "secret1", + }, + map[string]any{ + "name": "bob", + "token": "ghp_abcdefghijklmnopqrstuvwxyz1234567890", + }, + }, + } + + redacted := redactor.RedactFields(fields) + + users, ok := redacted["users"].([]any) + if !ok { + t.Fatalf("expected users to be []any") + } + + if len(users) != 2 { + t.Fatalf("expected 2 users, got %d", len(users)) + } + + user1, ok := users[0].(map[string]any) + if !ok { + t.Fatalf("expected user1 to be map[string]any") + } + + if user1["name"] != "alice" { + t.Fatalf("expected alice's name intact") + } + + if user1["password"] == "secret1" { + t.Fatalf("expected alice's password redacted") + } + + user2, ok := users[1].(map[string]any) + if !ok { + t.Fatalf("expected user2 to be map[string]any") + } + + if user2["token"] == "ghp_abcdefghijklmnopqrstuvwxyz1234567890" { + t.Fatalf("expected bob's token redacted") + } + }) +} + +// TestWithRedactionKeys tests the WithRedactionKeys option +func TestWithRedactionKeys(t *testing.T) { + t.Run("add custom keys", func(t *testing.T) { + redactor, err := NewRedactor( + WithRedactionKeys("custom_secret", "private_data"), + ) + if err != nil { + t.Fatalf("expected redactor, got %v", err) + } + + fields := map[string]any{ + "custom_secret": "sensitive", + "private_data": "confidential", + "public_info": "visible", + } + + redacted := redactor.RedactFields(fields) + + if redacted["custom_secret"] == "sensitive" { + t.Fatalf("expected custom_secret redacted") + } + + if redacted["private_data"] == "confidential" { + t.Fatalf("expected private_data redacted") + } + + if redacted["public_info"] != "visible" { + t.Fatalf("expected public_info intact") + } + }) + + t.Run("case insensitive keys", func(t *testing.T) { + redactor, err := NewRedactor( + WithRedactionKeys("MySecret"), + ) + if err != nil { + t.Fatalf("expected redactor, got %v", err) + } + + fields := map[string]any{ + "mysecret": "value1", + "MYSECRET": "value2", + "MySecret": "value3", + } + + redacted := redactor.RedactFields(fields) + + if redacted["mysecret"] == "value1" { + t.Fatalf("expected mysecret redacted") + } + + if redacted["MYSECRET"] == "value2" { + t.Fatalf("expected MYSECRET redacted") + } + + if redacted["MySecret"] == "value3" { + t.Fatalf("expected MySecret redacted") + } + }) +} + +// TestWithRedactionMaxDepth tests the WithRedactionMaxDepth option +func TestWithRedactionMaxDepth(t *testing.T) { + t.Run("depth limit prevents deep redaction", func(t *testing.T) { + redactor, err := NewRedactor(WithRedactionMaxDepth(2)) + if err != nil { + t.Fatalf("expected redactor, got %v", err) + } + + // Create a deeply nested structure (depth > 2) + fields := map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "level3": map[string]any{ + "password": "should_not_redact", + }, + }, + }, + } + + redacted := redactor.RedactFields(fields) + + level1, ok := redacted["level1"].(map[string]any) + if !ok { + t.Fatalf("expected level1 to be map[string]any") + } + + level2, ok := level1["level2"].(map[string]any) + if !ok { + t.Fatalf("expected level2 to be map[string]any") + } + + level3, ok := level2["level3"].(map[string]any) + if !ok { + t.Fatalf("expected level3 to be map[string]any") + } + + // At depth 3, the password should not be redacted due to max depth limit + if level3["password"] != "should_not_redact" { + t.Fatalf("expected password to remain intact at depth > maxDepth") + } + }) + + t.Run("within depth limit redacts properly", func(t *testing.T) { + redactor, err := NewRedactor(WithRedactionMaxDepth(3)) + if err != nil { + t.Fatalf("expected redactor, got %v", err) + } + + fields := map[string]any{ + "level1": map[string]any{ + "password": "should_redact", + }, + } + + redacted := redactor.RedactFields(fields) + + level1, ok := redacted["level1"].(map[string]any) + if !ok { + t.Fatalf("expected level1 to be map[string]any") + } + + if level1["password"] == "should_redact" { + t.Fatalf("expected password to be redacted within depth limit") + } + }) +} From e075986c346fd55c07defe350295cde0092cebf8 Mon Sep 17 00:00:00 2001 From: "F." Date: Mon, 12 Jan 2026 14:36:51 +0100 Subject: [PATCH 3/9] fix(secrets): validate redactor config based on added key count - Return ErrInvalidRedactorConfig when no keys are actually added during redactor setup. - Replace len(cfg.keys) pre-check with an addedCount tracked in the build loop to avoid accepting configs that result in zero patterns after filtering/deduplication. tests(secrets): use errors.Is for assertions and tidy formatting test(encoding): minor test cleanup (introduce result var) --- cspell.json | 1 + pkg/encoding/encoding_test.go | 1 + pkg/secrets/redact.go | 5 +++- pkg/secrets/secrets_test.go | 50 +++++++++++++++++++++++------------ 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/cspell.json b/cspell.json index ccf3118..61ac155 100644 --- a/cspell.json +++ b/cspell.json @@ -123,6 +123,7 @@ "MX", "myproject", "mypy", + "mysecret", "myuser", "nbf", "nethtml", diff --git a/pkg/encoding/encoding_test.go b/pkg/encoding/encoding_test.go index 7334578..06bc5c3 100644 --- a/pkg/encoding/encoding_test.go +++ b/pkg/encoding/encoding_test.go @@ -154,6 +154,7 @@ func TestDecodeJSONReader(t *testing.T) { reader := strings.NewReader(`{"name":"alpha"}`) var result payload + err := DecodeJSONReader(reader, &result) if err != nil { t.Fatalf("expected decoded, got %v", err) diff --git a/pkg/secrets/redact.go b/pkg/secrets/redact.go index 829a6ca..d1c9500 100644 --- a/pkg/secrets/redact.go +++ b/pkg/secrets/redact.go @@ -85,6 +85,8 @@ func WithRedactionKeys(keys ...string) RedactorOption { cfg.keys = make(map[string]struct{}) } + addedCount := 0 + for _, key := range keys { value := normalizeRedactionKey(key) if value == "" { @@ -92,9 +94,10 @@ func WithRedactionKeys(keys ...string) RedactorOption { } cfg.keys[value] = struct{}{} + addedCount++ } - if len(cfg.keys) == 0 { + if addedCount == 0 { return ErrInvalidRedactorConfig } diff --git a/pkg/secrets/secrets_test.go b/pkg/secrets/secrets_test.go index da1d3b2..512c50e 100644 --- a/pkg/secrets/secrets_test.go +++ b/pkg/secrets/secrets_test.go @@ -1,6 +1,7 @@ package secrets import ( + "errors" "strings" "testing" ) @@ -12,7 +13,7 @@ func TestSecretDetectorDetectAny(t *testing.T) { } err = detector.DetectAny("AKIA1234567890ABCD12") - if err != ErrSecretDetected { + if !errors.Is(err, ErrSecretDetected) { t.Fatalf("expected ErrSecretDetected, got %v", err) } } @@ -24,6 +25,7 @@ func TestSecretDetectorRedact(t *testing.T) { } input := "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890" + output, matches, err := detector.Redact(input) if err != nil { t.Fatalf("expected redacted, got %v", err) @@ -80,7 +82,7 @@ func TestRedactorDetector(t *testing.T) { } } -// TestSecretDetectorInputTooLong tests ErrSecretInputTooLong error +// TestSecretDetectorInputTooLong tests ErrSecretInputTooLong error. func TestSecretDetectorInputTooLong(t *testing.T) { detector, err := NewSecretDetector(WithSecretMaxLength(10)) if err != nil { @@ -88,23 +90,24 @@ func TestSecretDetectorInputTooLong(t *testing.T) { } longInput := strings.Repeat("a", 11) + _, err = detector.Detect(longInput) - if err != ErrSecretInputTooLong { + if !errors.Is(err, ErrSecretInputTooLong) { t.Fatalf("expected ErrSecretInputTooLong, got %v", err) } err = detector.DetectAny(longInput) - if err != ErrSecretInputTooLong { + if !errors.Is(err, ErrSecretInputTooLong) { t.Fatalf("expected ErrSecretInputTooLong for DetectAny, got %v", err) } _, _, err = detector.Redact(longInput) - if err != ErrSecretInputTooLong { + if !errors.Is(err, ErrSecretInputTooLong) { t.Fatalf("expected ErrSecretInputTooLong for Redact, got %v", err) } } -// TestSecretDetectorInvalidConfig tests invalid detector configurations +// TestSecretDetectorInvalidConfig tests invalid detector configurations. func TestSecretDetectorInvalidConfig(t *testing.T) { tests := []struct { name string @@ -142,7 +145,7 @@ func TestSecretDetectorInvalidConfig(t *testing.T) { } } -// TestRedactorInvalidConfig tests invalid redactor configurations +// TestRedactorInvalidConfig tests invalid redactor configurations. func TestRedactorInvalidConfig(t *testing.T) { tests := []struct { name string @@ -184,7 +187,7 @@ func TestRedactorInvalidConfig(t *testing.T) { } } -// TestSecretDetectorEdgeCases tests edge cases like empty and nil values +// TestSecretDetectorEdgeCases tests edge cases like empty and nil values. func TestSecretDetectorEdgeCases(t *testing.T) { detector, err := NewSecretDetector() if err != nil { @@ -196,6 +199,7 @@ func TestSecretDetectorEdgeCases(t *testing.T) { if err != nil { t.Fatalf("expected no error, got %v", err) } + if matches != nil { t.Fatalf("expected nil matches, got %v", matches) } @@ -206,6 +210,7 @@ func TestSecretDetectorEdgeCases(t *testing.T) { if err != nil { t.Fatalf("expected no error, got %v", err) } + if matches != nil { t.Fatalf("expected nil matches, got %v", matches) } @@ -216,13 +221,14 @@ func TestSecretDetectorEdgeCases(t *testing.T) { if err != nil { t.Fatalf("expected no error, got %v", err) } + if len(matches) != 0 { t.Fatalf("expected no matches, got %v", matches) } }) } -// TestRedactorEdgeCases tests edge cases for redactor +// TestRedactorEdgeCases tests edge cases for redactor. func TestRedactorEdgeCases(t *testing.T) { redactor, err := NewRedactor() if err != nil { @@ -238,6 +244,7 @@ func TestRedactorEdgeCases(t *testing.T) { t.Run("empty map", func(t *testing.T) { fields := map[string]any{} + result := redactor.RedactFields(fields) if len(result) != 0 { t.Fatalf("expected empty map, got %v", result) @@ -252,7 +259,7 @@ func TestRedactorEdgeCases(t *testing.T) { }) } -// TestWithSecretPattern tests the WithSecretPattern option +// TestWithSecretPattern tests the WithSecretPattern option. func TestWithSecretPattern(t *testing.T) { detector, err := NewSecretDetector( WithSecretPattern("custom-pattern", `custom-[0-9]{4}`), @@ -262,15 +269,18 @@ func TestWithSecretPattern(t *testing.T) { } input := "My custom token is custom-1234" + matches, err := detector.Detect(input) if err != nil { t.Fatalf("expected no error, got %v", err) } found := false + for _, match := range matches { if match.Pattern == "custom-pattern" && match.Value == "custom-1234" { found = true + break } } @@ -280,7 +290,7 @@ func TestWithSecretPattern(t *testing.T) { } } -// TestWithSecretPatterns tests the WithSecretPatterns option +// TestWithSecretPatterns tests the WithSecretPatterns option. func TestWithSecretPatterns(t *testing.T) { patterns := []SecretPattern{ {Name: "test-pattern-1", Pattern: `test-[0-9]{3}`}, @@ -293,6 +303,7 @@ func TestWithSecretPatterns(t *testing.T) { } input := "Found test-123 and secret-abc" + matches, err := detector.Detect(input) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -303,9 +314,10 @@ func TestWithSecretPatterns(t *testing.T) { } } -// TestWithSecretMaxLength tests the WithSecretMaxLength option +// TestWithSecretMaxLength tests the WithSecretMaxLength option. func TestWithSecretMaxLength(t *testing.T) { maxLen := 20 + detector, err := NewSecretDetector(WithSecretMaxLength(maxLen)) if err != nil { t.Fatalf("expected detector, got %v", err) @@ -313,6 +325,7 @@ func TestWithSecretMaxLength(t *testing.T) { t.Run("within limit", func(t *testing.T) { input := "short text" + _, err := detector.Detect(input) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -321,22 +334,25 @@ func TestWithSecretMaxLength(t *testing.T) { t.Run("exceeds limit", func(t *testing.T) { input := strings.Repeat("a", maxLen+1) + _, err := detector.Detect(input) - if err != ErrSecretInputTooLong { + if !errors.Is(err, ErrSecretInputTooLong) { t.Fatalf("expected ErrSecretInputTooLong, got %v", err) } }) } -// TestWithSecretMask tests the WithSecretMask option +// TestWithSecretMask tests the WithSecretMask option. func TestWithSecretMask(t *testing.T) { customMask := "***HIDDEN***" + detector, err := NewSecretDetector(WithSecretMask(customMask)) if err != nil { t.Fatalf("expected detector, got %v", err) } input := "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890" + output, matches, err := detector.Redact(input) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -351,7 +367,7 @@ func TestWithSecretMask(t *testing.T) { } } -// TestNestedStructureRedaction tests redaction of nested structures +// TestNestedStructureRedaction tests redaction of nested structures. func TestNestedStructureRedaction(t *testing.T) { detector, err := NewSecretDetector() if err != nil { @@ -458,7 +474,7 @@ func TestNestedStructureRedaction(t *testing.T) { }) } -// TestWithRedactionKeys tests the WithRedactionKeys option +// TestWithRedactionKeys tests the WithRedactionKeys option. func TestWithRedactionKeys(t *testing.T) { t.Run("add custom keys", func(t *testing.T) { redactor, err := NewRedactor( @@ -519,7 +535,7 @@ func TestWithRedactionKeys(t *testing.T) { }) } -// TestWithRedactionMaxDepth tests the WithRedactionMaxDepth option +// TestWithRedactionMaxDepth tests the WithRedactionMaxDepth option. func TestWithRedactionMaxDepth(t *testing.T) { t.Run("depth limit prevents deep redaction", func(t *testing.T) { redactor, err := NewRedactor(WithRedactionMaxDepth(2)) From 788ca16a4e8f6725a25f406fd1269976fdb2deb7 Mon Sep 17 00:00:00 2001 From: "F." <62474964+hyp3rd@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:00:47 +0100 Subject: [PATCH 4/9] Update pkg/secrets/redact.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/secrets/redact.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/secrets/redact.go b/pkg/secrets/redact.go index d1c9500..971e3e6 100644 --- a/pkg/secrets/redact.go +++ b/pkg/secrets/redact.go @@ -93,8 +93,10 @@ func WithRedactionKeys(keys ...string) RedactorOption { continue } - cfg.keys[value] = struct{}{} - addedCount++ + if _, exists := cfg.keys[value]; !exists { + cfg.keys[value] = struct{}{} + addedCount++ + } } if addedCount == 0 { From 7ffd500a74fe7bcab09aad8efa5b1d91df83e98a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:01:11 +0000 Subject: [PATCH 5/9] Initial plan From 96cb10ef3ea7ebd21b9b27704034572bfbee34bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:04:23 +0000 Subject: [PATCH 6/9] test(secrets): add duplicate key handling test cases Add test cases to validate the behavior when duplicate keys are provided to WithRedactionKeys, including: - Duplicate keys in the same call - Keys with different cases (normalized to same key) - Keys already in default keys (should error) - Multiple keys already in defaults (should error) - Mix of duplicate and default keys (should error) The new TestRedactorDuplicateKeys validates that duplicate keys are properly handled by the addedCount logic, ensuring at least one unique key is added. Co-authored-by: hyp3rd <62474964+hyp3rd@users.noreply.github.com> --- pkg/secrets/secrets_test.go | 68 +++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/pkg/secrets/secrets_test.go b/pkg/secrets/secrets_test.go index 512c50e..c0154ce 100644 --- a/pkg/secrets/secrets_test.go +++ b/pkg/secrets/secrets_test.go @@ -175,6 +175,18 @@ func TestRedactorInvalidConfig(t *testing.T) { name: "empty keys", opts: []RedactorOption{WithRedactionKeys()}, }, + { + name: "key already in defaults", + opts: []RedactorOption{WithRedactionKeys("password")}, + }, + { + name: "multiple keys already in defaults", + opts: []RedactorOption{WithRedactionKeys("password", "token", "secret")}, + }, + { + name: "mix of duplicate and default keys", + opts: []RedactorOption{WithRedactionKeys("password", "password", "token")}, + }, } for _, tt := range tests { @@ -187,6 +199,62 @@ func TestRedactorInvalidConfig(t *testing.T) { } } +// TestRedactorDuplicateKeys tests that duplicate keys are handled correctly. +func TestRedactorDuplicateKeys(t *testing.T) { + tests := []struct { + name string + opts []RedactorOption + testKey string + wantMatch bool + }{ + { + name: "duplicate keys in same call - first is added", + opts: []RedactorOption{WithRedactionKeys("duplicate_key", "duplicate_key")}, + testKey: "duplicate_key", + wantMatch: true, + }, + { + name: "duplicate keys with different cases", + opts: []RedactorOption{WithRedactionKeys("MyKey", "mykey", "MYKEY")}, + testKey: "mykey", + wantMatch: true, + }, + { + name: "new key plus default key", + opts: []RedactorOption{WithRedactionKeys("custom_key", "password")}, + testKey: "custom_key", + wantMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + redactor, err := NewRedactor(tt.opts...) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Test that the key is properly recognized + fields := map[string]any{ + tt.testKey: "sensitive_value", + "other": "non_sensitive", + } + + redacted := redactor.RedactFields(fields) + + if tt.wantMatch { + if redacted[tt.testKey] != "[REDACTED]" { + t.Errorf("expected key %q to be redacted, got %v", tt.testKey, redacted[tt.testKey]) + } + } else { + if redacted[tt.testKey] == "[REDACTED]" { + t.Errorf("expected key %q not to be redacted", tt.testKey) + } + } + }) + } +} + // TestSecretDetectorEdgeCases tests edge cases like empty and nil values. func TestSecretDetectorEdgeCases(t *testing.T) { detector, err := NewSecretDetector() From 3ac0c6b86b89adc22e6de4aef9cfb6dcbdd4b2ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:05:35 +0000 Subject: [PATCH 7/9] fix: remove extra whitespace in test Co-authored-by: hyp3rd <62474964+hyp3rd@users.noreply.github.com> --- pkg/secrets/secrets_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/secrets/secrets_test.go b/pkg/secrets/secrets_test.go index c0154ce..419cc11 100644 --- a/pkg/secrets/secrets_test.go +++ b/pkg/secrets/secrets_test.go @@ -241,7 +241,7 @@ func TestRedactorDuplicateKeys(t *testing.T) { } redacted := redactor.RedactFields(fields) - + if tt.wantMatch { if redacted[tt.testKey] != "[REDACTED]" { t.Errorf("expected key %q to be redacted, got %v", tt.testKey, redacted[tt.testKey]) From 04341d6bff74e2f7d486071e108443bbc7bd11d2 Mon Sep 17 00:00:00 2001 From: "F." Date: Mon, 12 Jan 2026 15:15:25 +0100 Subject: [PATCH 8/9] chore(cspell): add mykey to dictionary to avoid false positives - Update cspell.json to include mykey in the allowed word list - Prevents spellcheck/lint failures in secrets-related code and docs - No runtime or API changes --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index 61ac155..f5ffba2 100644 --- a/cspell.json +++ b/cspell.json @@ -121,6 +121,7 @@ "mlock", "mvdan", "MX", + "mykey", "myproject", "mypy", "mysecret", From 79b92b7aabfed405d7c2d975ce55de62178ed835 Mon Sep 17 00:00:00 2001 From: "F." Date: Mon, 12 Jan 2026 15:24:17 +0100 Subject: [PATCH 9/9] ci(proto): split into settings job and gated proto job - Rename original job to `settings` to centralize project config - Publish `go_version`, `buf_version`, and `proto_enabled` via - Add `proto` job that `needs: settings` and runs only when `proto_enabled == 'true'` - Switch references from `steps.settings.outputs.*` to `needs.settings.outputs.*` - Remove env-based gating and per-step `if` checks in favor of job-level condition This makes proto lint/format/generate deterministic, avoids env leakage, and fixes output wiring in the workflow. --- .github/workflows/proto.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index 30dd08a..96d5e40 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -8,10 +8,12 @@ on: workflow_dispatch: jobs: - proto: + settings: runs-on: ubuntu-latest - env: - PROTO_ENABLED: true + outputs: + go_version: ${{ steps.settings.outputs.go_version }} + buf_version: ${{ steps.settings.outputs.buf_version }} + proto_enabled: ${{ steps.settings.outputs.proto_enabled }} steps: - uses: actions/checkout@v6 - name: Load project settings @@ -22,25 +24,27 @@ jobs: set +a echo "go_version=${GO_VERSION}" >> "$GITHUB_OUTPUT" echo "buf_version=${BUF_VERSION}" >> "$GITHUB_OUTPUT" - echo "PROTO_ENABLED=${PROTO_ENABLED:-true}" >> "$GITHUB_ENV" + echo "proto_enabled=${PROTO_ENABLED:-true}" >> "$GITHUB_OUTPUT" + proto: + runs-on: ubuntu-latest + needs: settings + if: needs.settings.outputs.proto_enabled == 'true' + steps: + - uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 with: - go-version: "${{ steps.settings.outputs.go_version }}" + go-version: "${{ needs.settings.outputs.go_version }}" check-latest: true - name: Setup buf uses: bufbuild/buf-setup-action@v1 with: - version: "${{ steps.settings.outputs.buf_version }}" + version: "${{ needs.settings.outputs.buf_version }}" - name: Lint protos - if: env.PROTO_ENABLED != 'false' run: buf lint - name: Format protos - if: env.PROTO_ENABLED != 'false' run: buf format -w - name: Generate protos - if: env.PROTO_ENABLED != 'false' run: buf generate - name: Check diff - if: env.PROTO_ENABLED != 'false' run: git diff --exit-code