| index c0510dbc7..07866947a 100644 |
| --- a/internal/apperr/sanitize.go |
| +++ b/internal/apperr/sanitize.go |
| @@ -13,10 +13,50 @@ var ( |
| reAuth = regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+`) |
| // key=value and "key": "value" shapes, value up to the next delimiter. |
| reKV = regexp.MustCompile(`(?i)\b(token|key|secret|password|passwd|pwd|api_key|apikey|access_token|auth)("?\s*[=:]\s*"?)[^\s&"',;]+`) |
| - // A hex or base64 run of 32+ characters: a digest, a session id, a key. |
| - reLong = regexp.MustCompile(`\b[A-Za-z0-9+/_-]{32,}={0,2}`) |
| + // reLongCandidate finds runs of 32+ characters that COULD be a |
| + // redacted secret: a digest, a token, a base64 blob. It deliberately |
| + // over-matches — an absolute path (/app/node_modules/.../index) and a |
| + // long camelCase identifier both fit this shape — because |
| + // looksLikeSecretToken, not the regex, decides what actually gets |
| + // redacted. |
| + reLongCandidate = regexp.MustCompile(`\b[A-Za-z0-9+/_-]{32,}={0,2}`) |
| + // reHexOnly is a candidate that is nothing but hex digits: a digest. |
| + reHexOnly = regexp.MustCompile(`^[0-9a-fA-F]{32,}$`) |
| ) |
| |
| +// looksLikeSecretToken reports whether a run already matched by |
| +// reLongCandidate actually looks like a hex digest or a base64 blob, |
| +// rather than an ordinary path or a long identifier. A hex digest is all |
| +// [0-9a-fA-F]. A base64 blob either mixes a digit, an upper-case letter |
| +// and a lower-case letter, or carries a base64-only character (+ or the = |
| +// padding) — a bare / is treated as a base64 signal only when the token |
| +// has no "." and is not itself a path segment, since both a |
| +// node_modules path segment and an absolute path contain "/" without |
| +// being base64. precededBySlash is true when the character immediately |
| +// before the match in the original string is "/": \b never matches |
| +// between two non-word characters, so an absolute path's candidate token |
| +// starts right after its leading "/", not with it — precededBySlash is |
| +// how the caller hands back the "starts with /" signal the regex itself |
| +// can't capture. |
| +func looksLikeSecretToken(tok string, precededBySlash bool) bool { |
| + if reHexOnly.MatchString(tok) { |
| + return true |
| + } |
| + hasDigit := strings.ContainsAny(tok, "0123456789") |
| + hasUpper := strings.ContainsAny(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") |
| + hasLower := strings.ContainsAny(tok, "abcdefghijklmnopqrstuvwxyz") |
| + if hasDigit && hasUpper && hasLower { |
| + return true |
| + } |
| + if strings.ContainsAny(tok, "+=") { |
| + return true |
| + } |
| + if strings.Contains(tok, "/") && !strings.Contains(tok, ".") && !precededBySlash { |
| + return true |
| + } |
| + return false |
| +} |
| + |
| // Sanitize redacts secrets in free text: messages, stacks, field values. |
| func Sanitize(s string) string { |
| s = reEmail.ReplaceAllString(s, "<email>") |
| @@ -25,10 +65,36 @@ func Sanitize(s string) string { |
| return word + " <redacted>" |
| }) |
| s = reKV.ReplaceAllString(s, "${1}${2}<redacted>") |
| - s = reLong.ReplaceAllString(s, "<hex>") |
| + s = redactLongTokens(s) |
| return s |
| } |
| |
| +// redactLongTokens replaces each reLongCandidate match that |
| +// looksLikeSecretToken accepts with "<hex>", leaving the rest — an |
| +// ordinary path or identifier — untouched. It walks matches by index |
| +// (rather than ReplaceAllStringFunc) because looksLikeSecretToken needs |
| +// to see the byte before the match to tell a path segment from a base64 |
| +// blob. |
| +func redactLongTokens(s string) string { |
| + idxs := reLongCandidate.FindAllStringIndex(s, -1) |
| + if idxs == nil { |
| + return s |
| + } |
| + var b strings.Builder |
| + last := 0 |
| + for _, m := range idxs { |
| + start, end := m[0], m[1] |
| + precededBySlash := start > 0 && s[start-1] == '/' |
| + if looksLikeSecretToken(s[start:end], precededBySlash) { |
| + b.WriteString(s[last:start]) |
| + b.WriteString("<hex>") |
| + last = end |
| + } |
| + } |
| + b.WriteString(s[last:]) |
| + return b.String() |
| +} |
| + |
| var ( |
| reUUID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) |
| reHex16 = regexp.MustCompile(`^[0-9a-fA-F]{16,}$`) |