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.
261 lines
6.1 KiB
Go
261 lines
6.1 KiB
Go
package gormlog_test
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// minNonTestFiles guards the walk below against passing because it
|
|
// found nothing to look at. The tree held 60 non-test .go files when
|
|
// this was written.
|
|
const minNonTestFiles = 40
|
|
|
|
// isRowProducer reports whether name is a method that returns a
|
|
// database/sql row handle. GORM's Row and Rows return *sql.Row and
|
|
// *sql.Rows, so Scan on the result of one of them is database/sql's
|
|
// Scan and never (*gorm.DB).Scan.
|
|
func isRowProducer(name string) bool {
|
|
switch name {
|
|
case "Row", "Rows", "QueryRow", "QueryRowContext":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// receiverIsRowHandle reports whether x is syntactically a call to a
|
|
// row producer, which is the only receiver form this check accepts for
|
|
// a Scan.
|
|
func receiverIsRowHandle(x ast.Expr) bool {
|
|
call, ok := x.(*ast.CallExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
return isRowProducer(sel.Sel.Name)
|
|
}
|
|
|
|
// unguardedScans returns the position of every Scan call in file whose
|
|
// receiver is not a row handle. It fails closed: a receiver it cannot
|
|
// resolve syntactically — a local variable, a struct field — is
|
|
// reported rather than assumed safe.
|
|
func unguardedScans(
|
|
fset *token.FileSet, file *ast.File,
|
|
) []token.Position {
|
|
var found []token.Position
|
|
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok || sel.Sel.Name != "Scan" {
|
|
return true
|
|
}
|
|
|
|
if !receiverIsRowHandle(sel.X) {
|
|
found = append(found, fset.Position(sel.Sel.Pos()))
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
return found
|
|
}
|
|
|
|
// moduleRoot walks up from the working directory to the directory
|
|
// holding go.mod.
|
|
func moduleRoot(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
|
|
for {
|
|
_, statErr := os.Stat(filepath.Join(dir, "go.mod"))
|
|
if statErr == nil {
|
|
return dir
|
|
}
|
|
|
|
parent := filepath.Dir(dir)
|
|
require.NotEqual(t, parent, dir, "no go.mod above %s", dir)
|
|
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// skipDir reports whether a directory holds no source this check
|
|
// governs.
|
|
func skipDir(name string) bool {
|
|
switch name {
|
|
case ".git", "bin", "node_modules", "testdata":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// walkNonTestGo parses every non-test .go file under root and returns
|
|
// how many it parsed along with every unguarded Scan it found.
|
|
func walkNonTestGo(t *testing.T, root string) (int, []string) {
|
|
t.Helper()
|
|
|
|
var (
|
|
parsed int
|
|
hits []string
|
|
)
|
|
|
|
fset := token.NewFileSet()
|
|
|
|
require.NoError(t, filepath.WalkDir(
|
|
root,
|
|
func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if d.IsDir() {
|
|
if skipDir(d.Name()) {
|
|
return fs.SkipDir
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if !isNonTestGo(d.Name()) {
|
|
return nil
|
|
}
|
|
|
|
file, err := parser.ParseFile(fset, path, nil, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
parsed++
|
|
|
|
for _, pos := range unguardedScans(fset, file) {
|
|
hits = append(hits, relPosition(root, pos))
|
|
}
|
|
|
|
return nil
|
|
},
|
|
))
|
|
|
|
return parsed, hits
|
|
}
|
|
|
|
// isNonTestGo reports whether a file name is Go source this check
|
|
// governs.
|
|
func isNonTestGo(name string) bool {
|
|
return strings.HasSuffix(name, ".go") &&
|
|
!strings.HasSuffix(name, "_test.go")
|
|
}
|
|
|
|
// relPosition renders pos with its path relative to root, so a failure
|
|
// names the file the way the repository does.
|
|
func relPosition(root string, pos token.Position) string {
|
|
name := pos.Filename
|
|
|
|
rel, err := filepath.Rel(root, name)
|
|
if err == nil {
|
|
name = rel
|
|
}
|
|
|
|
return fmt.Sprintf("%s:%d:%d", name, pos.Line, pos.Column)
|
|
}
|
|
|
|
// TestGormScanIsNeverCalledOutsideTests keeps (*gorm.DB).Scan out of
|
|
// non-test code.
|
|
//
|
|
// It is the one statement path (*Logger).ParamsFilter does not reach:
|
|
// Scan swaps GORM's own trace recorder in for the adapter, and that
|
|
// recorder does not implement gorm.ParamsFilter, so the statement is
|
|
// logged with its values interpolated. The package comment states the
|
|
// limit; this fails when someone adds a call site anyway.
|
|
//
|
|
// The current tree has one caller, internal/database/database_test.go,
|
|
// which this check does not govern: it is test-only and its SELECT 1
|
|
// binds nothing.
|
|
func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
parsed, offenders := walkNonTestGo(t, moduleRoot(t))
|
|
|
|
require.GreaterOrEqual(
|
|
t, parsed, minNonTestFiles,
|
|
"parsed %d non-test .go files, so this check found "+
|
|
"nothing to look at", parsed,
|
|
)
|
|
require.Empty(
|
|
t, offenders,
|
|
"Scan called on a receiver this check cannot show is a "+
|
|
"database/sql row handle. (*gorm.DB).Scan logs the "+
|
|
"statement with its bound values interpolated — use "+
|
|
"Find, Pluck, or Raw(...).Row().Scan instead. A "+
|
|
"database/sql Scan reached through a variable is "+
|
|
"reported too; write it as <producer>().Scan rather "+
|
|
"than widening this check.",
|
|
)
|
|
}
|
|
|
|
// scanGuardCase is one planted snippet and whether the check above
|
|
// should report it.
|
|
type scanGuardCase struct {
|
|
name string
|
|
body string
|
|
want int
|
|
}
|
|
|
|
func scanGuardCases() []scanGuardCase {
|
|
return []scanGuardCase{
|
|
{"gorm chain", `db.DB().Raw("SELECT 1").Scan(&v)`, 1},
|
|
{"gorm receiver", `gdb.Scan(&v)`, 1},
|
|
{"gorm via variable", "q := gdb.Raw(\"x\")\nq.Scan(&v)", 1},
|
|
{"gorm model chain", `gdb.Model(&x).Scan(&v)`, 1},
|
|
{"sql row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
|
|
{"sql rows", `gdb.Raw("SELECT 1").Rows().Scan(&v)`, 0},
|
|
{"unrelated call", `gdb.Find(&v)`, 0},
|
|
}
|
|
}
|
|
|
|
// TestScanGuard_ReportsPlantedCalls proves the check fires. Without it
|
|
// a detector that matched nothing would satisfy the walk above no
|
|
// matter what the tree contained.
|
|
func TestScanGuard_ReportsPlantedCalls(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range scanGuardCases() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fset := token.NewFileSet()
|
|
src := fmt.Sprintf(
|
|
"package p\n\nfunc f() {\n\t%s\n}\n", tc.body,
|
|
)
|
|
|
|
file, err := parser.ParseFile(
|
|
fset, tc.name+".go", src, 0,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, unguardedScans(fset, file), tc.want)
|
|
})
|
|
}
|
|
}
|