carlos / platform Public

apperr: redact only hex/base64-looking long runs, not paths or identifiers

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 4098a2469e31df0d42a58403b8f62eaed6ead4c7 parent 185b019
2 files changed, +81 −3
  • internal/apperr/sanitize.go +69 −3
  • internal/apperr/sanitize_test.go +12 −0
diff --git a/internal/apperr/sanitize.go b/internal/apperr/sanitize.go
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,}$`)
diff --git a/internal/apperr/sanitize_test.go b/internal/apperr/sanitize_test.go
index 6553c6aaa..694ef7cf4 100644
--- a/internal/apperr/sanitize_test.go
+++ b/internal/apperr/sanitize_test.go
@@ -14,6 +14,18 @@ func TestSanitize(t *testing.T) {
"b64 QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo0MTIzNA== ok": "b64 <hex> ok",
"short 4123 stays": "short 4123 stays",
"nothing here": "nothing here",
+ // Regression: a long run of letters/digits/"/"/"-"/"_" is not
+ // automatically a secret — a path or a long identifier must
+ // survive; only something hex- or base64-shaped is redacted.
+ "at Object.<anonymous> (/app/node_modules/express/lib/router/index.js:284:7)": "at Object.<anonymous> (/app/node_modules/express/lib/router/index.js:284:7)",
+ "internalServerErrorHandlerMiddlewareFactory": "internalServerErrorHandlerMiddlewareFactory",
+ "sess 0123456789abcdef0123456789abcdef0123456789abcdef done": "sess <hex> done",
+ // A JWT's dot-separated base64url segments: the "." breaks the
+ // candidate run into two tokens. The first (36 chars, mixes
+ // digit/upper/lower) clears the 32-char floor and is redacted;
+ // the second (19 chars) is below the floor and survives — see
+ // the report for why that is an accepted partial redaction here.
+ "jwt eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0 x": "jwt <hex>.eyJzdWIiOiIxMjM0In0 x",
}
for in, want := range cases {
if got := Sanitize(in); got != want {