diff --git a/CHANGELOG.md b/CHANGELOG.md index 6854383a..465ecc65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 benchmarks that publish diagnostics no longer time a growing buffer. ### Performance +- Security: `Scanner.Scan` walks the AST with a pooled struct visitor instead of a per-call closure and ancestor slice; `BenchmarkScanASTRW` B/op −53.27% to −58.31%, allocs/op −29% to −33% (M4, indicative). Findings are byte-identical (differential test against the previous implementation over the corpus); no API or behaviour change. [W6-02] - **Linter**: rule L007 (keyword case consistency) scans words without allocating (substring words, ASCII upper-casing into a stack buffer before the keyword lookup). Output is unchanged. On the canonical benchmarks (Apple M4, local), `BenchmarkLintRule/L007` allocates about 98% fewer objects (72565 to 1194 allocs/op) and about 97% fewer bytes, and `BenchmarkLintRW` allocates about 12.5% fewer objects. [W6-03] - **gosqlx**: errors returned by `Parse`, `Validate`, `Lint` and the tree helpers now wrap the sentinel and the underlying error in an unexported `wrapErr` that builds its message lazily, instead of `fmt.Errorf("%w: %w", ...)` on every failure. Error text, `errors.Is` and `errors.As` behaviour are unchanged; only the unexported concrete error type differs. The saving applies to callers that only check `errors.Is`/`errors.As` or drop the message: when the message is built (`err.Error()`, as the LSP and CLI do) the gain is about 2 allocs/op and 3-4% B/op per error (syntax_mid 21 -> 19 allocs). Because the message is now built on demand, `Error()` reflects any later mutation of the inner `*errors.Error` reached through `errors.As`; previously the text was fixed at creation. [W1-03] - **Linter L010**: scans bytes in a single pass instead of rebuilding diff --git a/pkg/sql/security/scan_walker.go b/pkg/sql/security/scan_walker.go new file mode 100644 index 00000000..f185ccad --- /dev/null +++ b/pkg/sql/security/scan_walker.go @@ -0,0 +1,133 @@ +package security + +import ( + "sync" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +const maxPooledScanDepth = 1024 + +// scanWalker is exclusively owned between acquire and release. Between scans, +// len(ancestors) == 0 and every slot in ancestors[:cap(ancestors)] is nil. +type scanWalker struct { + s *Scanner + result *ScanResult + ancestors []ast.Node +} + +var scanWalkerPool = sync.Pool{New: func() interface{} { + return &scanWalker{ancestors: make([]ast.Node, 0, 16)} +}} + +func (w *scanWalker) reset(s *Scanner, r *ScanResult) { + w.s, w.result = s, r + w.ancestors = w.ancestors[:0] +} + +func acquireScanWalker(s *Scanner, r *ScanResult) *scanWalker { + w, ok := scanWalkerPool.Get().(*scanWalker) + if !ok { + w = &scanWalker{ancestors: make([]ast.Node, 0, 16)} + } + w.reset(s, r) + return w +} + +func (w *scanWalker) release() (pooled bool) { + w.s, w.result = nil, nil + if cap(w.ancestors) > maxPooledScanDepth { + return false + } + w.ancestors = w.ancestors[:0] + scanWalkerPool.Put(w) + return true +} + +func (w *scanWalker) Visit(node ast.Node) (ast.Visitor, error) { + if w.visit(node) { + return w, nil + } + return nil, nil +} + +func (w *scanWalker) visit(node ast.Node) bool { + if node == nil { + w.ancestors[len(w.ancestors)-1] = nil + w.ancestors = w.ancestors[:len(w.ancestors)-1] + return false + } + var parent ast.Node + if len(w.ancestors) > 0 { + parent = w.ancestors[len(w.ancestors)-1] + } + if join, ok := parent.(*ast.JoinClause); ok && node == &join.Left { + // Left repeats FROM or an earlier join's right reference, + // including its subquery and table function. + return false + } + switch n := node.(type) { + case *ast.FunctionCall: + w.s.scanFunctionCall(n, w.result) + w.s.scanCondition(n.Filter, w.result) + case *ast.TableReference: + w.s.scanTableReference(n, w.result) + case *ast.SetOperation: + // Direct operands are already checked by the outer operation. + // Operations inside CTEs and subqueries start a new check. + if _, nested := parent.(*ast.SetOperation); !nested { + w.s.checkUnionInjection(n, false, w.result) + } + case *ast.SelectStatement: + w.s.scanCondition(n.Where, w.result) + w.s.scanCondition(n.Having, w.result) + w.s.scanCondition(n.Qualify, w.result) + w.s.scanCondition(n.PrewhereClause, w.result) + w.s.scanCondition(n.StartWith, w.result) + case *ast.ConnectByClause: + w.s.scanCondition(n.Condition, w.result) + case *ast.JoinClause: + w.s.scanCondition(n.Condition, w.result) + w.s.scanCondition(n.MatchCondition, w.result) + case *ast.UpdateStatement: + w.s.scanCondition(n.Where, w.result) + case *ast.Update: + w.s.scanCondition(n.Where, w.result) + case *ast.DeleteStatement: + w.s.scanCondition(n.Where, w.result) + case *ast.Delete: + w.s.scanCondition(n.Where, w.result) + case *ast.MergeStatement: + w.s.scanCondition(n.OnCondition, w.result) + case *ast.MergeWhenClause: + w.s.scanCondition(n.Condition, w.result) + case *ast.MergeAction: + w.s.scanCondition(n.Where, w.result) + w.s.scanCondition(n.DeleteWhere, w.result) + case *ast.WhenClause: + w.s.scanCondition(n.Condition, w.result) + case *ast.OnConflict: + w.s.scanCondition(n.TargetWhere, w.result) + w.s.scanCondition(n.Action.Where, w.result) + case *ast.Values: + w.s.scanStatementText(n.SQL(), w.result) + return false + case *ast.UtilityStatement: + w.s.scanStatementText(n.Text, w.result) + return false + case *ast.SetStatement: + w.s.scanStatementText(n.Text, w.result) + return false + case *ast.TransactionStatement: + w.s.scanStatementText(n.Text, w.result) + return false + case *ast.UseStatement: + w.s.scanStatementText(n.Text, w.result) + return false + case *ast.UnsupportedStatement: + w.s.scanStatementText(n.RawSQL, w.result) + return false + } + w.ancestors = append(w.ancestors, node) + return true +} diff --git a/pkg/sql/security/scan_walker_equiv_test.go b/pkg/sql/security/scan_walker_equiv_test.go new file mode 100644 index 00000000..8f00c4cd --- /dev/null +++ b/pkg/sql/security/scan_walker_equiv_test.go @@ -0,0 +1,236 @@ +// Copyright 2026 GoSQLX Authors +// SPDX-License-Identifier: Apache-2.0 + +package security + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/internal/benchcorpus" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// frozen pre-W6-02 implementation, the reference for TestScanWalkerEquivalence; do not edit +func scanInspectReference(s *Scanner, tree *ast.AST) *ScanResult { + result := &ScanResult{ + Findings: make([]Finding, 0), + } + + if tree == nil { + return result + } + + // Ancestry is local to the scan, so concurrent scans share no walk state. + // Preallocated so typical nesting depths need no regrowth. + ancestors := make([]ast.Node, 0, 16) + ast.Inspect(tree, func(node ast.Node) bool { + if node == nil { + ancestors = ancestors[:len(ancestors)-1] + return false + } + var parent ast.Node + if len(ancestors) > 0 { + parent = ancestors[len(ancestors)-1] + } + if join, ok := parent.(*ast.JoinClause); ok && node == &join.Left { + // Left repeats FROM or an earlier join's right reference, + // including its subquery and table function. + return false + } + switch n := node.(type) { + case *ast.FunctionCall: + s.scanFunctionCall(n, result) + s.scanCondition(n.Filter, result) + case *ast.TableReference: + s.scanTableReference(n, result) + case *ast.SetOperation: + // Direct operands are already checked by the outer operation. + // Operations inside CTEs and subqueries start a new check. + if _, nested := parent.(*ast.SetOperation); !nested { + s.checkUnionInjection(n, false, result) + } + case *ast.SelectStatement: + s.scanCondition(n.Where, result) + s.scanCondition(n.Having, result) + s.scanCondition(n.Qualify, result) + s.scanCondition(n.PrewhereClause, result) + s.scanCondition(n.StartWith, result) + case *ast.ConnectByClause: + s.scanCondition(n.Condition, result) + case *ast.JoinClause: + s.scanCondition(n.Condition, result) + s.scanCondition(n.MatchCondition, result) + case *ast.UpdateStatement: + s.scanCondition(n.Where, result) + case *ast.Update: + s.scanCondition(n.Where, result) + case *ast.DeleteStatement: + s.scanCondition(n.Where, result) + case *ast.Delete: + s.scanCondition(n.Where, result) + case *ast.MergeStatement: + s.scanCondition(n.OnCondition, result) + case *ast.MergeWhenClause: + s.scanCondition(n.Condition, result) + case *ast.MergeAction: + s.scanCondition(n.Where, result) + s.scanCondition(n.DeleteWhere, result) + case *ast.WhenClause: + s.scanCondition(n.Condition, result) + case *ast.OnConflict: + s.scanCondition(n.TargetWhere, result) + s.scanCondition(n.Action.Where, result) + case *ast.Values: + s.scanStatementText(n.SQL(), result) + return false + case *ast.UtilityStatement: + s.scanStatementText(n.Text, result) + return false + case *ast.SetStatement: + s.scanStatementText(n.Text, result) + return false + case *ast.TransactionStatement: + s.scanStatementText(n.Text, result) + return false + case *ast.UseStatement: + s.scanStatementText(n.Text, result) + return false + case *ast.UnsupportedStatement: + s.scanStatementText(n.RawSQL, result) + return false + } + ancestors = append(ancestors, node) + return true + }) + + // Update counts + s.updateCounts(result) + + return result +} + +type scanWalkerCase struct { + name string + sql string + dialect keywords.SQLDialect +} + +// scanWalkerTargetCases is also used by the concurrent pool test. Keep the +// small tree immediately after the deep tree to exercise the next scan. +func scanWalkerTargetCases() []scanWalkerCase { + const pg = keywords.DialectPostgreSQL + const ch = keywords.DialectClickHouse + return []scanWalkerCase{ + {"table function join", "SELECT * FROM url('http://x') AS a JOIN s3('y') AS b ON a.id = b.id", ch}, + {"three table functions", "SELECT * FROM url('http://x') AS a JOIN s3('y') AS b ON a.id = b.id JOIN remote('z') AS c ON b.id = c.id", ch}, + {"CTE set operations", "WITH c AS (SELECT a FROM t UNION SELECT b FROM u UNION SELECT NULL, NULL FROM information_schema.tables) SELECT * FROM c", pg}, + {"subquery set operations", "SELECT * FROM (SELECT a FROM t UNION SELECT b FROM u UNION SELECT NULL, NULL FROM information_schema.tables) AS c", pg}, + {"right set operation", "SELECT a FROM t UNION (SELECT b FROM u UNION SELECT NULL, NULL FROM information_schema.tables)", pg}, + {"nested VALUES", "SELECT * FROM (VALUES (pg_sleep(5))) v", pg}, + {"utility text", "EXEC xp_cmdshell 'dir'", keywords.DialectSQLServer}, + {"set text", "SET @a = SLEEP(5)", keywords.DialectMySQL}, + {"transaction text", "COMMIT PREPARED 'OR 1=1'", pg}, + {"use text", "USE `OR 1=1`", keywords.DialectMySQL}, + {"VALUES text", "VALUES (pg_sleep(5))", pg}, + {"merge condition", "MERGE INTO t USING s ON t.id = s.other_id WHEN MATCHED AND 1=1 THEN UPDATE SET x = s.x", pg}, + {"conflict condition", "INSERT INTO t VALUES (1) ON CONFLICT (id) DO UPDATE SET x = 1 WHERE 1=1", pg}, + {"START WITH condition", "SELECT * FROM t START WITH 1=1 CONNECT BY PRIOR id = pid", keywords.DialectOracle}, + {"deep chain", "SELECT * FROM t WHERE " + strings.Repeat("1=1 OR ", 4999) + "1=1", pg}, + {"small after deep", "SELECT pg_sleep(5)", pg}, + } +} + +func scanWalkerResultDump(r *ScanResult) string { + var b strings.Builder + fmt.Fprintf(&b, "total=%d critical=%d high=%d medium=%d low=%d\n", r.TotalCount, r.CriticalCount, r.HighCount, r.MediumCount, r.LowCount) + for i, f := range r.Findings { + fmt.Fprintf(&b, "%d: Severity=%q Pattern=%q Line=%d Column=%d Description=%q Risk=%q SQL=%q Suggestion=%q\n", i, f.Severity, f.Pattern, f.Line, f.Column, f.Description, f.Risk, f.SQL, f.Suggestion) + } + return b.String() +} + +func TestScanWalkerEquivalence(t *testing.T) { + s := NewScanner() + compare := func(t *testing.T, tree *ast.AST, requireFinding bool) (int, int) { + t.Helper() + got, want := s.Scan(tree), scanInspectReference(s, tree) + if !reflect.DeepEqual(got, want) { + t.Fatalf("Scan/reference mismatch\nScan:\n%sreference:\n%s", scanWalkerResultDump(got), scanWalkerResultDump(want)) + } + if again := s.Scan(tree); !reflect.DeepEqual(got, again) { + t.Fatalf("Scan changed across calls\nfirst:\n%ssecond:\n%s", scanWalkerResultDump(got), scanWalkerResultDump(again)) + } + if requireFinding && len(got.Findings) == 0 { + t.Fatal("targeted fixture must produce at least one finding") + } + return len(got.Findings), len(want.Findings) + } + parseCompare := func(t *testing.T, sql string, dialect keywords.SQLDialect, requireFinding bool) (int, int) { + t.Helper() + tree, err := gosqlx.ParseWithDialect(sql, dialect) + if err != nil { + if requireFinding { + t.Fatalf("targeted fixture parse: %v", err) + } + t.Skipf("unparseable corpus statement: %v", err) + } + defer ast.ReleaseAST(tree) + return compare(t, tree, requireFinding) + } + corpus, err := benchcorpus.Default() + if err != nil { + t.Fatal(err) + } + stride := 1 + if raceEnabled || testing.Short() { + stride = 4 + } + t.Run("corpus", func(t *testing.T) { + for i, st := range corpus.All() { + if i%stride != 0 { + continue + } + t.Run(fmt.Sprintf("%s/%d", st.Dialect, i), func(t *testing.T) { + parseCompare(t, st.SQL, keywords.SQLDialect(st.Dialect), false) + }) + } + }) + total, referenceTotal := 0, 0 + t.Run("realworld suffixes", func(t *testing.T) { + suffixes := []string{"", " OR 1=1", " OR a=a", " UNION SELECT NULL,NULL FROM information_schema.tables", "; DROP TABLE x", " AND SLEEP(5)", " OR 'a'='a'"} + for _, dialect := range benchcorpus.Dialects() { + for i, st := range corpus.RealWorld(dialect) { + if i%stride != 0 { + continue + } + for j, suffix := range suffixes { + t.Run(fmt.Sprintf("%s/%d/%d", dialect, i, j), func(t *testing.T) { + n, ref := parseCompare(t, st.SQL+suffix, keywords.SQLDialect(dialect), false) + total += n + referenceTotal += ref + }) + } + } + } + }) + if total == 0 || referenceTotal == 0 { + t.Fatalf("realworld suffix matrix is vacuous: Scan findings=%d reference findings=%d", total, referenceTotal) + } + t.Run("targeted", func(t *testing.T) { + for _, tc := range scanWalkerTargetCases() { + t.Run(tc.name, func(t *testing.T) { parseCompare(t, tc.sql, tc.dialect, true) }) + } + t.Run("unsupported text", func(t *testing.T) { + // The parser never constructs UnsupportedStatement; cover this legacy + // node directly, as TestScan_RawStatementNodeTypes does. + tree := &ast.AST{Statements: []ast.Statement{&ast.UnsupportedStatement{RawSQL: "SELECT pg_sleep(5)"}}} + defer ast.ReleaseAST(tree) + compare(t, tree, true) + }) + }) +} diff --git a/pkg/sql/security/scan_walker_test.go b/pkg/sql/security/scan_walker_test.go new file mode 100644 index 00000000..e030db8d --- /dev/null +++ b/pkg/sql/security/scan_walker_test.go @@ -0,0 +1,206 @@ +// Copyright 2026 GoSQLX Authors +// SPDX-License-Identifier: Apache-2.0 + +package security + +import ( + "fmt" + "reflect" + "strings" + "sync" + "testing" + + "github.com/ajitpratap0/GoSQLX/internal/benchcorpus" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func scanWalkerTestTree(t *testing.T, sql string, dialect keywords.SQLDialect) *ast.AST { + t.Helper() + tree, err := gosqlx.ParseWithDialect(sql, dialect) + if err != nil { + t.Fatalf("parse %q: %v", sql, err) + } + t.Cleanup(func() { ast.ReleaseAST(tree) }) + return tree +} + +func scanWalkerChain(terms int) string { + return "SELECT * FROM t WHERE " + strings.Repeat("1=1 OR ", terms-1) + "1=1" +} + +func TestScanWalkerReleaseInvariant(t *testing.T) { + cases := []struct{ name, sql string }{ + {"join", "SELECT * FROM url('http://x') AS a JOIN s3('y') AS b ON a.id = b.id"}, + {"nested set operation", "SELECT a FROM t UNION (SELECT b FROM u UNION SELECT NULL, NULL FROM information_schema.tables)"}, + {"deep chain", scanWalkerChain(5000)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tree := scanWalkerTestTree(t, tc.sql, keywords.DialectClickHouse) + s := NewScanner() + r := &ScanResult{Findings: make([]Finding, 0)} + w := acquireScanWalker(s, r) + if err := ast.Walk(w, tree); err != nil { + t.Fatal(err) + } + // Inspect before release so reslicing in release cannot hide an + // unbalanced walk, and inspect capacity so stale nodes cannot hide. + if len(w.ancestors) != 0 { + t.Errorf("ancestors length after walk = %d, want 0", len(w.ancestors)) + } + for i, node := range w.ancestors[:cap(w.ancestors)] { + if node != nil { + t.Errorf("ancestors[%d] retains %T after walk", i, node) + break + } + } + w.release() + if w.s != nil || w.result != nil { + t.Error("release retained scanner or result") + } + }) + } +} + +func TestScanWalkerDropsOversized(t *testing.T) { + for _, tc := range []struct { + terms int + want bool + }{{5000, false}, {16, true}} { + t.Run(fmt.Sprint(tc.terms), func(t *testing.T) { + tree := scanWalkerTestTree(t, scanWalkerChain(tc.terms), keywords.DialectPostgreSQL) + w := acquireScanWalker(NewScanner(), &ScanResult{}) + if err := ast.Walk(w, tree); err != nil { + t.Fatal(err) + } + capacity := cap(w.ancestors) + if got := w.release(); got != tc.want { + t.Errorf("release() = %t for capacity %d, want %t", got, capacity, tc.want) + } + }) + } +} + +func TestScanWalkerAcquireState(t *testing.T) { + s, r := NewScanner(), &ScanResult{} + w := acquireScanWalker(s, r) + if w.s != s || w.result != r || len(w.ancestors) != 0 { + t.Errorf("acquire did not reset state: scanner=%p result=%p len=%d", w.s, w.result, len(w.ancestors)) + } + w.release() +} + +func TestScanWalkerResetClearsDirtyState(t *testing.T) { + w := &scanWalker{ + s: NewScanner(), result: &ScanResult{}, + ancestors: []ast.Node{&ast.SetOperation{}, &ast.JoinClause{}, &ast.SetOperation{}}, + } + s, r := NewScanner(), &ScanResult{} + w.reset(s, r) + if len(w.ancestors) != 0 || w.s != s || w.result != r { + t.Errorf("reset did not replace state: len=%d scanner=%p result=%p", len(w.ancestors), w.s, w.result) + } + // Deliberately dirty backing storage is not put into the shared pool. +} + +type noopVisitor struct{} + +func (*noopVisitor) Visit(node ast.Node) (ast.Visitor, error) { + if node == nil { + return nil, nil + } + return noopV, nil +} + +var noopV = &noopVisitor{} + +func TestScanAllocsOverWalk(t *testing.T) { + if raceEnabled { + t.Skip("race instrumentation changes sync.Pool allocation counts") + } + s := NewScanner() + for _, sql := range []string{ + "SELECT a, b FROM t JOIN u ON t.id = u.id WHERE x > 1 ORDER BY a", + "SELECT COUNT(*) FROM o GROUP BY s HAVING COUNT(*) > 2", + "UPDATE t SET a = 1 WHERE id = 2", + } { + t.Run(sql, func(t *testing.T) { + tree := scanWalkerTestTree(t, sql, keywords.DialectPostgreSQL) + if r := s.Scan(tree); !r.IsClean() { + t.Fatalf("allocation input has findings: %+v", r.Findings) + } + scanAllocs := testing.AllocsPerRun(200, func() { _ = s.Scan(tree) }) + walkAllocs := testing.AllocsPerRun(200, func() { _ = ast.Walk(noopV, tree) }) + if delta := scanAllocs - walkAllocs; delta > 1 { + t.Errorf("Scan allocs %.0f - Walk allocs %.0f = %.0f, want <= 1", scanAllocs, walkAllocs, delta) + } + }) + } +} + +func TestScanConcurrentPooledWalker(t *testing.T) { + s := NewScanner() + var trees []*ast.AST + var names []string + // Under -race the instrumented walk is several times slower and CI shards + // have a fixed time budget, so the race run uses a 100-term chain, fewer + // workers/iterations and fewer corpus statements. The 5000-deep tree is + // still exercised concurrently in the non-race run. + workers, iterations, corpusN := 16, 50, 50 + if raceEnabled { + workers, iterations, corpusN = 4, 8, 10 + } + for _, tc := range scanWalkerTargetCases() { + if raceEnabled && tc.name == "deep chain" { + tc.sql = "SELECT * FROM t WHERE " + strings.Repeat("1=1 OR ", 99) + "1=1" + } + trees = append(trees, scanWalkerTestTree(t, tc.sql, tc.dialect)) + names = append(names, tc.name) + } + unsupported := &ast.AST{Statements: []ast.Statement{ + &ast.UnsupportedStatement{Kind: "unsupported", RawSQL: "SELECT * FROM t WHERE 1=1"}, + }} + t.Cleanup(func() { ast.ReleaseAST(unsupported) }) + trees = append(trees, unsupported) + names = append(names, "unsupported text") + set, err := benchcorpus.Default() + if err != nil { + t.Fatal(err) + } + statements := set.RealWorld("postgresql") + if len(statements) < corpusN { + t.Fatalf("PostgreSQL corpus has %d statements, want at least %d", len(statements), corpusN) + } + for _, st := range statements[:corpusN] { + tree, err := gosqlx.ParseWithDialect(st.SQL, keywords.DialectPostgreSQL) + if err != nil { + t.Logf("skip real-world parse error %s: %v", st.Key(), err) + continue + } + t.Cleanup(func() { ast.ReleaseAST(tree) }) + trees = append(trees, tree) + names = append(names, st.Key()) + } + serial := make([]*ScanResult, len(trees)) + for i, tree := range trees { + serial[i] = s.Scan(tree) + } + var wg sync.WaitGroup + for worker := 0; worker < workers; worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for iteration := 0; iteration < iterations; iteration++ { + for i, tree := range trees { + if got := s.Scan(tree); !reflect.DeepEqual(got, serial[i]) { + t.Errorf("iteration %d, %s: concurrent Scan differs from serial\ngot: %#v\nwant: %#v", iteration, names[i], got, serial[i]) + return + } + } + } + }() + } + wg.Wait() +} diff --git a/pkg/sql/security/scanner.go b/pkg/sql/security/scanner.go index b84f5160..b2c07257 100644 --- a/pkg/sql/security/scanner.go +++ b/pkg/sql/security/scanner.go @@ -605,6 +605,7 @@ func isValidSeverity(severity Severity) bool { // tautologies, dangerous functions, UNION-based injection, and other attack vectors. // // The method is safe for concurrent use as it does not modify the Scanner state. +// Walk state is drawn from a pool and exclusively owned for each call. // // Returns a ScanResult containing all detected findings that meet the MinSeverity // threshold, along with summary statistics by severity level. @@ -632,88 +633,11 @@ func (s *Scanner) Scan(tree *ast.AST) *ScanResult { return result } - // Ancestry is local to the scan, so concurrent scans share no walk state. - // Preallocated so typical nesting depths need no regrowth. - ancestors := make([]ast.Node, 0, 16) - ast.Inspect(tree, func(node ast.Node) bool { - if node == nil { - ancestors = ancestors[:len(ancestors)-1] - return false - } - var parent ast.Node - if len(ancestors) > 0 { - parent = ancestors[len(ancestors)-1] - } - if join, ok := parent.(*ast.JoinClause); ok && node == &join.Left { - // Left repeats FROM or an earlier join's right reference, - // including its subquery and table function. - return false - } - switch n := node.(type) { - case *ast.FunctionCall: - s.scanFunctionCall(n, result) - s.scanCondition(n.Filter, result) - case *ast.TableReference: - s.scanTableReference(n, result) - case *ast.SetOperation: - // Direct operands are already checked by the outer operation. - // Operations inside CTEs and subqueries start a new check. - if _, nested := parent.(*ast.SetOperation); !nested { - s.checkUnionInjection(n, false, result) - } - case *ast.SelectStatement: - s.scanCondition(n.Where, result) - s.scanCondition(n.Having, result) - s.scanCondition(n.Qualify, result) - s.scanCondition(n.PrewhereClause, result) - s.scanCondition(n.StartWith, result) - case *ast.ConnectByClause: - s.scanCondition(n.Condition, result) - case *ast.JoinClause: - s.scanCondition(n.Condition, result) - s.scanCondition(n.MatchCondition, result) - case *ast.UpdateStatement: - s.scanCondition(n.Where, result) - case *ast.Update: - s.scanCondition(n.Where, result) - case *ast.DeleteStatement: - s.scanCondition(n.Where, result) - case *ast.Delete: - s.scanCondition(n.Where, result) - case *ast.MergeStatement: - s.scanCondition(n.OnCondition, result) - case *ast.MergeWhenClause: - s.scanCondition(n.Condition, result) - case *ast.MergeAction: - s.scanCondition(n.Where, result) - s.scanCondition(n.DeleteWhere, result) - case *ast.WhenClause: - s.scanCondition(n.Condition, result) - case *ast.OnConflict: - s.scanCondition(n.TargetWhere, result) - s.scanCondition(n.Action.Where, result) - case *ast.Values: - s.scanStatementText(n.SQL(), result) - return false - case *ast.UtilityStatement: - s.scanStatementText(n.Text, result) - return false - case *ast.SetStatement: - s.scanStatementText(n.Text, result) - return false - case *ast.TransactionStatement: - s.scanStatementText(n.Text, result) - return false - case *ast.UseStatement: - s.scanStatementText(n.Text, result) - return false - case *ast.UnsupportedStatement: - s.scanStatementText(n.RawSQL, result) - return false - } - ancestors = append(ancestors, node) - return true - }) + w := acquireScanWalker(s, result) + _ = ast.Walk(w, tree) + // release is deliberately not deferred: if the walk panics, the walker + // (possibly holding dirty ancestor slots) is dropped instead of pooled. + w.release() // Update counts s.updateCounts(result)