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>
122 lines
3.3 KiB
Go
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
|
|
}
|