Log SQL with placeholders, never bound values (closes #207)
All checks were successful
check / check (push) Successful in 3m39s

With DEBUG=true the GORM adapter logged fully interpolated statements.
On a first boot that put two secrets in the log: the INSERT into
settings carrying the base64 session encryption key -- which is the
whole of the session security model, since anyone holding it can forge
an authenticated session cookie -- and the INSERT into users carrying
the admin account's Argon2id password hash. Debug logs get pasted into
issues and chats.

internal/gormlog.Logger now implements gorm.ParamsFilter and discards
the bound values, so GORM renders the statement with its placeholders
intact instead of substituting them in. This is unconditional rather
than a denylist of tables known to hold a secret: a table added later
is covered without anyone remembering to add it, and the cost of
missing one is a credential in a log. It applies at every level,
including the routine arm an operator reaches at DEBUG, which is the
only level at which a successful INSERT is written at all.

Truncation was never a fix for this. The session key is 44 base64
characters and an Argon2id hash under 100, so both fit inside every
budget the adapter applies; a truncated secret is still a secret.

internal/gormlog/firstboot_test.go boots the real graph -- config.New
reading DEBUG from the environment, internal/logger building its
production handler, database.New migrating and creating the admin
user, session.New taking the session key -- against an empty DATA_DIR,
captures stdout, and asserts that neither the session key nor the
password hash appears in it. It reads both secrets back out of the
SQLite file afterwards, so the assertions are made against the values
that boot actually generated. Three requires guard against vacuity:
the capture has to contain a DEBUG line and both INSERTs, or the
absence of the secrets proves nothing. values_test.go pins the same
property per arm of Trace, and that an INSERT keeps one placeholder
per value it bound.

Removing the filter fails all three new tests.

README documents what DEBUG=true does and does not expose, including
the one secret still logged in the clear on purpose: the initial admin
password, at INFO, once, because that line is the only place an
operator ever sees it.
This commit is contained in:
2026-08-20 04:15:49 +00:00
parent a13e5b7ded
commit 31228608d1
6 changed files with 807 additions and 17 deletions

View File

@@ -256,6 +256,51 @@ On first startup, webhooker creates an `admin` user
with a randomly generated password and logs it to stdout. This password
is only displayed once.
#### What `DEBUG=true` exposes
`DEBUG=true` lowers the log level to `DEBUG`, which turns on every
statement GORM runs, the two by-design lookup misses on the
unauthenticated routes, and the rate limiter's own rejections. It is
meant to be safe to turn on while diagnosing a live service and safe to
paste the output of into a bug report.
What it does **not** put in the log:
- **Values bound to a SQL statement.** Statements are logged with their
placeholders, never with the values substituted into them, at every
level. That is what keeps the session encryption key out of the first
boot's `INSERT INTO settings` and the `admin` account's Argon2id
password hash out of its `INSERT INTO users` — the two statements
that made a debug log worth stealing. It applies to every table and
every statement rather than to a list of tables known to hold a
secret, so a table added later is covered without anyone remembering
to add it. The cost is that a failing statement can no longer be
replayed from the log alone: the statement, the table, the driver
error and the row count are all still there, but its values have to
come from the database.
`internal/gormlog/firstboot_test.go` boots the real graph with
`DEBUG=true` against an empty `DATA_DIR` and asserts that neither
secret appears in what that boot wrote to stdout.
The one exception is `(*gorm.DB).Scan`, which GORM logs through its
own trace recorder rather than through this filter. No production
code path calls it, and `internal/gormlog/scan_guard_test.go` fails
if a non-test file adds one.
- **Session cookies, API keys or target credentials.** None of these is
logged at any level.
What is in the log regardless of `DEBUG`, and is not a debug-logging
decision:
- **The initial `admin` password**, in the clear, once, at `INFO`, on
the first boot that creates the account. That line is the only place
it is ever shown; the database stores the hash. A first boot's output
is not safe to paste anywhere until that account's password has been
changed.
- **An authenticated operator's own configuration**, echoed back
untruncated — webhook names, target hostnames. See the logging
section under Security for the full list and for the per-line size
bound that covers unauthenticated traffic.
### Running with Docker
```bash
@@ -1418,6 +1463,24 @@ and the driver error — against a smaller fixed portion than the access
log's, and `internal/gormlog/gormlog_test.go` asserts each line against
`MaxAccessLogLineBytes` directly rather than leaving it as arithmetic.
The adapter also logs no bound value at all: it implements
`gorm.ParamsFilter` and discards the parameters, so GORM renders the
statement with its placeholders intact instead of substituting the
values into it. That is a separate property from the size bound and it
is what a bound is no substitute for — the session encryption key is 44
base64 characters and an Argon2id hash under 100, so both fit inside
every budget above and a truncated secret is still a secret. It holds
on all three arms of `Trace`, including the routine one an operator
reaches at `DEBUG`, which is the only level at which a successful
`INSERT` is written at all. One GORM path does not consult the filter —
`(*gorm.DB).Scan`, which records the statement through GORM's own trace
recorder. No production code path calls it; its one caller is
`internal/database/database_test.go:91`, whose `SELECT 1` binds
nothing, and `internal/gormlog/scan_guard_test.go` fails if a non-test
file calls it. `Pluck`, `Row` and `Raw` all run through the normal
callback processor and are filtered.
See `#### What DEBUG=true exposes` under Configuration.
What that ceiling does **not** cover, stated here so the figure is not
read as more than it is: