oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1// Package logtest is §12 gate 9, "no-content-in-logs": it proves, by
2// capturing what the app actually logs, that no address, team name or
3// queue payload reaches a log line.
4//
5// The rule is the design spec's §3.4: "no address, team name, or queue
6// payload in any log line, error string, job status message or metric
7// label. Errors carry person refs and codes. Test-enforced (§12 gate
8// 9)." The reasoning is that logs are storage, and storage we do not
9// control is storage we cannot redact — AGENTS.md's own phrasing is
10// "Platform-held logs are storage we cannot grep."
11//
12// Cite §3.4 rather than AGENTS.md for the four-sink list: AGENTS.md on
13// this branch still names three, and gains the fourth only when
14// fix/agents-md-matches-spec lands.
15//
16// # What it does not cover
17//
18// Named in full, because "gate 9 is done" would otherwise be believed.
19// §3.4 lists four sinks — log line, error string, job status message,
20// metric label — and this covers one and a half of them.
21//
22// - Log lines: only those routed through slog. A sink that writes to
23// os.Stdout or os.Stderr directly is invisible here. chi's own
24// middleware.Logger is exactly such a sink (it builds
25// log.New(os.Stdout, ...) at init), which is why
26// internal/guardstest forbids importing it.
27// - Error strings: only transitively, when an error is logged. An
28// error string rendered into an HTTP response is not scanned.
29// - Job status messages: not covered. There is no queue yet; when
30// there is, rastrillo's jobs package turns err.Error() into a
31// status message the owner sees, and that needs its own assertion.
32// - Metric labels: not covered. This app exports no metrics.
33//
34// Also uncovered: cmd/home/main.go's two logger.Error calls, which run
35// outside any handler with no seam to drive from a test; and anything
36// logged by rastrillo's own machinery, since this drives the app router
37// directly rather than rastrillo's handler chain. Rastrillo resolves
38// slog.Default() at CONSTRUCTION, so swapping the default afterwards
39// does not capture a logger it already holds.
40//
41// # Why it passes trivially today
42//
43// The skeleton holds no content and takes no user input, so there is
44// nothing yet to leak. Its value is as a tripwire for §13 step 2: the
45// day sign-in logs an address, this fails. §13 step 1 lists gate 9
46// alongside the skeleton for exactly that reason — a guard written
47// after the code it guards is a guard written to pass.
48//
49// AssertNoContent takes a *testing.T, so this package must never be
50// imported from non-test code: doing so would drag the testing package
51// into the production binary. internal/sheets/sealtest carries the same
52// warning for the same reason.
53package logtest
54
55import (
56 "context"
57 "encoding/base64"
58 "fmt"
59 "log/slog"
60 "math"
61 "net/url"
62 "strconv"
63 "strings"
64 "sync"
65 "testing"
66)
67
68// Canaries are the strings a test seeds into the app before asserting.
69// Any of them reaching a log record — as the message, as an attribute
70// value, or nested inside a group — is a content leak.
71//
72// One per sink named in §3.4. They are deliberately unlike anything the
73// app would otherwise log, so a match is a leak and never a coincidence.
74var Canaries = []string{
75 "CANARY-ADDRESS-a41f@example.invalid",
76 "CANARY-TEAM-NAME-7c2e",
77 "CANARY-QUEUE-PAYLOAD-93bd",
78}
79
80// Recorder collects everything logged through the handler it hands out.
81// The zero value is not usable; call NewRecorder.
82type Recorder struct {
83 mu sync.Mutex
84 buf strings.Builder
85}
86
87// NewRecorder returns a Recorder ready to hand out handlers.
88func NewRecorder() *Recorder { return &Recorder{} }
89
90// Handler returns a slog.Handler that records into r. Install it with
91// slog.New(r.Handler()).
92//
93// It fans every record out through a real slog.TextHandler AND a real
94// slog.JSONHandler, and scans what they produce.
95//
96// This is the third shape of this function and the first that is not a
97// guess. The first two hand-wrote a value renderer and tried to
98// enumerate the ways a value prints differently in production than
99// through slog.Value.String — first the []byte type, then a reflect
100// kind check plus encoding.TextMarshaler plus json.Marshaler. Both
101// were incomplete, and the second was incomplete in ten ways: any
102// struct with an exported field and a redacting String(), a slice or
103// map of such values, a *string (fmt prints the pointer, JSON prints
104// the string), a value whose MarshalText FAILS (both stock handlers
105// print the error text, which contains the value), a fmt.Formatter
106// that says more under %+v than %v, and so on. JSONHandler does not
107// call MarshalJSON — it calls json.Marshal on the whole value, which
108// walks exported fields, elements and map values to any depth and
109// honours MarshalText at every one of them. That is not a list anyone
110// can finish by hand.
111//
112// So do not try. The production sinks are these two handlers; run the
113// record through them and scan their bytes. It cannot drift from the
114// standard library, it needs no maintenance when a new interface is
115// honoured, and it inherits slog's own panic recovery — a MarshalText
116// that panics prints "!PANIC: …" here exactly as it would in
117// production, rather than taking the test binary down.
118//
119// One precision: production is slog's own default handler, since
120// cmd/home/main.go never configures one and rastrillo falls back to
121// slog.Default(). That handler renders values the way TextHandler does,
122// so scanning both handlers is a superset of what production emits, not
123// a different set.
124func (r *Recorder) Handler() slog.Handler {
125 // Level is set below every real level so nothing is filtered. A
126 // handler that filtered by level would let a canary logged at Debug
127 // pass the gate by never being formatted — the gate would succeed
128 // by not looking.
129 opts := &slog.HandlerOptions{Level: slog.Level(math.MinInt32)}
130 w := recorderWriter{rec: r}
131 return &handler{
132 rec: r,
133 text: slog.NewTextHandler(w, opts),
134 json: slog.NewJSONHandler(w, opts),
135 }
136}
137
138// recorderWriter serialises both stock handlers into one buffer. Each
139// handler holds its own mutex, so they do not exclude each other.
140type recorderWriter struct{ rec *Recorder }
141
142func (w recorderWriter) Write(p []byte) (int, error) {
143 w.rec.mu.Lock()
144 defer w.rec.mu.Unlock()
145 return w.rec.buf.Write(p)
146}
147
148// Text returns every record captured so far.
149func (r *Recorder) Text() string {
150 r.mu.Lock()
151 defer r.mu.Unlock()
152 return r.buf.String()
153}
154
155func (r *Recorder) write(line string) {
156 r.mu.Lock()
157 defer r.mu.Unlock()
158 r.buf.WriteString(line)
159 r.buf.WriteByte('\n')
160}
161
162// found returns the first canary present in what was captured, or "".
163//
164// It is the detection primitive, kept separate from AssertNoContent so
165// this package's own tests can drive a deliberate leak: a failing
166// subtest always fails its parent, so a scanner exercised only through
167// a live *testing.T could never be proven to detect anything while
168// leaving the suite green.
169func (r *Recorder) found() string {
170 text := r.Text()
171 lower := strings.ToLower(text)
172 for _, c := range Canaries {
173 for _, v := range encodings(c) {
174 if strings.Contains(text, v) || strings.Contains(lower, strings.ToLower(v)) {
175 return c
176 }
177 }
178 }
179 return ""
180}
181
182// encodings returns the forms a canary can legitimately take on its way
183// into a log line. A transformed leak is still a leak.
184//
185// Percent-encoding is not hypothetical: a request logger that prints
186// r.URL, r.RequestURI or RawQuery — the usual first thing anyone adds —
187// logs an address from a query string as CANARY%40example.invalid, and
188// a scan for the raw form misses it. Case-folding matters because an
189// address is routinely lowercased before it is hashed, so the lowercased
190// copy is the one likeliest to reach an error string.
191//
192// strconv.Quote covers what TextHandler does to a value that needs
193// quoting. (JSONHandler escapes the same characters for the forms these
194// canaries take; the two differ only on control characters, which none
195// of them carry.)
196//
197// The base64 and decimal-byte forms are there because a []byte NESTED in
198// something — a struct field, a slice element, a map value — is printed
199// by JSONHandler as base64 and by TextHandler as "[67 65 78 …]", and
200// neither resembles the canary. A job struct carrying a Payload []byte
201// and logged whole with slog.Any is more likely than logging the bare
202// slice, and the queue payload is the sink this package singled out as
203// most likely to arrive as bytes.
204func encodings(c string) []string {
205 q := strconv.Quote(c)
206 b := []byte(c)
207 return []string{
208 c,
209 url.QueryEscape(c),
210 url.PathEscape(c),
211 q[1 : len(q)-1],
212 base64.StdEncoding.EncodeToString(b),
213 base64.RawStdEncoding.EncodeToString(b),
214 base64.URLEncoding.EncodeToString(b),
215 fmt.Sprint(b),
216 }
217}
218
219// AssertNoContent fails t if any canary reached a log record, naming
220// the canary and dumping everything captured so the offending line is
221// visible without a second run.
222func (r *Recorder) AssertNoContent(t *testing.T) {
223 t.Helper()
224 if c := r.found(); c != "" {
225 t.Fatalf("gate 9: %q reached a log line.\ncaptured:\n%s", c, r.Text())
226 }
227}
228
229// handler fans one record out to both stock handlers, and records open
230// group names alongside.
231type handler struct {
232 rec *Recorder
233 text slog.Handler
234 json slog.Handler
235 groups []string
236}
237
238// Enabled answers true at every level, for the reason Handler gives.
239func (h *handler) Enabled(context.Context, slog.Level) bool { return true }
240
241func (h *handler) Handle(ctx context.Context, r slog.Record) error {
242 // Open group names are written explicitly. Both stock handlers
243 // prefix keys with them — "acme.rows=3" — so they are usually
244 // captured anyway, but slog drops a group with no attributes under
245 // it, and a group name is caller-supplied: WithGroup(team.Slug) is
246 // a plausible line to write. Writing it costs a string.
247 for _, g := range h.groups {
248 h.rec.write(g)
249 }
250 if err := h.text.Handle(ctx, r); err != nil {
251 return err
252 }
253 // A slog.Record is re-iterable, so handing the same one to a second
254 // handler is sound.
255 return h.json.Handle(ctx, r)
256}
257
258func (h *handler) WithAttrs(as []slog.Attr) slog.Handler {
259 return &handler{
260 rec: h.rec,
261 text: h.text.WithAttrs(as),
262 json: h.json.WithAttrs(as),
263 groups: append([]string{}, h.groups...),
264 }
265}
266
267func (h *handler) WithGroup(name string) slog.Handler {
268 if name == "" {
269 return h
270 }
271 return &handler{
272 rec: h.rec,
273 text: h.text.WithGroup(name),
274 json: h.json.WithGroup(name),
275 groups: append(append([]string{}, h.groups...), name),
276 }
277}
278