Log SQL with placeholders, never bound values (closes #207)
All checks were successful
check / check (push) Successful in 3m39s
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:
63
README.md
63
README.md
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user