Log SQL with placeholders, never bound values (closes #207) (#222)
Some checks failed
check / check (push) Superseded by a newer commit; never tested
Some checks failed
check / check (push) Superseded by a newer commit; never tested
This commit was merged in pull request #222.
This commit is contained in:
@@ -17,6 +17,10 @@
|
||||
// level the operator controls, they are shaped by whichever handler
|
||||
// internal/logger selected, and every value a client can influence is
|
||||
// spent through logfield.Truncate.
|
||||
//
|
||||
// It also logs no bound value at all. See ParamsFilter: the statement
|
||||
// is written with its placeholders intact, at every level, so the
|
||||
// values a statement carries never reach the log in the first place.
|
||||
package gormlog
|
||||
|
||||
import (
|
||||
@@ -26,6 +30,7 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
"sneak.berlin/go/webhooker/internal/logfield"
|
||||
)
|
||||
@@ -47,8 +52,14 @@ type Logger struct {
|
||||
}
|
||||
|
||||
// Interface compliance is asserted here rather than discovered at the
|
||||
// gorm.Open call sites.
|
||||
var _ gormlogger.Interface = (*Logger)(nil)
|
||||
// gorm.Open call sites. gorm.ParamsFilter is the optional half: GORM
|
||||
// type-asserts for it and silently keeps interpolating if it is
|
||||
// missing, so losing it would cost no build error and no test that
|
||||
// does not look at the emitted SQL.
|
||||
var (
|
||||
_ gormlogger.Interface = (*Logger)(nil)
|
||||
_ gorm.ParamsFilter = (*Logger)(nil)
|
||||
)
|
||||
|
||||
// New returns a GORM logger that writes through log.
|
||||
func New(log *slog.Logger) *Logger {
|
||||
@@ -71,6 +82,46 @@ func (l *Logger) LogMode(gormlogger.LogLevel) gormlogger.Interface {
|
||||
return l
|
||||
}
|
||||
|
||||
// ParamsFilter drops every bound value before GORM renders a statement
|
||||
// for the log, so what is logged is the statement's shape — its
|
||||
// placeholders — and never the values in it.
|
||||
//
|
||||
// GORM builds the string it hands to Trace by calling
|
||||
// Dialector.Explain(sql, vars...), which substitutes each value into
|
||||
// the statement. Discarding vars here leaves the '?' placeholders in
|
||||
// place, because ExplainSQL only substitutes while it still has a
|
||||
// value for the next one. That happens before Trace is reached, so it
|
||||
// holds on all three of its arms: the failed statement, the slow one,
|
||||
// and the routine one an operator sees at DEBUG.
|
||||
//
|
||||
// This is the whole of the fix, and it is deliberately unconditional
|
||||
// rather than a list of tables to redact. At first boot the two
|
||||
// statements that carry a secret are the INSERT into settings holding
|
||||
// the base64 session key — which is the entire session security model,
|
||||
// since anyone with it can mint a valid cookie — and the INSERT into
|
||||
// users holding the Argon2id hash. A denylist would have had to be
|
||||
// extended by hand for every table added afterwards, and the cost of
|
||||
// missing one is a credential in a log that gets pasted into issues.
|
||||
//
|
||||
// What is given up is the ability to read a value out of the log. The
|
||||
// statement, the table, the error and the row count are all still
|
||||
// there, which is what identifies a failing statement; reproducing it
|
||||
// needs the values, and those an operator now gets from the database
|
||||
// rather than from the log.
|
||||
//
|
||||
// One GORM path does not consult this: (*gorm.DB).Scan records the
|
||||
// statement through gorm's own traceRecorder, which does not implement
|
||||
// this interface. No production code path calls it; its one caller is
|
||||
// internal/database/database_test.go:91, whose SELECT 1 binds nothing.
|
||||
// scan_guard_test.go fails if a non-test file calls it.
|
||||
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback
|
||||
// processor and are filtered.
|
||||
func (l *Logger) ParamsFilter(
|
||||
_ context.Context, sql string, _ ...any,
|
||||
) (string, []any) {
|
||||
return sql, nil
|
||||
}
|
||||
|
||||
// Info logs one of GORM's own informational messages.
|
||||
func (l *Logger) Info(
|
||||
ctx context.Context, msg string, data ...any,
|
||||
@@ -93,9 +144,9 @@ func (l *Logger) Error(
|
||||
}
|
||||
|
||||
// Trace reports the outcome of a single statement. GORM calls it for
|
||||
// every statement it runs, so the cheap paths stay cheap: fc()
|
||||
// renders the interpolated SQL and is called only on a branch that
|
||||
// will actually emit.
|
||||
// every statement it runs, so the cheap paths stay cheap: fc() renders
|
||||
// the statement — with placeholders, per ParamsFilter — and is called
|
||||
// only on a branch that will actually emit.
|
||||
//
|
||||
// The arms are ordered exactly as GORM's own Trace orders them —
|
||||
// non-record-not-found error, then slow, then the routine case — so
|
||||
|
||||
Reference in New Issue
Block a user