Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 42 additions & 35 deletions internal/logs/sanitizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand All @@ -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{
Expand Down
69 changes: 69 additions & 0 deletions internal/logs/sanitizer_pattern_order_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading