Files

113 lines
3.5 KiB
Go
Raw Permalink Normal View History

// 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
}