Go · 4857 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package log builds the slog handler the rest of the binary uses.
4 //
5 // Output format:
6 // - "text" — human-friendly key=value lines (default in dev)
7 // - "json" — one JSON object per line (default in prod)
8 //
9 // Standard log fields contract (every line):
10 // - time, level, msg
11 // - request_id when a request is in flight
12 // - user_id when known (post-S05)
13 // - component set by the package emitting the line
14 // - error/stack on error-level lines
15 //
16 // Redaction: field values whose key matches a known secret pattern (token,
17 // password, key, dsn, authorization, otpauth) are rewritten to "***" before
18 // they hit the underlying handler.
19 package log
20
21 import (
22 "context"
23 "io"
24 "log/slog"
25 "regexp"
26 "strings"
27 )
28
29 // Options configures the handler.
30 type Options struct {
31 Level string // debug | info | warn | error
32 Format string // text | json
33 Writer io.Writer
34 }
35
36 // New returns a slog.Logger configured per opts. The redacting handler
37 // wraps the chosen base handler so every record is sanitised before output.
38 func New(opts Options) *slog.Logger {
39 if opts.Writer == nil {
40 opts.Writer = io.Discard
41 }
42 level := parseLevel(opts.Level)
43 handlerOpts := &slog.HandlerOptions{Level: level}
44
45 var base slog.Handler
46 switch strings.ToLower(opts.Format) {
47 case "json":
48 base = slog.NewJSONHandler(opts.Writer, handlerOpts)
49 default:
50 base = slog.NewTextHandler(opts.Writer, handlerOpts)
51 }
52 return slog.New(&redactHandler{base: base})
53 }
54
55 func parseLevel(s string) slog.Level {
56 switch strings.ToLower(s) {
57 case "debug":
58 return slog.LevelDebug
59 case "warn", "warning":
60 return slog.LevelWarn
61 case "error":
62 return slog.LevelError
63 default:
64 return slog.LevelInfo
65 }
66 }
67
68 // secretAttrKeys are case-insensitive substrings that mark an attribute key
69 // as a secret value to redact.
70 var secretAttrKeys = []string{
71 "password", "pass",
72 "secret",
73 "key",
74 "token",
75 "authorization",
76 "dsn",
77 "otpauth",
78 }
79
80 // secretValueMarkers are substrings that, if found anywhere in a string
81 // value, signal the line is leaking a token / URL credential / secret URI
82 // even if the attribute key itself looks innocent.
83 var secretValueMarkers = []string{
84 "shithub_pat_",
85 "otpauth://",
86 "Bearer ",
87 "Basic ",
88 }
89
90 // redactHandler wraps another slog.Handler, scrubbing matched attributes.
91 type redactHandler struct {
92 base slog.Handler
93 attrs []slog.Attr
94 group string
95 }
96
97 func (h *redactHandler) Enabled(ctx context.Context, level slog.Level) bool {
98 return h.base.Enabled(ctx, level)
99 }
100
101 func (h *redactHandler) Handle(ctx context.Context, r slog.Record) error {
102 scrubbed := slog.Record{
103 Time: r.Time,
104 Message: r.Message,
105 Level: r.Level,
106 PC: r.PC,
107 }
108 r.Attrs(func(a slog.Attr) bool {
109 scrubbed.AddAttrs(redactAttr(a))
110 return true
111 })
112 return h.base.Handle(ctx, scrubbed)
113 }
114
115 func (h *redactHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
116 scrubbed := make([]slog.Attr, len(attrs))
117 for i, a := range attrs {
118 scrubbed[i] = redactAttr(a)
119 }
120 return &redactHandler{base: h.base.WithAttrs(scrubbed), attrs: append(append([]slog.Attr{}, h.attrs...), scrubbed...), group: h.group}
121 }
122
123 func (h *redactHandler) WithGroup(name string) slog.Handler {
124 return &redactHandler{base: h.base.WithGroup(name), attrs: h.attrs, group: name}
125 }
126
127 func redactAttr(a slog.Attr) slog.Attr {
128 if shouldRedactKey(a.Key) {
129 if a.Value.Kind() == slog.KindString {
130 return slog.String(a.Key, "***")
131 }
132 }
133 if a.Value.Kind() == slog.KindString {
134 if v := redactValueIfSensitive(a.Value.String()); v != a.Value.String() {
135 return slog.String(a.Key, v)
136 }
137 }
138 return a
139 }
140
141 func shouldRedactKey(key string) bool {
142 lower := strings.ToLower(key)
143 for _, needle := range secretAttrKeys {
144 if strings.Contains(lower, needle) {
145 return true
146 }
147 }
148 return false
149 }
150
151 func redactValueIfSensitive(v string) string {
152 for _, marker := range secretValueMarkers {
153 if strings.Contains(v, marker) {
154 return "***"
155 }
156 }
157 if u := stripURLUserinfo(v); u != v {
158 return u
159 }
160 return v
161 }
162
163 // urlUserinfoRE matches the credentials portion of a URL of the form
164 // scheme://user:pass@host/path. We replace it with scheme://***@host/path
165 // so the host + path stay readable in logs (helps debugging) while the
166 // credentials are gone.
167 //
168 // The pattern is deliberately strict: scheme [a-z][a-z0-9+\-.]* / / a
169 // minimal user:pass containing no @ before the literal @host. Slack and
170 // other URL-detection regexes get this wrong all the time; we only need
171 // to handle URLs that come through Go's net/url emitter, which canonicalizes.
172 var urlUserinfoRE = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+\-.]*)://[^\s/@:]+:[^\s/@]+@`)
173
174 func stripURLUserinfo(s string) string {
175 if !strings.Contains(s, "://") || !strings.Contains(s, "@") {
176 return s
177 }
178 return urlUserinfoRE.ReplaceAllString(s, "$1://***@")
179 }
180