From cff610da707202f96e13a1a54142046a55050c32 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Wed, 28 Oct 2026 11:01:23 +0530 Subject: [PATCH] fix(security): walk the full AST in Scanner.Scan for ScanSQL parity Scan(tree) only inspected top-level SELECT WHERE/HAVING/columns/FROM, INSERT VALUES rows, UPDATE/DELETE WHERE and set-operation leaves, so time-based, out-of-band and tautology patterns inside CTEs, subqueries, derived tables, CASE/CAST/IN/BETWEEN, ORDER/GROUP BY, JOIN ON, window specs, FILTER, LIMIT, LATERAL/APPLY, PREWHERE, QUALIFY, CONNECT BY, RETURNING, ON CONFLICT, INSERT ... SELECT, MERGE, CREATE TABLE AS, CREATE [MATERIALIZED] VIEW and EXPLAIN went undetected while ScanSQL caught them. Scan now uses ast.Inspect: every FunctionCall and table function is checked once, tautologies are checked in every condition position, UNION checks run on each outermost set operation (including those nested in CTEs and subqueries), and text-bodied nodes keep the text path. JOIN Left references are skipped to avoid double counting, and qualified identifiers such as a.id = b.id are no longer reported as tautologies. T-SQL IF ... WAITFOR and standalone VALUES were already covered on main (#871) and gain regression tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- CHANGELOG.md | 8 + pkg/sql/security/ast_coverage_test.go | 253 ++++++++++++++++++++++ pkg/sql/security/scanner.go | 294 +++++++++----------------- 3 files changed, 356 insertions(+), 199 deletions(-) create mode 100644 pkg/sql/security/ast_coverage_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2935c051..9d1f96c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 in dialects without them. ### Fixed +- **Security scanner**: `Scanner.Scan(tree)` now walks the whole AST, so time-based, + out-of-band and tautology patterns are detected inside CTEs, derived tables and + subqueries, CASE/CAST/IN/BETWEEN/ANY, ORDER BY/GROUP BY, JOIN ON, window specs, + FILTER, LIMIT, LATERAL/APPLY, PREWHERE, QUALIFY, CONNECT BY, RETURNING, ON CONFLICT, + INSERT ... SELECT, MERGE, CREATE TABLE AS, CREATE [MATERIALIZED] VIEW and EXPLAIN, + matching `ScanSQL`. UNION injection checks also run on set operations nested in CTEs + and subqueries. Qualified column comparisons such as `a.id = b.id` are no longer + reported as tautologies. - Watch now respects `--dialect`, re-validates changes and formats files in place with the CLI safety checks; MCP parsing accepts a dialect and documents comment preservation. - Correct transpile dialect help, avoid duplicate lint syntax errors, and rewrite diff --git a/pkg/sql/security/ast_coverage_test.go b/pkg/sql/security/ast_coverage_test.go new file mode 100644 index 00000000..46f14799 --- /dev/null +++ b/pkg/sql/security/ast_coverage_test.go @@ -0,0 +1,253 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package security + +import ( + "reflect" + "sync" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" +) + +type astCoverageCase struct { + name string + sql string + dialect keywords.SQLDialect + patterns []PatternType + exact map[PatternType]int +} + +func astCoverageCases() []astCoverageCase { + const pg = keywords.DialectPostgreSQL + tb := []PatternType{PatternTimeBased} + taut := []PatternType{PatternTautology} + oob := []PatternType{PatternOutOfBand} + oneTB := map[PatternType]int{PatternTimeBased: 1} + oneOOB := map[PatternType]int{PatternOutOfBand: 1} + return []astCoverageCase{ + {"01 CTE function", "WITH c AS (SELECT pg_sleep(5)) SELECT * FROM c", pg, tb, oneTB}, + {"02 CTE condition", "WITH c AS (SELECT * FROM t WHERE 1=1 OR 2=2) SELECT * FROM c", pg, taut, nil}, + {"03 derived table", "SELECT * FROM (SELECT pg_sleep(5)) s", pg, tb, oneTB}, + {"04 IN subquery", "SELECT * FROM t WHERE id IN (SELECT pg_sleep(5))", pg, tb, oneTB}, + {"05 EXISTS", "SELECT * FROM t WHERE EXISTS (SELECT pg_sleep(5))", pg, tb, nil}, + {"06 scalar subquery", "SELECT * FROM t WHERE x = (SELECT pg_sleep(5))", pg, tb, oneTB}, + {"07 CASE", "SELECT CASE WHEN 1=1 THEN pg_sleep(5) END FROM t", pg, []PatternType{PatternTimeBased, PatternTautology}, oneTB}, + {"08 CAST", "SELECT CAST(pg_sleep(5) AS text) FROM t", pg, tb, nil}, + {"09 BETWEEN", "SELECT * FROM t WHERE x BETWEEN pg_sleep(1) AND 2", pg, tb, nil}, + {"10 IN list", "SELECT * FROM t WHERE x IN (pg_sleep(1), 2)", pg, tb, nil}, + {"11 ORDER BY", "SELECT * FROM t ORDER BY pg_sleep(5)", pg, tb, nil}, + {"12 GROUP BY", "SELECT * FROM t GROUP BY pg_sleep(5)", pg, tb, nil}, + {"13 JOIN condition", "SELECT * FROM a JOIN b ON a.id = b.id OR 1=1", pg, taut, nil}, + {"14 JOIN function", "SELECT * FROM a JOIN b ON pg_sleep(5) IS NULL", pg, tb, oneTB}, + {"15 window", "SELECT count(*) OVER (ORDER BY pg_sleep(5)) FROM t", pg, tb, nil}, + {"16 INSERT SELECT", "INSERT INTO t SELECT pg_sleep(5)", pg, tb, nil}, + {"17 ON CONFLICT", "INSERT INTO t VALUES (1) ON CONFLICT (id) DO UPDATE SET x = pg_sleep(5)", pg, tb, nil}, + {"18 INSERT RETURNING", "INSERT INTO t VALUES (1) RETURNING pg_sleep(5)", pg, tb, nil}, + {"19 UPDATE RETURNING", "UPDATE t SET x = 1 RETURNING pg_sleep(5)", pg, tb, nil}, + {"20 DELETE subquery", "DELETE FROM t WHERE id IN (SELECT id FROM u WHERE 1=1 OR 2=2)", pg, taut, nil}, + {"21 MERGE UPDATE", "MERGE INTO t USING s ON t.id = s.id OR 1=1 WHEN MATCHED THEN UPDATE SET x = pg_sleep(5)", pg, []PatternType{PatternTautology, PatternTimeBased}, nil}, + {"22 MERGE INSERT", "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT (a) VALUES (pg_sleep(5))", pg, tb, nil}, + {"23 CREATE TABLE AS", "CREATE TABLE x AS SELECT pg_sleep(5)", pg, tb, nil}, + {"24 CREATE VIEW", "CREATE VIEW v AS SELECT pg_sleep(5)", pg, tb, nil}, + {"25 CREATE MATERIALIZED VIEW", "CREATE MATERIALIZED VIEW v AS SELECT pg_sleep(5)", pg, tb, nil}, + {"26 EXPLAIN", "EXPLAIN SELECT pg_sleep(5)", pg, tb, nil}, + {"27 UNION derived table", "SELECT 1 UNION SELECT * FROM (SELECT pg_sleep(5)) s", pg, tb, oneTB}, + {"28 ANY", "SELECT * FROM t WHERE x = ANY (SELECT pg_sleep(5))", pg, tb, nil}, + {"29 ARRAY", "SELECT ARRAY[pg_sleep(5)]", pg, tb, nil}, + {"30 tuple", "SELECT (pg_sleep(5), 1)", pg, tb, nil}, + {"31 FILTER", "SELECT coalesce(x, 1) FILTER (WHERE pg_sleep(5) IS NULL) FROM t", pg, tb, nil}, + {"32 LIMIT", "SELECT * FROM t LIMIT (SELECT pg_sleep(5))", pg, tb, nil}, + {"33 LATERAL", "SELECT * FROM t, LATERAL (SELECT pg_sleep(5)) s", pg, tb, nil}, + {"34 cast operator", "SELECT x::text FROM t WHERE pg_sleep(5)::text = ''", pg, tb, nil}, + {"35 EXTRACT", "SELECT EXTRACT(YEAR FROM pg_sleep(5))", pg, tb, nil}, + {"36 nested VALUES", "SELECT * FROM (VALUES (pg_sleep(5))) v", pg, tb, oneTB}, + {"37 CTE UNION", "WITH c AS (SELECT id FROM t UNION SELECT table_name FROM information_schema.tables) SELECT * FROM c", pg, []PatternType{PatternUnionBased}, nil}, + {"38 WHERE function", "SELECT * FROM t WHERE pg_sleep(5) IS NULL", pg, tb, oneTB}, + {"39a function", "SELECT pg_sleep(5)", pg, tb, oneTB}, + {"39b qualified function", "SELECT pg_catalog.pg_sleep(5)", pg, tb, oneTB}, + {"40 CONNECT BY", "SELECT * FROM t START WITH id = 1 CONNECT BY PRIOR id = pid AND DBMS_LOCK.SLEEP(5) = 1", keywords.DialectOracle, tb, nil}, + {"41 PREWHERE", "SELECT * FROM t PREWHERE sleep(3) = 0", keywords.DialectClickHouse, tb, nil}, + {"42 table function", "SELECT * FROM url('http://x/a.csv', CSV, 'a String')", keywords.DialectClickHouse, oob, oneOOB}, + {"43 JOIN table function", "SELECT * FROM t JOIN url('http://x/a.csv', CSV, 'a String') u ON 1 = 1", keywords.DialectClickHouse, oob, oneOOB}, + {"44 APPLY", "SELECT TOP (1) * FROM t CROSS APPLY (SELECT xp_cmdshell('dir')) s", keywords.DialectSQLServer, oob, nil}, + {"45 IF WAITFOR", "IF 1=1 WAITFOR DELAY '0:0:5'", keywords.DialectSQLServer, []PatternType{PatternTimeBased, PatternTautology}, nil}, + {"46 IF subquery WAITFOR", "IF (SELECT COUNT(*) FROM users) > 0 WAITFOR DELAY '0:0:5'", keywords.DialectSQLServer, tb, nil}, + {"47 IF BEGIN WAITFOR", "IF 1=1 BEGIN WAITFOR DELAY '0:0:5' END", keywords.DialectSQLServer, tb, nil}, + {"48 AND function", "SELECT * FROM t WHERE id = 1 AND SLEEP(5)", keywords.DialectMySQL, tb, oneTB}, + {"49 LIKE function", "SELECT * FROM t WHERE x LIKE SLEEP(5)", keywords.DialectMySQL, tb, oneTB}, + {"50 QUALIFY", "SELECT * FROM t QUALIFY ROW_NUMBER() OVER (ORDER BY pg_sleep(5)) = 1", keywords.DialectSnowflake, tb, nil}, + {"FILTER condition", "SELECT count(*) FILTER (WHERE 1=1) FROM t", pg, taut, nil}, + {"ON CONFLICT target condition", "INSERT INTO t VALUES (1) ON CONFLICT (id) WHERE 1=1 DO NOTHING", pg, taut, nil}, + {"ON CONFLICT action condition", "INSERT INTO t VALUES (1) ON CONFLICT (id) DO UPDATE SET x = 1 WHERE 1=1", pg, taut, nil}, + {"QUALIFY condition", "SELECT * FROM t QUALIFY 1=1", keywords.DialectSnowflake, taut, nil}, + {"PREWHERE condition", "SELECT * FROM t PREWHERE 1=1", keywords.DialectClickHouse, taut, nil}, + {"START WITH condition", "SELECT * FROM t START WITH 1=1 CONNECT BY PRIOR id = pid", keywords.DialectOracle, taut, nil}, + {"CONNECT BY condition", "SELECT * FROM t START WITH id = 1 CONNECT BY 1=1", keywords.DialectOracle, taut, nil}, + {"MERGE WHEN condition", "MERGE INTO t USING s ON t.id = s.other_id WHEN MATCHED AND 1=1 THEN UPDATE SET x = s.x", pg, taut, nil}, + {"JOIN MATCH_CONDITION", "SELECT * FROM t1 ASOF JOIN t2 MATCH_CONDITION (1=1) ON t1.sym = t2.other_sym", keywords.DialectSnowflake, taut, nil}, + {"boolean parentheses and NOT", "SELECT * FROM t WHERE NOT (x = 1 OR (1=1))", pg, taut, nil}, + {"nested dangerous function", "WITH c AS (SELECT CASE WHEN x > 1 THEN xp_cmdshell('dir') END FROM t) SELECT * FROM c", keywords.DialectSQLServer, oob, oneOOB}, + {"standalone WAITFOR", "WAITFOR DELAY '0:0:5'", keywords.DialectSQLServer, tb, nil}, + {"EXEC utility", "EXEC xp_cmdshell 'dir'", keywords.DialectSQLServer, oob, nil}, + {"standalone VALUES", "VALUES (pg_sleep(5))", pg, tb, oneTB}, + {"standalone VALUES CASE", "VALUES (CASE WHEN 1=1 THEN pg_sleep(5) END)", pg, []PatternType{PatternTimeBased, PatternTautology}, oneTB}, + } +} + +func astCoveragePatternCount(result *ScanResult, pattern PatternType) int { + count := 0 + for _, finding := range result.Findings { + if finding.Pattern == pattern { + count++ + } + } + return count +} + +func TestScan_ASTCoverage(t *testing.T) { + scanner := NewScanner() + for _, tt := range astCoverageCases() { + t.Run(tt.name, func(t *testing.T) { + tree, err := parser.ParseWithDialect(tt.sql, tt.dialect) + if err != nil { + t.Fatalf("parse %q: %v", tt.sql, err) + } + defer ast.ReleaseAST(tree) + result := scanner.Scan(tree) + for _, pattern := range tt.patterns { + if astCoveragePatternCount(result, pattern) == 0 { + t.Errorf("Scan missing %s: %+v", pattern, result.Findings) + } + } + for pattern, want := range tt.exact { + if got := astCoveragePatternCount(result, pattern); got != want { + t.Errorf("Scan %s count = %d, want %d; findings: %+v", pattern, got, want, result.Findings) + } + } + textResult := scanner.ScanSQL(tt.sql) + for _, pattern := range []PatternType{PatternTimeBased, PatternOutOfBand, PatternTautology} { + if hasPattern(textResult, pattern) && !hasPattern(result, pattern) { + t.Errorf("ScanSQL reports %s but Scan does not; text: %+v; AST: %+v", pattern, textResult.Findings, result.Findings) + } + } + }) + } +} + +func TestScan_ASTCoverageClean(t *testing.T) { + tests := []string{ + "SELECT 1=1 FROM t ORDER BY 2=2", + "SELECT f(1=1) FROM t", + "WITH c AS (SELECT id FROM t WHERE id = 1) SELECT * FROM c", + "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET x = s.x WHEN NOT MATCHED THEN INSERT (id, x) VALUES (s.id, s.x)", + "SELECT CASE WHEN a > 1 THEN 'x' ELSE 'y' END FROM t ORDER BY a", + "SELECT * FROM a JOIN b ON a.id = b.id WHERE b.x IN (SELECT x FROM c WHERE c.y = 2)", + "CREATE VIEW v AS SELECT id, name FROM users WHERE active = true", + } + for _, sql := range tests { + t.Run(sql, func(t *testing.T) { + tree, err := parser.ParseWithDialect(sql, keywords.DialectPostgreSQL) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + if result := NewScanner().Scan(tree); !result.IsClean() { + t.Errorf("expected clean Scan, got %+v", result.Findings) + } + }) + } +} + +func TestScan_ASTCoverageUnion(t *testing.T) { + scanner := NewScanner() + const unionSQL = "SELECT id FROM t UNION SELECT table_name FROM information_schema.tables" + baselineTree, err := parser.ParseWithDialect(unionSQL, keywords.DialectPostgreSQL) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(baselineTree) + baseline := scanner.Scan(baselineTree) + if countUnionSystemTableFindings(baseline) == 0 { + t.Fatalf("top-level UNION missing CRITICAL finding: %+v", baseline.Findings) + } + for _, sql := range []string{ + "WITH c AS (" + unionSQL + ") SELECT * FROM c", + "SELECT * FROM (" + unionSQL + ") s", + } { + tree, err := parser.ParseWithDialect(sql, keywords.DialectPostgreSQL) + if err != nil { + t.Fatal(err) + } + result := scanner.Scan(tree) + ast.ReleaseAST(tree) + if !reflect.DeepEqual(result.Findings, baseline.Findings) { + t.Errorf("nested UNION findings = %+v, want top-level findings %+v", result.Findings, baseline.Findings) + } + } + tree, err := parser.ParseWithDialect("SELECT * FROM t1 UNION ALL SELECT * FROM t2", keywords.DialectPostgreSQL) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + result := scanner.Scan(tree) + for _, pattern := range []PatternType{PatternTimeBased, PatternOutOfBand, PatternTautology} { + if hasPattern(result, pattern) { + t.Errorf("benign UNION has unexpected %s: %+v", pattern, result.Findings) + } + } + // The existing AST path produces no generic UNION finding. + if !result.IsClean() { + t.Errorf("benign UNION findings changed: %+v", result.Findings) + } +} + +func TestScan_ASTCoverageConcurrent(t *testing.T) { + scanner := NewScanner() + sqls := []string{ + "WITH c AS (SELECT pg_sleep(5)) SELECT * FROM c", + "SELECT CASE WHEN 1=1 THEN pg_sleep(5) END FROM t", + "SELECT * FROM (VALUES (pg_sleep(5))) v", + "SELECT id FROM t UNION SELECT table_name FROM information_schema.tables", + } + trees := make([]*ast.AST, 0, len(sqls)) + expected := make([]*ScanResult, 0, len(sqls)) + for _, sql := range sqls { + tree, err := parser.ParseWithDialect(sql, keywords.DialectPostgreSQL) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + trees = append(trees, tree) + expected = append(expected, scanner.Scan(tree)) + } + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for range 10 { + for i, tree := range trees { + if result := scanner.Scan(tree); !reflect.DeepEqual(result, expected[i]) { + t.Errorf("concurrent Scan changed result for %q: %+v", sqls[i], result) + } + } + } + }() + } + wg.Wait() +} diff --git a/pkg/sql/security/scanner.go b/pkg/sql/security/scanner.go index f7e39094..c008862e 100644 --- a/pkg/sql/security/scanner.go +++ b/pkg/sql/security/scanner.go @@ -627,9 +627,88 @@ func (s *Scanner) Scan(tree *ast.AST) *ScanResult { return result } - for _, stmt := range tree.Statements { - s.scanStatement(stmt, 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) @@ -723,36 +802,6 @@ func (s *Scanner) scanSQL(sql string, prefilter bool) *ScanResult { return result } -// scanStatement analyzes a single statement for injection patterns. -func (s *Scanner) scanStatement(stmt ast.Statement, result *ScanResult) { - switch st := stmt.(type) { - case *ast.SelectStatement: - s.scanSelectStatement(st, result) - case *ast.InsertStatement: - s.scanInsertStatement(st, result) - case *ast.UpdateStatement: - s.scanUpdateStatement(st, result) - case *ast.DeleteStatement: - s.scanDeleteStatement(st, result) - case *ast.SetOperation: - s.scanSetOperation(st, result) - case *ast.Values: - // The text rules cover expressions under every VALUES wrapper (CAST, - // CASE, subqueries, ...), without duplicating AST function findings. - s.scanStatementText(st.SQL(), result) - case *ast.UtilityStatement: - s.scanStatementText(st.Text, result) - case *ast.SetStatement: - s.scanStatementText(st.Text, result) - case *ast.TransactionStatement: - s.scanStatementText(st.Text, result) - case *ast.UseStatement: - s.scanStatementText(st.Text, result) - case *ast.UnsupportedStatement: - s.scanStatementText(st.RawSQL, result) - } -} - // scanStatementText covers statements whose bodies are retained as text rather // than expression nodes, including PREPARE/EXECUTE, T-SQL blocks and CREATE // POLICY/TRIGGER. These nodes take only the text path, so AST and text checks @@ -762,38 +811,10 @@ func (s *Scanner) scanStatementText(text string, result *ScanResult) { result.Findings = append(result.Findings, findings.Findings...) } -// scanSelectStatement analyzes SELECT for injection patterns. -func (s *Scanner) scanSelectStatement(stmt *ast.SelectStatement, result *ScanResult) { - // Check WHERE clause for tautologies - if stmt.Where != nil { - s.scanExpression(stmt.Where, result, "WHERE clause") - } - - // Check HAVING clause - if stmt.Having != nil { - s.scanExpression(stmt.Having, result, "HAVING clause") - } - - // Check for suspicious function calls in columns - for _, col := range stmt.Columns { - s.scanExpressionForDangerousFunctions(col, result) - } - - // Check table-function references in FROM and JOIN. Joins[i].Left - // repeats a reference already in From or an earlier join, so only the - // right side is scanned. - for i := range stmt.From { - s.scanTableReference(&stmt.From[i], result) - } - for i := range stmt.Joins { - s.scanTableReference(&stmt.Joins[i].Right, result) - } -} - // scanTableReference checks a function-call table reference such as // ClickHouse FROM url(...) or PostgreSQL FROM generate_series(...): the -// function name against the out-of-band table functions, and the call and -// its arguments against the dangerous function lists. +// function name is checked against the out-of-band table functions. Inspect +// separately visits the FunctionCall and its arguments. func (s *Scanner) scanTableReference(ref *ast.TableReference, result *ScanResult) { if ref == nil || ref.TableFunc == nil { return @@ -810,98 +831,23 @@ func (s *Scanner) scanTableReference(ref *ast.TableReference, result *ScanResult result.Findings = append(result.Findings, finding) } } - s.scanFunctionCall(ref.TableFunc, result) -} - -// scanInsertStatement analyzes INSERT for injection patterns. -func (s *Scanner) scanInsertStatement(stmt *ast.InsertStatement, result *ScanResult) { - // Check values for suspicious patterns (multi-row support) - for _, row := range stmt.Values { - for _, val := range row { - s.scanExpressionForDangerousFunctions(val, result) - } - } -} - -// scanUpdateStatement analyzes UPDATE for injection patterns. -func (s *Scanner) scanUpdateStatement(stmt *ast.UpdateStatement, result *ScanResult) { - // Check WHERE clause - if stmt.Where != nil { - s.scanExpression(stmt.Where, result, "WHERE clause") - } - - // Check SET values - for _, assignment := range stmt.Assignments { - s.scanExpressionForDangerousFunctions(assignment.Value, result) - } } -// scanDeleteStatement analyzes DELETE for injection patterns. -func (s *Scanner) scanDeleteStatement(stmt *ast.DeleteStatement, result *ScanResult) { - // Check WHERE clause - if stmt.Where != nil { - s.scanExpression(stmt.Where, result, "WHERE clause") - } -} - -// scanSetOperation analyzes UNION/EXCEPT/INTERSECT for injection patterns. -// -// Parenthesized operands produce nested SetOperation trees on either side -// (e.g. "a UNION (b UNION c)"), so the whole tree is walked once for UNION -// operands and once for the per-statement checks of each leaf. -func (s *Scanner) scanSetOperation(stmt *ast.SetOperation, result *ScanResult) { - // UNION-based injection detection over the whole set-operation tree. - s.checkUnionInjection(stmt, false, result) - - // Scan every leaf statement of the tree. - s.scanSetOperationLeaves(stmt, result) -} - -// scanSetOperationLeaves runs the per-statement checks on each non-set-operation -// operand of stmt, descending into nested set operations. -func (s *Scanner) scanSetOperationLeaves(stmt *ast.SetOperation, result *ScanResult) { - for _, operand := range [2]ast.Statement{stmt.Left, stmt.Right} { - switch op := operand.(type) { - case nil: - case *ast.SetOperation: - s.scanSetOperationLeaves(op, result) - default: - s.scanStatement(op, result) - } - } -} - -// scanExpression analyzes an expression for injection patterns. -func (s *Scanner) scanExpression(expr ast.Expression, result *ScanResult, context string) { - if expr == nil { - return - } - +// scanCondition checks tautologies through boolean expression structure only. +// Inspect separately handles functions and nested statements. +func (s *Scanner) scanCondition(expr ast.Expression, result *ScanResult) { switch e := expr.(type) { case *ast.BinaryExpression: - s.scanBinaryExpression(e, result, context) - case *ast.FunctionCall: - s.scanFunctionCall(e, result) + s.scanBinaryExpression(e, result) case *ast.UnaryExpression: - if e.Expr != nil { - s.scanExpression(e.Expr, result, context) - } + s.scanCondition(e.Expr, result) case *ast.ParenthesizedExpression: - s.scanExpression(e.Expr, result, context) - case *ast.FieldAccessExpression: - s.scanExpression(e.Expr, result, context) - case *ast.ArraySubscriptExpression, *ast.ArraySliceExpression: - // A subscript or slice on a call result, such as pg_sleep(5)[1]. - s.scanExpressionForDangerousFunctions(e, result) - case *ast.MatchAgainstExpression, *ast.AssignmentExpression, *ast.IntervalExpression, - *ast.KeywordArgument, *ast.OuterJoinMarker, *ast.ConnectByRootExpression: - // Operands of MATCH ... AGAINST (SLEEP(5)), @v := SLEEP(5), ... - s.scanExpressionForDangerousFunctions(e, result) + s.scanCondition(e.Expr, result) } } // scanBinaryExpression checks for tautologies and suspicious patterns. -func (s *Scanner) scanBinaryExpression(expr *ast.BinaryExpression, result *ScanResult, context string) { +func (s *Scanner) scanBinaryExpression(expr *ast.BinaryExpression, result *ScanResult) { if expr == nil { return } @@ -926,8 +872,8 @@ func (s *Scanner) scanBinaryExpression(expr *ast.BinaryExpression, result *ScanR } // Recursively check sub-expressions - s.scanExpression(expr.Left, result, context) - s.scanExpression(expr.Right, result, context) + s.scanCondition(expr.Left, result) + s.scanCondition(expr.Right, result) } // isTautology checks if an expression is always true. @@ -960,7 +906,7 @@ func (s *Scanner) isTautology(expr *ast.BinaryExpression) bool { rightIdent, rightIsIdent := right.(*ast.Identifier) if leftIsIdent && rightIsIdent { - if leftIdent.Name == rightIdent.Name { + if leftIdent.Name == rightIdent.Name && leftIdent.Table == rightIdent.Table { return true } } @@ -1153,7 +1099,7 @@ var outOfBandTableFuncNames = map[string]string{ "EXECUTABLE": "Command execution", } -// scanFunctionCall checks for dangerous function usage. +// scanFunctionCall checks only this call name; Inspect visits its children. func (s *Scanner) scanFunctionCall(fn *ast.FunctionCall, result *ScanResult) { if fn == nil { return @@ -1204,56 +1150,6 @@ func (s *Scanner) scanFunctionCall(fn *ast.FunctionCall, result *ScanResult) { } } - // Recursively check function arguments - for _, arg := range fn.Arguments { - s.scanExpressionForDangerousFunctions(arg, result) - } -} - -// scanExpressionForDangerousFunctions recursively checks for dangerous functions. -func (s *Scanner) scanExpressionForDangerousFunctions(expr ast.Expression, result *ScanResult) { - if expr == nil { - return - } - - switch e := expr.(type) { - case *ast.FunctionCall: - s.scanFunctionCall(e, result) - case *ast.BinaryExpression: - s.scanExpressionForDangerousFunctions(e.Left, result) - s.scanExpressionForDangerousFunctions(e.Right, result) - case *ast.UnaryExpression: - s.scanExpressionForDangerousFunctions(e.Expr, result) - case *ast.ParenthesizedExpression: - s.scanExpressionForDangerousFunctions(e.Expr, result) - case *ast.FieldAccessExpression: - s.scanExpressionForDangerousFunctions(e.Expr, result) - case *ast.ArraySubscriptExpression: - s.scanExpressionForDangerousFunctions(e.Array, result) - for _, idx := range e.Indices { - s.scanExpressionForDangerousFunctions(idx, result) - } - case *ast.ArraySliceExpression: - s.scanExpressionForDangerousFunctions(e.Array, result) - s.scanExpressionForDangerousFunctions(e.Start, result) - s.scanExpressionForDangerousFunctions(e.End, result) - case *ast.KeywordArgument: - // SUBSTRING(s FROM SLEEP(5)), TRIM(BOTH SLEEP(5) FROM s) - s.scanExpressionForDangerousFunctions(e.Value, result) - case *ast.MatchAgainstExpression: - for _, c := range e.Columns { - s.scanExpressionForDangerousFunctions(c, result) - } - s.scanExpressionForDangerousFunctions(e.Query, result) - case *ast.AssignmentExpression: - s.scanExpressionForDangerousFunctions(e.Value, result) - case *ast.IntervalExpression: - s.scanExpressionForDangerousFunctions(e.ValueExpr, result) - case *ast.OuterJoinMarker: - s.scanExpressionForDangerousFunctions(e.Expr, result) - case *ast.ConnectByRootExpression: - s.scanExpressionForDangerousFunctions(e.Expr, result) - } } // detectCommentPatterns checks raw SQL for comment-based injection. folded is the