check-safety/rawsql.go
Alex Dunmow c4d01de82c feat: concise output + diff-scoped any check
Reporter is now a collector: checks declare a verdict (OK/Skip/Warn/Fail/
Fatal) plus findings, and a single central render() prints output. Default
output is silent on pass/skip — only FAIL/WARN/ERR checks print, followed by
one tally line, so a clean run is two lines. --verbose restores full per-check
output. All ~30 checks were converted to this API; orphaned guidance/label
helpers (printPerTargetOKLines, per-check *Help blocks, colors_format.go) were
removed.

The any-usage check (2e) now defaults to only the unstaged working-tree diff
(changed lines), via a new per-repo git-diff index in changedlines.go; --all-any
restores the full scan. Not-a-git-repo / no-diff warns on nothing.

Golden fixtures regenerated; integration tests updated to the new format; added
unit tests for the diff index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:16:56 +08:00

122 lines
3.3 KiB
Go

package main
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
)
type rawSQLViolation struct {
file string
line int
snippet string
}
// SQL keywords that indicate raw SQL usage
var sqlKeywordRe = regexp.MustCompile(`(?i)\b(INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM|SELECT\s+.+\s+FROM|ALTER\s+TABLE|DROP\s+TABLE|CREATE\s+TABLE)\b`)
// Directories where raw SQL is expected (migrations, sqlc queries, seeder, test fixtures)
func isRawSQLAllowed(path string) bool {
return strings.Contains(path, "/sql/") ||
strings.Contains(path, "/migrations/") ||
strings.Contains(path, "/internal/db/") ||
strings.Contains(path, "/seeder/") ||
strings.HasSuffix(path, "_test.go") ||
strings.Contains(path, "/cmd/admincli/") ||
strings.Contains(path, "/datasources/") || // Bob query builder files
strings.Contains(path, "internal/plugins/") // Plugin tables are not in core schema
}
// Specific SQL patterns that are allowed (postgres admin commands, not data queries)
var allowedSQLPatterns = []string{
"pg_terminate_backend", // backup_service.go — kills connections before restore
"FROM pg_database", // Administrative catalog lookup for tenant database existence
"FROM pg_roles", // role_provisioner.go — per-plugin role existence lookup (DCL, not sqlc-able)
}
func isSQLAllowed(sql string) bool {
for _, pattern := range allowedSQLPatterns {
if strings.Contains(sql, pattern) {
return true
}
}
return false
}
// checkRawSQL finds raw SQL string literals passed to Exec/Query methods outside allowed dirs.
func checkRawSQL(root string) []rawSQLViolation {
var violations []rawSQLViolation
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
if strings.Contains(path, "/vendor/") {
return nil
}
if isRawSQLAllowed(path) {
return nil
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
relPath, _ := filepath.Rel(root, path)
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Match method calls: *.ExecContext, *.QueryContext, *.QueryRowContext, *.Exec, *.Query, *.QueryRow
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
method := sel.Sel.Name
isDBMethod := method == "ExecContext" || method == "QueryContext" || method == "QueryRowContext" ||
method == "Exec" || method == "Query" || method == "QueryRow"
if !isDBMethod {
return true
}
// Check if any string argument contains SQL keywords
for _, arg := range call.Args {
lit, ok := arg.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
val := strings.Trim(lit.Value, "`\"")
if sqlKeywordRe.MatchString(val) && !isSQLAllowed(val) {
pos := fset.Position(call.Pos())
// Truncate the SQL for display
display := val
if len(display) > 80 {
display = display[:77] + "..."
}
violations = append(violations, rawSQLViolation{
file: relPath,
line: pos.Line,
snippet: display,
})
}
}
return true
})
return nil
}); err != nil {
violations = append(violations, rawSQLViolation{file: root, line: 0, snippet: err.Error()})
}
return violations
}