- Each module logs through diary.For(<package>): cabana, lagoon, attach, cooler, postcard, beachcomber, lighthouse, centrifugo, flare, phrasebook, wristband, bouncer - App-published *slog.Logger overrides stay first; messages and attributes unchanged - sunscreen: InstallDefault documented as the compatibility entry point; diary wraps every channel - READMEs list diary as a dependency; logging docs: plugins use diary.For
204 lines
5.4 KiB
Go
204 lines
5.4 KiB
Go
// 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 <token>" becomes
|
|
// "Bearer [REDACTED]", "sk-" followed by 20 or more key characters becomes
|
|
// "sk-[REDACTED]", and "x-api-key: <value>" 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 code that logs through 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.
|
|
//
|
|
// Generated applications no longer call it: they install the diary module,
|
|
// which wraps every log channel with Wrap. InstallDefault stays as the
|
|
// compatibility entry point for programs that install their own default
|
|
// logger.
|
|
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
|
|
}
|