Files
2026-09-07 19:28:31 +01:00

635 lines
19 KiB
Go

// Package sqlguard provides a small AST-based check for SQL constructs that
// are unsafe across Kandev's SQLite and PostgreSQL engines.
package sqlguard
import (
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// Rule identifies one SQL portability rule.
type Rule string
const (
RuleSQLiteCatalog Rule = "sqlite-catalog"
RuleConflictSyntax Rule = "conflict-syntax"
RuleRawPlaceholder Rule = "raw-placeholder"
RuleBooleanInteger Rule = "boolean-integer"
RuleSQLiteDateFunction Rule = "sqlite-date-function"
RuleDateTimeType Rule = "datetime-type"
)
var knownRules = map[Rule]string{
RuleSQLiteCatalog: "SQLite catalog or PRAGMA syntax",
RuleConflictSyntax: "SQLite conflict syntax",
RuleRawPlaceholder: "raw question-mark placeholder",
RuleBooleanInteger: "integer literal for a SQL boolean column",
RuleSQLiteDateFunction: "SQLite-only date function",
RuleDateTimeType: "direct DATETIME type",
}
// Exemption allows one exact source file, symbol, and rule to use an
// intentional SQLite-only construct.
type Exemption struct {
File string `json:"file"`
Symbol string `json:"symbol"`
Rule Rule `json:"rule"`
Reason string `json:"reason"`
}
// Finding is one portability violation.
type Finding struct {
File string
Line int
Column int
Symbol string
Rule Rule
Message string
}
var (
sqliteCatalogPattern = regexp.MustCompile(`(?i)\b(?:sqlite_master|sqlite_schema|pragma(?:_[a-z0-9_]+)?)\b`)
conflictPattern = regexp.MustCompile(`(?i)\bINSERT\s+OR\s+IGNORE\b`)
booleanIntegerPattern = regexp.MustCompile(`(?is)\bBOOLEAN\b.{0,100}?\bDEFAULT\s+[01]\b`)
// These names are the boolean columns used by the built-in SQL schemas.
// The list is deliberately explicit: fields such as status, priority, and
// is_ephemeral are integer state values, not SQL BOOLEAN columns.
booleanComparisonPattern = regexp.MustCompile(`(?i)\b(?:enabled|is_enabled|active|is_active|hidden|is_hidden|deleted|is_deleted|builtin|is_builtin|installed|is_installed|last_ok|poll_enabled|user_modified|cli_passthrough|auto_approve)\b\s*(?:<>|!=|<=|>=|=|<|>)\s*[01]\b`)
sqliteDatePattern = regexp.MustCompile(`(?i)\b(?:date|datetime|strftime|julianday)\s*\(`)
datetimeTypePattern = regexp.MustCompile(`(?i)(?:^|[\s(,])DATETIME(?:\s|[,);]|$)`)
pragmaPattern = regexp.MustCompile(`(?i)\bpragma(?:_[a-z0-9_]+)?\b`)
sqlPattern = regexp.MustCompile(`(?i)\b(?:SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|WITH)\b`)
)
type analysisResult struct {
findings []Finding
used map[string]struct{}
}
// AnalyzeSource checks one Go source file. filename participates in exact
// exemption matching and should be the repository-relative path used by the
// caller.
func AnalyzeSource(filename string, source []byte, exemptions []Exemption) ([]Finding, error) {
if err := ValidateExemptions(exemptions); err != nil {
return nil, err
}
result, err := analyzeSource(filename, source, exemptions)
if err != nil {
return nil, err
}
return result.findings, nil
}
func analyzeSource(filename string, source []byte, exemptions []Exemption) (analysisResult, error) {
fileSet := token.NewFileSet()
file, err := parser.ParseFile(fileSet, filename, source, 0)
if err != nil {
return analysisResult{}, fmt.Errorf("parse %s: %w", filename, err)
}
parents := parentNodes(file)
values := topLevelStringValues(file)
result := analysisResult{used: make(map[string]struct{})}
analyzeStringLiterals(filename, file, fileSet, parents, exemptions, &result)
analyzeExecutorCalls(filename, file, fileSet, parents, values, exemptions, &result)
return result, nil
}
func analyzeStringLiterals(
filename string,
file *ast.File,
fileSet *token.FileSet,
parents map[ast.Node]ast.Node,
exemptions []Exemption,
result *analysisResult,
) {
seen := make(map[string]struct{})
ast.Inspect(file, func(node ast.Node) bool {
literal, ok := node.(*ast.BasicLit)
if !ok || literal.Kind != token.STRING {
return true
}
value, err := strconv.Unquote(literal.Value)
if err != nil {
return true
}
symbol := sourceSymbol(literal, parents)
analyzeSQLLiteral(filename, value, symbol, fileSet.Position(literal.Pos()), exemptions, seen, result)
return true
})
}
func analyzeSQLLiteral(
filename, value, symbol string,
position token.Position,
exemptions []Exemption,
seen map[string]struct{},
result *analysisResult,
forceSQL ...bool,
) {
sqlText := sqlPattern.MatchString(value)
if len(forceSQL) > 0 && forceSQL[0] {
sqlText = true
}
checks := []struct {
rule Rule
match bool
message string
}{
{RuleSQLiteCatalog, sqlText && sqliteCatalogPattern.MatchString(value), "SQLite catalog or PRAGMA syntax must stay behind a dialect boundary"},
{RuleConflictSyntax, sqlText && conflictPattern.MatchString(value), "use portable conflict syntax"},
{RuleBooleanInteger, sqlText && (booleanIntegerPattern.MatchString(value) || booleanComparisonPattern.MatchString(value)), "use a boolean value or a dialect-rendered boolean default"},
{RuleSQLiteDateFunction, sqlText && sqliteDatePattern.MatchString(value), "use an internal/db/dialect date helper"},
{RuleDateTimeType, sqlText && datetimeTypePattern.MatchString(value), "use a dialect-rendered timestamp type"},
}
for _, check := range checks {
if !check.match {
continue
}
key := findingKey(filename, symbol, check.rule, position.Line, position.Column)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
if matchesExemption(filename, symbol, check.rule, exemptions) {
result.used[exemptionKey(Exemption{File: filename, Symbol: symbol, Rule: check.rule})] = struct{}{}
continue
}
result.findings = append(result.findings, Finding{
File: position.Filename, Line: position.Line, Column: position.Column,
Symbol: symbol, Rule: check.rule, Message: check.message,
})
}
}
func analyzeExecutorCalls(
filename string,
file *ast.File,
fileSet *token.FileSet,
parents map[ast.Node]ast.Node,
values map[string]queryValue,
exemptions []Exemption,
result *analysisResult,
) {
seen := make(map[string]struct{})
scopes := make([]functionScope, 0, len(file.Decls))
for _, declaration := range file.Decls {
function, ok := declaration.(*ast.FuncDecl)
if !ok || function.Body == nil {
continue
}
scopes = append(scopes, functionScope{body: function.Body, params: function.Type.Params})
}
ast.Inspect(file, func(node ast.Node) bool {
if literal, ok := node.(*ast.FuncLit); ok {
scopes = append(scopes, functionScope{body: literal.Body, params: literal.Type.Params})
return false
}
return true
})
for _, scope := range scopes {
functionValues := cloneQueryValues(values)
for name := range localStringNames(scope.body) {
delete(functionValues, name)
}
for _, field := range scope.params.List {
for _, name := range field.Names {
delete(functionValues, name.Name)
}
}
ast.Inspect(scope.body, func(node ast.Node) bool {
// A function literal introduces its own parameter and assignment
// scope. It is analyzed separately when it is a top-level value;
// treating its parameters as the enclosing function's values would
// make an unrelated package-level query look executable here.
if literal, ok := node.(*ast.FuncLit); ok {
_ = literal
return false
}
switch node := node.(type) {
case *ast.ValueSpec:
recordStringValueSpec(node, functionValues)
case *ast.AssignStmt:
recordStringAssignments(node, functionValues)
case *ast.RangeStmt:
analyzeRangeSQLValues(node, functionValues)
case *ast.CallExpr:
analyzeExecutorCall(filename, node, fileSet, parents, functionValues, exemptions, seen, result)
}
return true
})
}
}
type functionScope struct {
body *ast.BlockStmt
params *ast.FieldList
}
func analyzeRangeSQLValues(
rangeStmt *ast.RangeStmt,
values map[string]queryValue,
) {
valueName, ok := rangeStmt.Value.(*ast.Ident)
if !ok {
return
}
composite, ok := rangeStmt.X.(*ast.CompositeLit)
if !ok {
return
}
// Keep one unsafe representative for the loop variable. The eventual
// executor call still decides whether that value is wrapped by Rebind, so
// a loop over raw query strings does not produce a false positive when the
// body correctly rebinds each query.
for _, element := range composite.Elts {
query, ok := resolveQueryValue(element, values)
if ok && !query.bound && rawPlaceholder(query.value) {
query.expression = element
values[valueName.Name] = query
return
}
}
}
func analyzeExecutorCall(
filename string,
call *ast.CallExpr,
fileSet *token.FileSet,
parents map[ast.Node]ast.Node,
values map[string]queryValue,
exemptions []Exemption,
seen map[string]struct{},
result *analysisResult,
) {
if !isExecutorCall(call) {
return
}
query, value, bound, ok := queryArgument(call, values)
if !ok || bound || isBoundQuery(query) {
return
}
if pragmaPattern.MatchString(value) {
position := fileSet.Position(query.Pos())
symbol := sourceSymbol(query, parents)
analyzeSQLLiteral(filename, value, symbol, position, exemptions, seen, result, true)
}
if rawPlaceholder(value) {
analyzeRawPlaceholder(filename, query, fileSet, parents, exemptions, seen, result)
}
}
func analyzeRawPlaceholder(
filename string,
query ast.Expr,
fileSet *token.FileSet,
parents map[ast.Node]ast.Node,
exemptions []Exemption,
seen map[string]struct{},
result *analysisResult,
) {
position := fileSet.Position(query.Pos())
symbol := sourceSymbol(query, parents)
key := findingKey(filename, symbol, RuleRawPlaceholder, position.Line, position.Column)
if _, exists := seen[key]; exists {
return
}
seen[key] = struct{}{}
if matchesExemption(filename, symbol, RuleRawPlaceholder, exemptions) {
result.used[exemptionKey(Exemption{File: filename, Symbol: symbol, Rule: RuleRawPlaceholder})] = struct{}{}
return
}
result.findings = append(result.findings, Finding{
File: position.Filename, Line: position.Line, Column: position.Column,
Symbol: symbol, Rule: RuleRawPlaceholder,
Message: "rebind SQL placeholders at the final database boundary",
})
}
// CheckFiles checks a fixed set of Go files and rejects exemptions that did not
// match a finding. Directory traversal belongs to the command, so callers
// cannot accidentally create an implicit directory-wide exemption.
func CheckFiles(files []string, exemptions []Exemption) ([]Finding, error) {
if err := ValidateExemptions(exemptions); err != nil {
return nil, err
}
findings := make([]Finding, 0)
used := make(map[string]struct{})
for _, filename := range files {
source, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read %s: %w", filename, err)
}
result, err := analyzeSource(filepath.ToSlash(filename), source, exemptions)
if err != nil {
return nil, err
}
findings = append(findings, result.findings...)
for key := range result.used {
used[key] = struct{}{}
}
}
for _, exemption := range exemptions {
if _, ok := used[exemptionKey(exemption)]; !ok {
return nil, fmt.Errorf("unused SQL guard exemption %s", exemptionKey(exemption))
}
}
return findings, nil
}
// ValidateExemptions rejects broad paths, unknown rules, missing reasons, and
// duplicate exact entries.
func ValidateExemptions(exemptions []Exemption) error {
seen := make(map[string]struct{}, len(exemptions))
for _, exemption := range exemptions {
if exemption.File == "" || filepath.IsAbs(exemption.File) || strings.ContainsAny(exemption.File, "*?") || strings.Contains(exemption.File, "..") {
return fmt.Errorf("SQL guard exemption file must be an exact relative path: %q", exemption.File)
}
if strings.Contains(exemption.File, "\\") || exemption.Symbol == "" || strings.ContainsAny(exemption.Symbol, "*?") {
return fmt.Errorf("SQL guard exemption must have an exact file and symbol: %q", exemption.File)
}
if _, ok := knownRules[exemption.Rule]; !ok {
return fmt.Errorf("SQL guard exemption has unknown rule %q", exemption.Rule)
}
if strings.TrimSpace(exemption.Reason) == "" {
return fmt.Errorf("SQL guard exemption %s has no reason", exemptionKey(exemption))
}
key := exemptionKey(exemption)
if _, ok := seen[key]; ok {
return fmt.Errorf("duplicate SQL guard exemption %s", key)
}
seen[key] = struct{}{}
}
return nil
}
// LoadExemptions loads the exact exemption registry from JSON.
func LoadExemptions(filename string) ([]Exemption, error) {
source, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read SQL guard exemptions: %w", err)
}
var document struct {
Exemptions []Exemption `json:"exemptions"`
}
if err := json.Unmarshal(source, &document); err != nil {
return nil, fmt.Errorf("parse SQL guard exemptions: %w", err)
}
if err := ValidateExemptions(document.Exemptions); err != nil {
return nil, err
}
return document.Exemptions, nil
}
func parentNodes(root ast.Node) map[ast.Node]ast.Node {
parents := make(map[ast.Node]ast.Node)
var walk func(ast.Node)
walk = func(parent ast.Node) {
ast.Inspect(parent, func(node ast.Node) bool {
if node == nil || node == parent {
return true
}
parents[node] = parent
walk(node)
return false
})
}
walk(root)
return parents
}
type queryValue struct {
expression ast.Expr
value string
bound bool
}
func topLevelStringValues(file *ast.File) map[string]queryValue {
values := make(map[string]queryValue)
for _, declaration := range file.Decls {
general, ok := declaration.(*ast.GenDecl)
if !ok {
continue
}
for _, specification := range general.Specs {
if valueSpec, ok := specification.(*ast.ValueSpec); ok {
recordStringValueSpec(valueSpec, values)
}
}
}
return values
}
func cloneQueryValues(values map[string]queryValue) map[string]queryValue {
clone := make(map[string]queryValue, len(values))
for name, value := range values {
clone[name] = value
}
return clone
}
func localStringNames(root ast.Node) map[string]struct{} {
names := make(map[string]struct{})
ast.Inspect(root, func(node ast.Node) bool {
if _, ok := node.(*ast.FuncLit); ok {
return false
}
switch node := node.(type) {
case *ast.ValueSpec:
for _, name := range node.Names {
names[name.Name] = struct{}{}
}
case *ast.AssignStmt:
for _, target := range node.Lhs {
if name, ok := target.(*ast.Ident); ok {
names[name.Name] = struct{}{}
}
}
}
return true
})
return names
}
func recordStringValueSpec(specification *ast.ValueSpec, values map[string]queryValue) {
for index, name := range specification.Names {
if index >= len(specification.Values) {
continue
}
if value, ok := resolveQueryValue(specification.Values[index], values); ok {
value.expression = specification.Values[index]
values[name.Name] = value
}
}
}
func recordStringAssignments(assignment *ast.AssignStmt, values map[string]queryValue) {
for index, target := range assignment.Lhs {
name, ok := target.(*ast.Ident)
if !ok || index >= len(assignment.Rhs) {
continue
}
if value, ok := resolveQueryValue(assignment.Rhs[index], values); ok {
value.expression = assignment.Rhs[index]
values[name.Name] = value
}
}
}
func resolveQueryValue(expression ast.Expr, values map[string]queryValue) (queryValue, bool) {
switch expression := expression.(type) {
case *ast.BasicLit:
return resolveBasicLiteral(expression)
case *ast.Ident:
return resolveIdentifier(expression, values)
case *ast.BinaryExpr:
return resolveBinaryQueryValue(expression, values)
case *ast.ParenExpr:
return resolveParenthesizedQueryValue(expression, values)
case *ast.CallExpr:
return resolveCallQueryValue(expression, values)
default:
return queryValue{}, false
}
}
func resolveBasicLiteral(expression *ast.BasicLit) (queryValue, bool) {
if expression.Kind != token.STRING {
return queryValue{}, false
}
value, err := strconv.Unquote(expression.Value)
return queryValue{expression: expression, value: value}, err == nil
}
func resolveIdentifier(expression *ast.Ident, values map[string]queryValue) (queryValue, bool) {
value, ok := values[expression.Name]
if ok {
value.expression = expression
}
return value, ok
}
func resolveBinaryQueryValue(expression *ast.BinaryExpr, values map[string]queryValue) (queryValue, bool) {
if expression.Op != token.ADD {
return queryValue{}, false
}
left, leftOK := resolveQueryValue(expression.X, values)
right, rightOK := resolveQueryValue(expression.Y, values)
if !leftOK || !rightOK {
return queryValue{}, false
}
return queryValue{expression: expression, value: left.value + right.value, bound: left.bound && right.bound}, true
}
func resolveParenthesizedQueryValue(expression *ast.ParenExpr, values map[string]queryValue) (queryValue, bool) {
value, ok := resolveQueryValue(expression.X, values)
if ok {
value.expression = expression
}
return value, ok
}
func resolveCallQueryValue(expression *ast.CallExpr, values map[string]queryValue) (queryValue, bool) {
if len(expression.Args) == 0 {
return queryValue{}, false
}
value, ok := resolveQueryValue(expression.Args[0], values)
if !ok {
return queryValue{}, false
}
switch functionName(expression.Fun) {
case "Rebind":
value.bound = true
case "In":
// sqlx.In expands '?' placeholders but does not rebind them to
// the target driver's syntax. It is safe only when wrapped by
// Rebind at the final database boundary.
value.bound = false
default:
return queryValue{}, false
}
value.expression = expression
return value, true
}
func sourceSymbol(node ast.Node, parents map[ast.Node]ast.Node) string {
for current := node; current != nil; current = parents[current] {
switch current := current.(type) {
case *ast.FuncDecl:
return current.Name.Name
case *ast.ValueSpec:
for _, name := range current.Names {
return name.Name
}
}
}
return "file"
}
func isExecutorCall(call *ast.CallExpr) bool {
switch functionName(call.Fun) {
case "Exec", "ExecContext", "Query", "QueryContext", "QueryRow", "QueryRowContext", "Get", "Select", "NamedExec", "Prepare", "PrepareContext":
return true
default:
return false
}
}
func queryArgument(call *ast.CallExpr, values map[string]queryValue) (ast.Expr, string, bool, bool) {
for _, argument := range call.Args {
resolved, ok := resolveQueryValue(argument, values)
if ok && (sqlPattern.MatchString(resolved.value) || pragmaPattern.MatchString(resolved.value)) {
return resolved.expression, resolved.value, resolved.bound, true
}
}
return nil, "", false, false
}
func isBoundQuery(expression ast.Expr) bool {
call, ok := expression.(*ast.CallExpr)
if !ok {
return false
}
name := functionName(call.Fun)
return name == "Rebind"
}
func functionName(expression ast.Expr) string {
switch expression := expression.(type) {
case *ast.Ident:
return expression.Name
case *ast.SelectorExpr:
return expression.Sel.Name
default:
return ""
}
}
func rawPlaceholder(value string) bool {
return strings.Contains(value, "?") && sqlPattern.MatchString(value)
}
func matchesExemption(filename, symbol string, rule Rule, exemptions []Exemption) bool {
for _, exemption := range exemptions {
if exemption.File == filename && exemption.Symbol == symbol && exemption.Rule == rule {
return true
}
}
return false
}
func exemptionKey(exemption Exemption) string {
return filepath.ToSlash(exemption.File) + ":" + exemption.Symbol + ":" + string(exemption.Rule)
}
func findingKey(filename, symbol string, rule Rule, line, column int) string {
return fmt.Sprintf("%s:%s:%s:%d:%d", filename, symbol, rule, line, column)
}