diff --git a/internal/logs/sanitizer.go b/internal/logs/sanitizer.go index fe205bea9..5eff862ce 100644 --- a/internal/logs/sanitizer.go +++ b/internal/logs/sanitizer.go @@ -38,6 +38,48 @@ func NewSecretSanitizer(core zapcore.Core) *SecretSanitizer { // registerDefaultPatterns registers patterns for common secret formats func (s *SecretSanitizer) registerDefaultPatterns() { + // Patterns run in order over the already-rewritten string, and every mask + // inserts "***", which no pattern's character class accepts. A pattern + // whose match can contain another pattern's match must therefore run + // first, or it stops at the inserted mask and logs the rest in clear. + + // Generic Bearer tokens. First: the credential may start with a known key + // prefix (sk-, ghp_, ...) and carry more after it. + s.patterns = append(s.patterns, &secretPattern{ + name: "bearer_token", + regex: regexp.MustCompile(`\b(Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*)\b`), + maskFunc: func(token string) string { + parts := strings.SplitN(token, " ", 2) + if len(parts) != 2 { + return "Bearer ****" + } + if len(parts[1]) <= 4 { + return "Bearer ****" + } + return "Bearer " + parts[1][:4] + "***" + parts[1][len(parts[1])-2:] + }, + }) + + // JWT tokens (eyJ...). Before the key patterns, whose \b can fire after a + // '-' inside a segment, and before high_entropy, which stops at the first + // '.' and masks only the header. The signature may be empty (alg: none); + // no trailing \b, since nothing but a non-word char can follow a '.'. + s.patterns = append(s.patterns, &secretPattern{ + name: "jwt", + regex: regexp.MustCompile(`\b(eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]*)`), + maskFunc: func(jwt string) string { + parts := strings.Split(jwt, ".") + if len(parts) != 3 { + return "****" + } + if len(parts[2]) < 4 { + return parts[0] + ".***.***" + } + // Show first part (header) but mask payload and signature + return parts[0] + ".***." + parts[2][len(parts[2])-4:] + }, + }) + // GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_, and fine-grained github_pat_) // Open-ended length ({36,}): the new stateless token format can be ~520 chars, // and an alphanumeric run has no \b boundary mid-token to stop a fixed upper bound. @@ -78,22 +120,6 @@ func (s *SecretSanitizer) registerDefaultPatterns() { }, }) - // Generic Bearer tokens - s.patterns = append(s.patterns, &secretPattern{ - name: "bearer_token", - regex: regexp.MustCompile(`\b(Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*)\b`), - maskFunc: func(token string) string { - parts := strings.SplitN(token, " ", 2) - if len(parts) != 2 { - return "Bearer ****" - } - if len(parts[1]) <= 4 { - return "Bearer ****" - } - return "Bearer " + parts[1][:4] + "***" + parts[1][len(parts[1])-2:] - }, - }) - // AWS keys (AKIA...) s.patterns = append(s.patterns, &secretPattern{ name: "aws_key", @@ -103,25 +129,6 @@ func (s *SecretSanitizer) registerDefaultPatterns() { }, }) - // JWT tokens (eyJ...). Must run before high_entropy: that pattern stops - // at the first '.', masks only the header, and leaves nothing for this - // regex to match, so the payload and signature would be logged in clear. - s.patterns = append(s.patterns, &secretPattern{ - name: "jwt", - regex: regexp.MustCompile(`\b(eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+)\b`), - maskFunc: func(jwt string) string { - parts := strings.Split(jwt, ".") - if len(parts) != 3 { - return "****" - } - if len(parts[2]) < 4 { - return parts[0] + ".***.***" - } - // Show first part (header) but mask payload and signature - return parts[0] + ".***." + parts[2][len(parts[2])-4:] - }, - }) - // Generic high-entropy strings (likely tokens/passwords) // Only mask if they appear in suspicious contexts (after = : or in quotes) s.patterns = append(s.patterns, &secretPattern{ diff --git a/internal/logs/sanitizer_pattern_order_test.go b/internal/logs/sanitizer_pattern_order_test.go new file mode 100644 index 000000000..846fb928a --- /dev/null +++ b/internal/logs/sanitizer_pattern_order_test.go @@ -0,0 +1,69 @@ +package logs + +import ( + "strings" + "testing" +) + +// A key-specific pattern that runs before bearer_token inserts "***", which is +// outside bearer's character class, so bearer stopped at the mask and the rest +// of the credential was logged in clear. +func TestSanitizer_BearerWithKnownKeyPrefixMasksWholeCredential(t *testing.T) { + s := newTestSanitizer() + const extra = "EXTRA_SECRET" + for _, token := range []string{ + "sk-AbCdEf1234567890GhIjKlMnOpQrStUvWxYz0123456789." + extra, + "sk-proj-AbCdEf1234567890_GhIjKl-MnOpQrStUvWxYz0123456789." + extra, + "ghp_1234567890abcdefghijABCDEFGHIJ123456." + extra, + "sk-ant-api03-" + strings.Repeat("aB3dE", 8) + "." + extra, + } { + out := s.sanitizeString("Authorization: Bearer " + token) + if strings.Contains(out, extra) { + t.Errorf("bearer credential suffix leaked:\n in: %s\n out: %s", token, out) + } + if !strings.Contains(out, "Bearer "+token[:4]+"***") { + t.Errorf("bearer mask format changed: %q", out) + } + } +} + +// A key-shaped run inside a JWT segment (after a '-', which is a \b boundary) +// must not be masked before jwt runs: the inserted "***" would stop the jwt +// regex and leave the payload and signature in clear. +func TestSanitizer_JWTContainingKeyShapedRunIsMaskedAsJWT(t *testing.T) { + s := newTestSanitizer() + const ( + payload = "eyJzdWIiOiIxMjM0NTY3ODkw-sk-AbCdEf1234567890GhIjKlMnOp" + signature = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + payload + "." + signature + ) + out := s.sanitizeString("got " + jwt) + if strings.Contains(out, "eyJzdWIiOiIxMjM0NTY3ODkw") || strings.Contains(out, signature) { + t.Errorf("JWT payload/signature leaked: %q", out) + } +} + +// An unsigned JWT (alg: none) has an empty third segment; its payload must +// still be masked. +func TestSanitizer_UnsignedJWTPayloadMasked(t *testing.T) { + s := newTestSanitizer() + const ( + header = "eyJhbGciOiJub25lIn0" + payload = "eyJzdWIiOiIxIn0" + jwt = header + "." + payload + "." + ) + for _, in := range []string{ + "jwt " + jwt, + "jwt " + jwt + " trailing", + `{"id_token":"` + jwt + `"}`, + "token=" + jwt, + } { + out := s.sanitizeString(in) + if strings.Contains(out, payload) { + t.Errorf("unsigned JWT payload leaked:\n in: %s\n out: %s", in, out) + } + if !strings.Contains(out, header+".***.***") { + t.Errorf("unsigned JWT mask format: %q", out) + } + } +}