// Package logredact strips PII and secrets from log strings before stdout/stderr. package logredact import ( "io" "log/slog" "os" "regexp" "sync" ) const Redacted = "[REDACTED]" var ( reAuthHeader = regexp.MustCompile(`(?i)\b(Bearer|Basic)\s+[A-Za-z0-9\-._~+/]+=*`) reSecretAssign = regexp.MustCompile(`(?i)\b((?:api[_-]?key|access[_-]?token|secret(?:_key)?|password|passwd|authorization|credential|private[_-]?key)\s*[=:]\s*)["']?[^\s"',}]+["']?`) reStripe = regexp.MustCompile(`\b(sk_live_|sk_test_|rk_live_|rk_test_|whsec_)[A-Za-z0-9]+`) reOpenAI = regexp.MustCompile(`\bsk-[A-Za-z0-9]{20,}`) reJWT = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`) reEmail = regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`) reDSN = regexp.MustCompile(`(?i)\b((?:mysql|postgres|postgresql|redis|rediss|mongodb):\/\/)[^@\s]+@`) reAuthPrefix = regexp.MustCompile(`(?i)^(Bearer|Basic)\s+`) reAssignPrefix = regexp.MustCompile(`(?i)^((?:api[_-]?key|access[_-]?token|secret(?:_key)?|password|passwd|authorization|credential|private[_-]?key)\s*[=:]\s*)`) ) // String redacts emails, tokens, Stripe/OpenAI keys, and DB URLs with credentials. // Fail-safe: returns Redacted if redaction panics. func String(input string) (out string) { defer func() { if recover() != nil { out = Redacted } }() out = input out = reAuthHeader.ReplaceAllStringFunc(out, func(match string) string { m := reAuthPrefix.FindStringSubmatch(match) if m != nil { return m[1] + " " + Redacted } return Redacted }) out = reSecretAssign.ReplaceAllStringFunc(out, func(match string) string { m := reAssignPrefix.FindStringSubmatch(match) if m != nil { return m[1] + Redacted } return Redacted }) out = reStripe.ReplaceAllString(out, Redacted) out = reOpenAI.ReplaceAllString(out, Redacted) out = reJWT.ReplaceAllString(out, Redacted) out = reEmail.ReplaceAllString(out, Redacted) out = reDSN.ReplaceAllString(out, "${1}"+Redacted+"@") return out } // ReplaceAttr is an slog.HandlerOptions.ReplaceAttr that redacts string attribute values and messages. func ReplaceAttr(_ []string, a slog.Attr) slog.Attr { switch a.Value.Kind() { case slog.KindString: a.Value = slog.StringValue(String(a.Value.String())) case slog.KindAny: if err, ok := a.Value.Any().(error); ok && err != nil { a.Value = slog.StringValue(String(err.Error())) } } if a.Key == slog.MessageKey && a.Value.Kind() == slog.KindString { a.Value = slog.StringValue(String(a.Value.String())) } return a } // NewJSONHandler returns a JSON slog handler that redacts PII/secrets. func NewJSONHandler(w io.Writer, opts *slog.HandlerOptions) slog.Handler { if opts == nil { opts = &slog.HandlerOptions{} } copied := *opts prev := copied.ReplaceAttr copied.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr { if prev != nil { a = prev(groups, a) } return ReplaceAttr(groups, a) } return slog.NewJSONHandler(w, &copied) } // Writer wraps an io.Writer so stdlib log output is redacted. func Writer(w io.Writer) io.Writer { if w == nil { w = os.Stderr } return &redactWriter{w: w} } type redactWriter struct { mu sync.Mutex w io.Writer } func (r *redactWriter) Write(p []byte) (int, error) { r.mu.Lock() defer r.mu.Unlock() cleaned := String(string(p)) if _, err := r.w.Write([]byte(cleaned)); err != nil { return 0, err } // Report original length so log.Logger does not retry/truncate oddly. return len(p), nil }