// Package sunscreen is a credential-redacting slog handler that keeps API // keys, tokens and passwords out of application logs. package sunscreen import ( "context" "fmt" "io" "log/slog" "reflect" "regexp" "slices" "strings" ) // Redacted replaces the value of a sensitive attribute and the secret part // of a scrubbed string. const Redacted = "[REDACTED]" var redactedKeys = []string{ "api_key", "apikey", "authorization", "bearer", "password", "secret", "token", "webhook_secret", "admin_password", "openai_api_key", "anthropic_api_key", "perplexity_api_key", } var keySet = func() map[string]struct{} { m := make(map[string]struct{}, len(redactedKeys)) for _, k := range redactedKeys { m[k] = struct{}{} } return m }() // RedactedKeys returns the attribute keys whose values are always replaced // with Redacted. Keys are compared case-insensitively, at any group depth. func RedactedKeys() []string { return slices.Clone(redactedKeys) } func sensitive(key string) bool { _, ok := keySet[strings.ToLower(key)] return ok } var patterns = []struct { re *regexp.Regexp repl string }{ {regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._\-+/=]+`), "Bearer " + Redacted}, // Also covers dashed keys such as sk-ant-..., which the reference // pattern sk-[A-Za-z0-9]{20,} misses. {regexp.MustCompile(`sk-[A-Za-z0-9_\-]{20,}`), "sk-" + Redacted}, {regexp.MustCompile(`(?i)x-api-key:\s*[^\s,]+`), "x-api-key: " + Redacted}, } // Scrub replaces credential shapes in s: "Bearer " becomes // "Bearer [REDACTED]", "sk-" followed by 20 or more key characters becomes // "sk-[REDACTED]", and "x-api-key: " becomes "x-api-key: [REDACTED]". // The Bearer and x-api-key matches are case-insensitive. func Scrub(s string) string { for _, p := range patterns { s = p.re.ReplaceAllString(s, p.repl) } return s } // Wrap returns a handler that redacts every record before next sees it: the // message is scrubbed; attributes whose key is one of RedactedKeys get the // value Redacted, at any group depth; LogValuer values are resolved first; // string values, error values and other formatted values are scrubbed; maps // with string keys are redacted key by key. Attributes added with WithAttrs // are redacted the same way before they reach next. func Wrap(next slog.Handler) slog.Handler { if h, ok := next.(*handler); ok { return h } return &handler{next: next} } // InstallDefault makes a redacting text handler writing to w the process // default logger (slog.SetDefault), so plugins that fall back to // slog.Default and the standard log package both log through it. It builds // a fresh slog.TextHandler instead of wrapping the existing default, whose // output goes through the log package that SetDefault redirects back here. func InstallDefault(w io.Writer) { slog.SetDefault(slog.New(Wrap(slog.NewTextHandler(w, nil)))) } type handler struct { next slog.Handler } func (h *handler) Enabled(ctx context.Context, level slog.Level) bool { return h.next.Enabled(ctx, level) } func (h *handler) Handle(ctx context.Context, r slog.Record) error { out := slog.NewRecord(r.Time, r.Level, Scrub(r.Message), r.PC) r.Attrs(func(a slog.Attr) bool { out.AddAttrs(redactAttr(a)) return true }) return h.next.Handle(ctx, out) } func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler { red := make([]slog.Attr, len(attrs)) for i, a := range attrs { red[i] = redactAttr(a) } return &handler{next: h.next.WithAttrs(red)} } func (h *handler) WithGroup(name string) slog.Handler { return &handler{next: h.next.WithGroup(name)} } func redactAttr(a slog.Attr) slog.Attr { if sensitive(a.Key) { return slog.String(a.Key, Redacted) } v := a.Value.Resolve() switch v.Kind() { case slog.KindGroup: group := v.Group() out := make([]slog.Attr, len(group)) for i, g := range group { out[i] = redactAttr(g) } return slog.Attr{Key: a.Key, Value: slog.GroupValue(out...)} case slog.KindString: return slog.String(a.Key, Scrub(v.String())) case slog.KindAny: return slog.Attr{Key: a.Key, Value: redactAny(v.Any())} default: return slog.Attr{Key: a.Key, Value: v} } } func redactAny(x any) slog.Value { switch t := x.(type) { case nil: return slog.AnyValue(nil) case error: return slog.StringValue(Scrub(t.Error())) case []byte: return slog.StringValue(Scrub(string(t))) } if m, ok := redactMap(x); ok { return slog.AnyValue(m) } text := fmt.Sprintf("%+v", x) if scrubbed := Scrub(text); scrubbed != text { return slog.StringValue(scrubbed) } return slog.AnyValue(x) } // redactMap copies a map with string keys (including http.Header and other // named map types), redacting sensitive keys and scrubbing the rest. func redactMap(x any) (map[string]any, bool) { rv := reflect.ValueOf(x) if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String { return nil, false } out := make(map[string]any, rv.Len()) iter := rv.MapRange() for iter.Next() { k := iter.Key().String() if sensitive(k) { out[k] = Redacted continue } val := iter.Value().Interface() switch t := val.(type) { case string: out[k] = Scrub(t) case []string: s := make([]string, len(t)) for i, e := range t { s[i] = Scrub(e) } out[k] = s default: out[k] = redactAny(val).Any() } } return out, true }