635 lines
19 KiB
Go
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)
|
|
}
|