From b5222a316b6ff5176249a0a673312877c2fb3acb Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Wed, 28 Oct 2026 23:57:44 +0530 Subject: [PATCH 1/2] fix(security): close ScanSQL bypasses via quoted dollar tags, truncation and inline comments - Dollar-quote stripping ran inside quoted strings, so SELECT '$x$'; DROP TABLE users; SELECT '$x$' scanned clean. A $tag$ is now stripped only when every quoting interpretation (backslash or doubled quote escapes, PostgreSQL E-strings, "..." strings, [bracket] identifiers, nested comments, # comments) agrees it is outside strings and comments. - Tautology and comment checks only looked at the first 10,000 bytes. They now cover the whole input; inputs above 10 MiB get a CRITICAL INPUT_TOO_LARGE finding regardless of the severity filter. - Comments between keywords (UNION/**/SELECT, SLEEP/**/(5), ;--\nDROP, # comments, /*/, MySQL 1--1) evaded matching. Patterns now also run over comment-free views of the text for each quoting convention; views only add findings. /*! */ and /*M! */ bodies are scanned. - The AST UNION NULL-padding check now matches the parser's null literal. - The tautology check compares operands without allocating per candidate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- CHANGELOG.md | 1 + pkg/sql/security/dollar_quote.go | 97 +++-- pkg/sql/security/prefilter_test.go | 4 +- pkg/sql/security/scanner.go | 288 ++++++++----- pkg/sql/security/sqltext.go | 308 ++++++++++++++ pkg/sql/security/sqltext_test.go | 66 +++ pkg/sql/security/wp_p_bypass_test.go | 595 +++++++++++++++++++++++++++ 7 files changed, 1215 insertions(+), 144 deletions(-) create mode 100644 pkg/sql/security/sqltext.go create mode 100644 pkg/sql/security/sqltext_test.go create mode 100644 pkg/sql/security/wp_p_bypass_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2935c0517..5feba9778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 in dialects without them. ### Fixed +- **Security (`ScanSQL`)**: closed several ways to slip injection past the string scanner (also used by MCP, the C binding and WASM). Dollar-quote stripping no longer runs inside quoted strings or identifiers (`SELECT '$x$'; DROP TABLE users; SELECT '$x$'` was clean). Tautology and comment checks now cover the whole input instead of the first 10,000 bytes; inputs above 10 MiB get a CRITICAL `INPUT_TOO_LARGE` finding whatever the severity filter. Comments between keywords (`UNION/**/SELECT`, `SLEEP/**/(5)`, `;--\nDROP`, `#` comments, `/*/`, MySQL `1--1`) are matched through comment-free views of the text under each quoting and escaping convention, and MySQL/MariaDB `/*! */` and `/*M! */` bodies are scanned. The AST UNION NULL-padding check now matches the parser's NULL literal. The tautology check no longer allocates per candidate (1 MiB of `1=2` went from 50 MB to 1 MB). - 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/dollar_quote.go b/pkg/sql/security/dollar_quote.go index f6e520d9c..f52b2d3f3 100644 --- a/pkg/sql/security/dollar_quote.go +++ b/pkg/sql/security/dollar_quote.go @@ -14,63 +14,60 @@ package security -import ( - "regexp" - "strings" -) +import "strings" -// dollarQuotePattern matches PostgreSQL dollar-quoted strings: $$...$$ or $tag$...$tag$ -// Tags must be valid identifiers (letters, digits, underscore; cannot start with digit). -var dollarQuotePattern = regexp.MustCompile(`\$([A-Za-z_][A-Za-z0-9_]*)?\$`) - -// stripDollarQuotedStrings replaces the content of dollar-quoted strings with empty -// strings (preserving the delimiters) so that injection patterns inside string literals -// don't cause false positives, and injection patterns that use dollar-quoting to evade -// detection are neutralized. +// stripDollarQuotedStrings removes bodies of PostgreSQL ($$ and $tag$) and +// Snowflake ($$) quotes outside strings, identifiers and comments. Ambiguous +// escapes, block nesting and bracket identifiers must permit an opener in every +// interpretation before text +// is hidden. ScanSQL has no dialect: MySQL can use $x$ as an identifier, so a +// MySQL-only payload of that shape is still stripped. func stripDollarQuotedStrings(sql string) string { - // Every dollar-quote starts with '$'; skip the regex on the common path. if strings.IndexByte(sql, '$') < 0 { return sql } - - // Find all potential opening dollar-quote tags - matches := dollarQuotePattern.FindAllStringIndex(sql, -1) - if len(matches) == 0 { - return sql - } - - var result strings.Builder - result.Grow(len(sql)) - pos := 0 - - for pos < len(sql) { - // Find next dollar-quote opening from current position - loc := dollarQuotePattern.FindStringIndex(sql[pos:]) - if loc == nil { - result.WriteString(sql[pos:]) - break + // Cross product of escapes, nesting, bracket identifiers, hash comments and + // PostgreSQL E-string-only escapes. + var states [32]sqlLexState + resetDollarStates(&states) + var out strings.Builder + start := 0 + for i := 0; i < len(sql); i++ { + code := true + for j := range states { + code = code && states[j].code() } - - // Write everything before this match - openStart := pos + loc[0] - openEnd := pos + loc[1] - result.WriteString(sql[pos:openStart]) - - openTag := sql[openStart:openEnd] - - // Find the matching closing tag - closeIdx := strings.Index(sql[openEnd:], openTag) - if closeIdx == -1 { - // No closing tag - write the rest as-is (unterminated) - result.WriteString(sql[openStart:]) - break + if code && sql[i] == '$' && (i == 0 || !identifierByte(sql[i-1])) { + if end := dollarTagEnd(sql, i); end != 0 { + tag := sql[i:end] + closeAt := strings.Index(sql[end:], tag) + if closeAt < 0 { + break + } + if start == 0 { + out.Grow(len(sql)) + } + out.WriteString(sql[start:end]) + out.WriteString(tag) + i = end + closeAt + len(tag) - 1 + start = i + 1 + resetDollarStates(&states) + continue + } + } + for j := range states { + states[j].step(sql, i, j&1 != 0, j&2 != 0, j&8 != 0, j&4 != 0) } - - // Write opening tag, skip content, write closing tag - result.WriteString(openTag) - result.WriteString(openTag) - pos = openEnd + closeIdx + len(openTag) } + if start == 0 { + return sql + } + out.WriteString(sql[start:]) + return out.String() +} - return result.String() +func resetDollarStates(states *[32]sqlLexState) { + for j := range states { + states[j] = sqlLexState{eOnly: j&16 != 0} + } } diff --git a/pkg/sql/security/prefilter_test.go b/pkg/sql/security/prefilter_test.go index 5c8b29c09..08da8665e 100644 --- a/pkg/sql/security/prefilter_test.go +++ b/pkg/sql/security/prefilter_test.go @@ -31,7 +31,7 @@ import ( // equivalenceCorpus returns a deterministic set of ScanSQL inputs used to check // that the literal prefilters never change findings. It combines the repository // SQL corpus, the fuzz seeds, case/whitespace obfuscations, Unicode case-fold -// tricks, near-misses, structural edge cases and payloads beyond maxRegexInputLen. +// tricks, near-misses, structural edge cases and payloads beyond the former 10,000-byte cap. func equivalenceCorpus(t testing.TB) []string { t.Helper() @@ -216,7 +216,7 @@ func equivalenceCorpus(t testing.TB) []string { "\x00SLEEP(1)", ) - // Length edges: payloads after byte maxRegexInputLen (10_000). + // Length edges: payloads after the former 10,000-byte cap. pad := strings.Repeat("SELECT 1 ", 1200) corpus = append(corpus, pad+" UNION SELECT NULL,NULL FROM information_schema.tables", diff --git a/pkg/sql/security/scanner.go b/pkg/sql/security/scanner.go index f7e39094a..0eb25d74d 100644 --- a/pkg/sql/security/scanner.go +++ b/pkg/sql/security/scanner.go @@ -194,7 +194,6 @@ package security import ( "fmt" - "regexp" "strings" "sync" @@ -266,11 +265,11 @@ func initCompiledPatterns() { // Time-based blind injection functions PatternTimeBased: { patterns: []compiledPattern{ - compilePattern(`(?i)\bSLEEP\s*\(`), - compilePattern(`(?i)\bWAITFOR\s+DELAY\b`), - compilePattern(`(?i)\bpg_sleep\s*\(`), - compilePattern(`(?i)\bBENCHMARK\s*\(`), - compilePattern(`(?i)\bDBMS_LOCK\.SLEEP\s*\(`), + compilePattern(`(?i)\bSLEEP[\s\v]*\(`), + compilePattern(`(?i)\bWAITFOR[\s\v]+DELAY\b`), + compilePattern(`(?i)\bpg_sleep[\s\v]*\(`), + compilePattern(`(?i)\bBENCHMARK[\s\v]*\(`), + compilePattern(`(?i)\bDBMS_LOCK\.SLEEP[\s\v]*\(`), }, severity: SeverityHigh, risk: "Time-based blind SQL injection", @@ -281,16 +280,16 @@ func initCompiledPatterns() { PatternOutOfBand: { patterns: []compiledPattern{ compilePattern(`(?i)\bxp_cmdshell\b`), - compilePattern(`(?i)\bLOAD_FILE\s*\(`), - compilePattern(`(?i)\bINTO\s+OUTFILE\b`), - compilePattern(`(?i)\bINTO\s+DUMPFILE\b`), + compilePattern(`(?i)\bLOAD_FILE[\s\v]*\(`), + compilePattern(`(?i)\bINTO[\s\v]+OUTFILE\b`), + compilePattern(`(?i)\bINTO[\s\v]+DUMPFILE\b`), compilePattern(`(?i)\bUTL_HTTP\b`), compilePattern(`(?i)\bDBMS_LDAP\b`), - compilePattern(`(?i)\bEXEC\s+master\b`), + compilePattern(`(?i)\bEXEC[\s\v]+master\b`), compilePattern(`(?i)\bsp_oacreate\b`), // ClickHouse table functions that reach files, URLs, object // stores, other servers or external programs. - compilePattern(`(?i)\b(?:FROM|JOIN)\s+(?:url|urlCluster|file|fileCluster|remote|remoteSecure|s3|s3Cluster|gcs|azureBlobStorage|hdfs|hdfsCluster|jdbc|odbc|mysql|postgresql|mongodb|redis|sqlite|executable)\s*\(`), + compilePattern(`(?i)\b(?:FROM|JOIN)[\s\v]+(?:url|urlCluster|file|fileCluster|remote|remoteSecure|s3|s3Cluster|gcs|azureBlobStorage|hdfs|hdfsCluster|jdbc|odbc|mysql|postgresql|mongodb|redis|sqlite|executable)[\s\v]*\(`), }, severity: SeverityCritical, risk: "Out-of-band data exfiltration or command execution", @@ -300,10 +299,10 @@ func initCompiledPatterns() { // Dangerous functions that might indicate injection PatternDangerousFunc: { patterns: []compiledPattern{ - compilePattern(`(?i)\bEXEC\s*\(`), - compilePattern(`(?i)\bEXECUTE\s+IMMEDIATE\b`), + compilePattern(`(?i)\bEXEC[\s\v]*\(`), + compilePattern(`(?i)\bEXECUTE[\s\v]+IMMEDIATE\b`), compilePattern(`(?i)\bsp_executesql\b`), - compilePattern(`(?i)\bPREPARE\s+\w+\s+FROM\b`), + compilePattern(`(?i)\bPREPARE[\s\v]+\w+[\s\v]+FROM\b`), }, severity: SeverityMedium, risk: "Dynamic SQL execution vulnerability", @@ -317,10 +316,10 @@ func initCompiledPatterns() { PatternTautology: { patterns: []compiledPattern{ // OR TRUE - compilePattern(`(?i)\bOR\s+TRUE\b`), + compilePattern(`(?i)\bOR[\s\v]+TRUE\b`), // || TRUE: || is logical OR in MySQL and MariaDB (#781), so // ' || TRUE -- is the same bypass as ' OR TRUE --. - compilePattern(`(?i)\|\|\s*TRUE\b`), + compilePattern(`(?i)\|\|[\s\v]*TRUE\b`), }, severity: SeverityCritical, risk: "Authentication bypass via always-true condition", @@ -333,9 +332,9 @@ func initCompiledPatterns() { PatternUnionInjection: { patterns: []compiledPattern{ // System table access via UNION (injection fingerprint) - compilePattern(`(?i)UNION\s+(ALL\s+)?SELECT.*\b(information_schema|pg_catalog|mysql\b|sys\.)\b`), + compilePattern(`(?i)UNION[\s\v]+(ALL[\s\v]+)?SELECT.*\b(information_schema|pg_catalog|mysql\b|sys\.)\b`), // NULL-padded columns (classic injection to match column count) - compilePattern(`(?i)UNION\s+(ALL\s+)?SELECT\s+(?:NULL,?\s*){2,}`), + compilePattern(`(?i)UNION[\s\v]+(ALL[\s\v]+)?SELECT[\s\v]+(?:NULL,?[\s\v]*){2,}`), // Bare system catalog references (schema enumeration fingerprint) compilePattern(`(?i)\binformation_schema\b`), }, @@ -347,7 +346,7 @@ func initCompiledPatterns() { // Generic UNION SELECT (HIGH): may be legitimate or injection PatternUnionGeneric: { patterns: []compiledPattern{ - compilePattern(`(?i)\bUNION\s+(ALL\s+)?SELECT\b`), + compilePattern(`(?i)\bUNION[\s\v]+(ALL[\s\v]+)?SELECT\b`), }, severity: SeverityHigh, risk: "Possible UNION-based data extraction; review for legitimacy", @@ -357,9 +356,9 @@ func initCompiledPatterns() { // Stacked query injection patterns (destructive statements after semicolon) PatternStackedQuery: { patterns: []compiledPattern{ - compilePattern(`;\s*(?i)(DROP|DELETE|TRUNCATE|UPDATE|INSERT|ALTER)\b`), - compilePattern(`;\s*(?i)EXEC\b`), - compilePattern(`;\s*(?i)EXECUTE\b`), + compilePattern(`;[\s\v]*(?i)(DROP|DELETE|TRUNCATE|UPDATE|INSERT|ALTER)\b`), + compilePattern(`;[\s\v]*(?i)EXEC\b`), + compilePattern(`;[\s\v]*(?i)EXECUTE\b`), }, severity: SeverityCritical, risk: "Stacked query injection with destructive operations", @@ -371,32 +370,18 @@ func initCompiledPatterns() { // initCommentPatterns initializes comment detection patterns once. func initCommentPatterns() { commentPatterns = []commentPattern{ - {compilePattern(`--\s*$`), "Trailing SQL comment may indicate injection", SeverityMedium}, - {compilePattern(`--\s*['")\]]`), "Comment after quote/bracket (potential bypass)", SeverityHigh}, - {compilePattern(`/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*$`), "Unclosed or trailing block comment may indicate injection", SeverityMedium}, - {compilePattern(`/\*!.*\*/`), "MySQL conditional comment (version-specific execution)", SeverityMedium}, - {compilePattern(`#\s*$`), "Hash comment at end (MySQL)", SeverityMedium}, - {compilePattern(`;\s*--`), "Statement terminator followed by comment", SeverityHigh}, + {compilePattern(`--[\s\v]*$`), "Trailing SQL comment may indicate injection", SeverityMedium}, + {compilePattern(`--[\s\v]*['")\]]`), "Comment after quote/bracket (potential bypass)", SeverityHigh}, + {compilePattern(`/\*[^*]*\*+(?:[^/*][^*]*\*+)*/[\s\v]*$`), "Unclosed or trailing block comment may indicate injection", SeverityMedium}, + {compilePattern(`(?is)/\*(?:M)?!.*\*/`), "MySQL conditional comment (version-specific execution)", SeverityMedium}, + {compilePattern(`#[\s\v]*$`), "Hash comment at end (MySQL)", SeverityMedium}, + {compilePattern(`;[\s\v]*--`), "Statement terminator followed by comment", SeverityHigh}, } } -// maxRegexInputLen guards against ReDoS by limiting the input length -// fed to regexes with nested quantifiers (e.g. the block-comment pattern). -// -// The cap applies to the comment and tautology groups only; the other regex -// groups remain uncapped exactly as before, so findings on inputs longer than -// 10_000 bytes are unchanged. The literal prefilters always see the whole -// (dollar-quote-stripped) input, so a prefilter can never hide a match that lies -// beyond byte 10_000. Go's RE2 engine is linear-time, so this cap is a -// constant-factor guard rather than a catastrophic-backtracking fix. -const maxRegexInputLen = 10_000 - -func safeRegexMatch(re *regexp.Regexp, s string) bool { - if len(s) > maxRegexInputLen { - s = s[:maxRegexInputLen] - } - return re.MatchString(s) -} +// MaxScanInputSize matches tokenizer.MaxInputSize (10 MiB). Larger inputs are +// rejected with an unconditional CRITICAL finding before lexical processing or regex matching. +const MaxScanInputSize = 10 * 1024 * 1024 // initTautologyCapturePatterns initializes patterns used to detect raw-SQL tautologies. // Because Go's RE2 engine does not support backreferences, each pattern captures both @@ -405,11 +390,11 @@ func safeRegexMatch(re *regexp.Regexp, s string) bool { func initTautologyCapturePatterns() { tautologyCapturePatterns = []compiledPattern{ // Numeric: two identical digit sequences around '=' (bounded to prevent ReDoS) - compilePattern(`(?i)\b(\d{1,6})\s*=\s*(\d{1,6})\b`), + compilePattern(`(?i)\b(\d{1,6})[\s\v]*=[\s\v]*(\d{1,6})\b`), // String literal: 'value' = 'value' (max 50 chars per side, no backtracking risk) - compilePattern(`(?i)('[^']{0,50}')\s*=\s*('[^']{0,50}')`), + compilePattern(`(?i)('[^']{0,50}')[\s\v]*=[\s\v]*('[^']{0,50}')`), // Identifier: col = col (bounded length, word chars + dots) - compilePattern(`(?i)\b([a-z_][a-z0-9_.]{0,50})\s*=\s*([a-z_][a-z0-9_.]{0,50})\b`), + compilePattern(`(?i)\b([a-z_][a-z0-9_.]{0,50})[\s\v]*=[\s\v]*([a-z_][a-z0-9_.]{0,50})\b`), } } @@ -438,6 +423,9 @@ var systemTableNames = []string{ type PatternType string const ( + // PatternInputTooLarge indicates input that was not scanned due to its size. + PatternInputTooLarge PatternType = "INPUT_TOO_LARGE" + // PatternTautology detects always-true conditions (1=1, 'a'='a') used to bypass authentication PatternTautology PatternType = "TAUTOLOGY" @@ -676,6 +664,18 @@ func (s *Scanner) scanSQL(sql string, prefilter bool) *ScanResult { Findings: make([]Finding, 0), } + if len(sql) > MaxScanInputSize { + result.Findings = append(result.Findings, Finding{ + Severity: SeverityCritical, + Pattern: PatternInputTooLarge, + Description: fmt.Sprintf("input exceeds maximum scannable size (%d bytes > %d bytes); not scanned", len(sql), MaxScanInputSize), + Risk: "SQL could not be checked for injection", + Suggestion: "Reject oversized SQL or reduce it below the scanning limit", + }) + s.updateCounts(result) + return result + } + // Strip dollar-quoted string content to prevent false positives sql = stripDollarQuotedStrings(sql) @@ -685,6 +685,11 @@ func (s *Scanner) scanSQL(sql string, prefilter bool) *ScanResult { if prefilter { bp := getFoldBuf() defer putFoldBuf(bp) + // Folding never grows beyond the input length. Reserve once so large + // inputs do not allocate a succession of increasingly large buffers. + if cap(*bp) < len(sql) { + *bp = make([]byte, 0, len(sql)) + } folded = foldASCII((*bp)[:0], sql) *bp = folded } @@ -692,30 +697,34 @@ func (s *Scanner) scanSQL(sql string, prefilter bool) *ScanResult { // Check for comment-based bypass patterns in raw SQL s.detectCommentPatterns(sql, folded, result) + // Normalized views only add matches. Required prefilter literals contain no + // whitespace, so removing comments cannot introduce a literal absent in raw. + views := append([]string{sql}, commentViews(sql)...) + // Check for tautology patterns (OR 1=1, 'a'='a', etc.) // detectRegexPatterns handles the simple OR TRUE pattern; detectTautologyInSQL // handles equality-based tautologies using a two-step capture approach (RE2 // does not support backreferences, so equality is verified programmatically). - s.detectRegexPatterns(sql, folded, PatternTautology, result) - s.detectTautologyInSQL(sql, folded, result) + s.detectRegexPatterns(views, folded, PatternTautology, result) + s.detectTautologyInSQL(views, folded, result) // Check for time-based patterns - s.detectRegexPatterns(sql, folded, PatternTimeBased, result) + s.detectRegexPatterns(views, folded, PatternTimeBased, result) // Check for out-of-band patterns - s.detectRegexPatterns(sql, folded, PatternOutOfBand, result) + s.detectRegexPatterns(views, folded, PatternOutOfBand, result) // Check for dangerous function patterns - s.detectRegexPatterns(sql, folded, PatternDangerousFunc, result) + s.detectRegexPatterns(views, folded, PatternDangerousFunc, result) // Check for UNION injection fingerprints (CRITICAL: system tables, NULL-padding) - s.detectRegexPatterns(sql, folded, PatternUnionInjection, result) + s.detectRegexPatterns(views, folded, PatternUnionInjection, result) // Check for generic UNION SELECT (HIGH: may be legitimate, but warrants review) - s.detectRegexPatterns(sql, folded, PatternUnionGeneric, result) + s.detectRegexPatterns(views, folded, PatternUnionGeneric, result) // Check for stacked query patterns - s.detectRegexPatterns(sql, folded, PatternStackedQuery, result) + s.detectRegexPatterns(views, folded, PatternStackedQuery, result) // Update counts s.updateCounts(result) @@ -1042,8 +1051,15 @@ func (s *Scanner) checkUnionSelect(rightSelect *ast.SelectStatement, result *Sca // Check for NULL placeholders (common in UNION injection) nullCount := 0 for _, col := range rightSelect.Columns { - if ident, ok := col.(*ast.Identifier); ok { - if strings.ToUpper(ident.Name) == "NULL" { + if alias, ok := col.(*ast.AliasedExpression); ok && alias != nil { + col = alias.Expr + } + if literal, ok := col.(*ast.LiteralValue); ok && literal != nil { + if strings.EqualFold(literal.Type, "null") { + nullCount++ + } + } else if ident, ok := col.(*ast.Identifier); ok && ident != nil { + if strings.EqualFold(ident.Name, "NULL") { nullCount++ } } @@ -1266,7 +1282,7 @@ func (s *Scanner) detectCommentPatterns(sql string, folded []byte, result *ScanR if folded != nil && !containsAll(folded, p.lits) { continue } - if safeRegexMatch(p.re, sql) { + if p.re.MatchString(sql) { finding := Finding{ Severity: p.severity, Pattern: PatternComment, @@ -1281,48 +1297,133 @@ func (s *Scanner) detectCommentPatterns(sql string, folded []byte, result *ScanR } } -// detectTautologyInSQL checks raw SQL for tautology patterns (e.g. OR 1=1, 'a'='a'). +// detectTautologyInSQL checks raw and normalized views for tautology patterns (e.g. OR 1=1, 'a'='a'). // Because Go's RE2 engine does not support backreferences, equality of the two sides // is verified programmatically after the regex captures both groups. folded is the // case-folded input used for literal prefiltering; nil disables prefiltering. -func (s *Scanner) detectTautologyInSQL(sql string, folded []byte, result *ScanResult) { +func (s *Scanner) detectTautologyInSQL(views []string, folded []byte, result *ScanResult) { // Ensure patterns are initialized tautologyCaptureOnce.Do(initTautologyCapturePatterns) - // Guard against very long inputs (ReDoS mitigation) - input := sql - if len(input) > maxRegexInputLen { - input = input[:maxRegexInputLen] - } - - for _, p := range tautologyCapturePatterns { + for patternIndex, p := range tautologyCapturePatterns { if folded != nil && !containsAll(folded, p.lits) { continue } - matches := p.re.FindAllStringSubmatch(input, -1) - for _, m := range matches { - if len(m) == 3 && strings.EqualFold(m[1], m[2]) { - finding := Finding{ - Severity: SeverityCritical, - Pattern: PatternTautology, - Description: "Always-true condition detected (tautology): " + m[0], - Risk: "Authentication bypass via always-true condition", - Suggestion: "Use parameterized queries to prevent tautology injection", + viewsLoop: + for _, input := range views { + // Pattern zero captures whole numeric operands. Reject unequal + // digit runs before invoking RE2 repeatedly: under race detection, + // sync.Pool intentionally drops regex machines on frequent calls. + // The unfiltered path remains the equivalence-test reference. + if patternIndex == 0 && folded != nil && !hasEqualNumericPair(input) { + continue + } + for rest := input; rest != ""; { + // FindString uses a stack capture buffer; SubmatchIndex allocates + // an index slice for every unequal pair on large inputs. + m := p.re.FindString(rest) + if m == "" { + break } - if s.shouldInclude(finding.Severity) { - result.Findings = append(result.Findings, finding) + if equalSQLPair(m) { + finding := Finding{ + Severity: SeverityCritical, + Pattern: PatternTautology, + Description: "Always-true condition detected (tautology): " + m, + Risk: "Authentication bypass via always-true condition", + Suggestion: "Use parameterized queries to prevent tautology injection", + } + if s.shouldInclude(finding.Severity) { + result.Findings = append(result.Findings, finding) + } + // Report at most one tautology finding per pattern across all views. + break viewsLoop } - // Report at most one tautology finding per pattern to avoid noise - break + rest = rest[sqlPairMatchEnd(rest, m):] } } } } +// sqlPairMatchEnd locates the exact capture-pattern match without allocating an +// index slice. Numeric/identifier patterns require ASCII word boundaries; an +// earlier occurrence of the same text inside an identifier is not that match. +// The quoted pattern has no boundary assertions. All patterns match nonempty +// text, and advancing to its actual end preserves the next match's boundary. +func sqlPairMatchEnd(sql, match string) int { + for offset := 0; offset < len(sql); { + relative := strings.Index(sql[offset:], match) + if relative < 0 { + break + } + start := offset + relative + end := start + len(match) + if match[0] == '\'' || sqlWordBoundary(sql, start) && sqlWordBoundary(sql, end) { + return end + } + offset = start + 1 + } + // FindString guarantees an occurrence satisfying these same boundaries. + return len(sql) +} + +// hasEqualNumericPair is a necessary condition for the numeric capture pattern +// to report a tautology. Ignoring word boundaries and operand length only adds +// candidates. Digit and whitespace runs are visited at most twice, so it is O(n). +func hasEqualNumericPair(sql string) bool { + for offset := 0; offset < len(sql); { + relative := strings.IndexByte(sql[offset:], '=') + if relative < 0 { + return false + } + eq := offset + relative + offset = eq + 1 + left, right := eq, eq+1 + for left > 0 && sqlWhitespace(sql[left-1]) { + left-- + } + for right < len(sql) && sqlWhitespace(sql[right]) { + right++ + } + start, end := left, right + for start > 0 && sql[start-1] >= '0' && sql[start-1] <= '9' { + start-- + } + for end < len(sql) && sql[end] >= '0' && sql[end] <= '9' { + end++ + } + if start < left && right < end && sql[start:left] == sql[right:end] { + return true + } + } + return false +} + +// equalSQLPair compares the two operands in a capture-pattern match without +// allocating. The string-literal pattern permits '=' inside either operand; +// its first closing quote identifies where to start looking for the separator. +func equalSQLPair(match string) bool { + start := 0 + if len(match) > 0 && match[0] == '\'' { + end := strings.IndexByte(match[1:], '\'') + if end < 0 { + return false + } + start = end + 2 + } + eq := strings.IndexByte(match[start:], '=') + if eq < 0 { + return false + } + eq += start + const whitespace = " \t\r\n\f\v" + return strings.EqualFold(strings.Trim(match[:eq], whitespace), strings.Trim(match[eq+1:], whitespace)) +} + // detectRegexPatterns checks SQL against the compiled regex group for patternType. // folded is the case-folded input used for literal prefiltering; nil disables // prefiltering. -func (s *Scanner) detectRegexPatterns(sql string, folded []byte, patternType PatternType, result *ScanResult) { +func (s *Scanner) detectRegexPatterns(views []string, folded []byte, patternType PatternType, result *ScanResult) { // Ensure patterns are initialized compiledPatternsOnce.Do(initCompiledPatterns) @@ -1339,16 +1440,19 @@ func (s *Scanner) detectRegexPatterns(sql string, folded []byte, patternType Pat // TestCompiledPatterns_AllHaveLiterals), so FindString returning "" means // no match, and a non-empty result is the same leftmost-first match that // FindStringSubmatch(sql)[0] would return. - if m := p.re.FindString(sql); m != "" { - finding := Finding{ - Severity: group.severity, - Pattern: patternType, - Description: "Pattern detected: " + m, - Risk: group.risk, - Suggestion: group.suggestion, - } - if s.shouldInclude(finding.Severity) { - result.Findings = append(result.Findings, finding) + for _, sql := range views { + if m := p.re.FindString(sql); m != "" { + finding := Finding{ + Severity: group.severity, + Pattern: patternType, + Description: "Pattern detected: " + m, + Risk: group.risk, + Suggestion: group.suggestion, + } + if s.shouldInclude(finding.Severity) { + result.Findings = append(result.Findings, finding) + } + break } } } diff --git a/pkg/sql/security/sqltext.go b/pkg/sql/security/sqltext.go new file mode 100644 index 000000000..b9ad70c32 --- /dev/null +++ b/pkg/sql/security/sqltext.go @@ -0,0 +1,308 @@ +// 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 "strings" + +// sqlLexState tracks one dialect interpretation without decoding UTF-8. skip +// consumes the second byte of a doubled quote, escape, or comment delimiter. +type sqlLexState struct { + quote byte + line bool + depth int + skip bool + // mysqlDash starts a -- comment only before whitespace, a control + // character or the end of input, as MySQL does; 1--1 is arithmetic there. + mysqlDash bool + // eOnly applies backslash escapes only to E'...' strings, as PostgreSQL + // does when standard_conforming_strings is on; eQuote marks such a string. + eOnly bool + eQuote bool +} + +// eStringPrefix reports whether the quote at s[i] is prefixed by a lone E/e. +func eStringPrefix(s string, i int) bool { + return i > 0 && (s[i-1] == 'E' || s[i-1] == 'e') && (i < 2 || !identifierByte(s[i-2])) +} + +// dashComment reports whether "--" at s[i] starts a line comment. +func dashComment(s string, i int, mysqlDash bool) bool { + if s[i] != '-' || i+1 >= len(s) || s[i+1] != '-' { + return false + } + return !mysqlDash || i+2 >= len(s) || s[i+2] <= ' ' +} + +// hasMySQLArithmeticDash reports whether some "--" is followed by a byte that +// MySQL would not treat as a comment start. +func hasMySQLArithmeticDash(s string) bool { + for i := strings.Index(s, "--"); i >= 0; { + if i+2 < len(s) && s[i+2] > ' ' { + return true + } + next := strings.Index(s[i+1:], "--") + if next < 0 { + return false + } + i += 1 + next + } + return false +} + +func (l *sqlLexState) code() bool { return l.quote == 0 && !l.line && l.depth == 0 && !l.skip } + +func (l *sqlLexState) step(s string, i int, escapes, nested, hash, brackets bool) { + if l.skip { + l.skip = false + return + } + c := s[i] + var next byte + if i+1 < len(s) { + next = s[i+1] + } + if l.line { + if c == '\n' || c == '\r' { + l.line = false + } + return + } + if l.depth > 0 { + if c == '*' && next == '/' { + l.depth-- + l.skip = true + } else if nested && c == '/' && next == '*' { + l.depth++ + l.skip = true + } + return + } + if l.quote != 0 { + if (escapes || l.eQuote) && (l.quote == '\'' || l.quote == '"') && c == '\\' && i+1 < len(s) { + l.skip = true + } else if c == l.quote { + if next == c { + l.skip = true + } else { + l.quote = 0 + } + } + return + } + switch { + case c == '\'' || c == '"' || c == '`': + l.quote = c + l.eQuote = l.eOnly && c == '\'' && eStringPrefix(s, i) + case brackets && c == '[': + l.quote = ']' + case c == '-' && next == '-' && dashComment(s, i, l.mysqlDash), hash && c == '#': + l.line = true + case c == '/' && next == '*': + l.depth = 1 + l.skip = true + } +} + +func identifierByte(c byte) bool { + return c >= 0x80 || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || + c >= '0' && c <= '9' || c == '_' || c == '$' +} + +func sqlWhitespace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v' +} + +func sqlWordBoundary(sql string, pos int) bool { + word := func(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' + } + before := pos > 0 && word(sql[pos-1]) + after := pos < len(sql) && word(sql[pos]) + return before != after +} + +// dollarTagEnd implements $([A-Za-z_][A-Za-z0-9_]*)?$ without a regex. +func dollarTagEnd(s string, i int) int { + j := i + 1 + if j == len(s) { + return 0 + } + if s[j] == '$' { + return j + 1 + } + if !tagLetter(s[j]) { + return 0 + } + for j++; j < len(s); j++ { + if s[j] == '$' { + return j + 1 + } + if !tagLetter(s[j]) && !(s[j] >= '0' && s[j] <= '9') { + return 0 + } + } + return 0 +} + +func tagLetter(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' +} + +// commentViews adds interpretations for matching; it never replaces the raw view. +// Build the PostgreSQL nested view only when a nested opener occurred in a block. +func commentViews(sql string) []string { + if !strings.Contains(sql, "/*") && !strings.Contains(sql, "--") && !strings.Contains(sql, "#") { + return nil + } + // Interpret escapes, brackets and hash comments independently: any dialect + // view may expose executable code. Omit modes that cannot change this input. + var enabled uint8 + if strings.Contains(sql, "\\") { + enabled |= 1 + } + if strings.Contains(sql, "[") { + enabled |= 2 + } + if strings.Contains(sql, "#") { + enabled |= 4 + } + if hasMySQLArithmeticDash(sql) { + enabled |= 8 + } + if strings.Contains(sql, "\\") && (strings.Contains(sql, "E'") || strings.Contains(sql, "e'")) { + enabled |= 16 + } + var views []string + add := func(view string) { + if view == sql { + return + } + for _, old := range views { + if old == view { + return + } + } + views = append(views, view) + } + for mode := uint8(0); mode < 32; mode++ { + if mode & ^enabled != 0 { + continue + } + var flat string + var nested bool + if mode == 0 { + flat, nested = normalizeComments(sql, false) + } else { + flat, nested = normalizeCommentView(sql, false, mode&1 != 0, mode&2 != 0, mode&4 != 0, mode&8 != 0, mode&16 != 0) + } + add(flat) + if nested { + pg, _ := normalizeCommentView(sql, true, mode&1 != 0, mode&2 != 0, mode&4 != 0, mode&8 != 0, mode&16 != 0) + add(pg) + } + } + return views +} + +func normalizeComments(sql string, nested bool) (string, bool) { + return normalizeCommentView(sql, nested, false, false, false, false, false) +} + +func normalizeCommentView(sql string, nested, escapes, brackets, hash, mysqlDash, eOnly bool) (string, bool) { + var out strings.Builder + lex := sqlLexState{mysqlDash: mysqlDash, eOnly: eOnly} + start := 0 + hasNested := false + for i := 0; i < len(sql); i++ { + if lex.code() && i+1 < len(sql) { + if dashComment(sql, i, mysqlDash) || hash && sql[i] == '#' { + if start == 0 { + out.Grow(len(sql)) + } + out.WriteString(sql[start:i]) + out.WriteByte(' ') + width := 2 + if sql[i] == '#' { + width = 1 + } + for i += width; i < len(sql) && sql[i] != '\n' && sql[i] != '\r'; i++ { + } + start = i + i-- + continue + } + if sql[i] == '/' && sql[i+1] == '*' { + if start == 0 { + out.Grow(len(sql)) + } + out.WriteString(sql[start:i]) + out.WriteByte(' ') + body := i + 2 + executable := body < len(sql) && sql[body] == '!' + if body+1 < len(sql) && (sql[body] == 'M' || sql[body] == 'm') && sql[body+1] == '!' { + executable = true + body++ + } + if executable { + body++ + for body < len(sql) && sql[body] >= '0' && sql[body] <= '9' { + body++ + } + } + j, depth := i+2, 1 + for j+1 < len(sql) { + if sql[j] == '*' && sql[j+1] == '/' { + depth-- + if depth == 0 { + break + } + j += 2 + } else if sql[j] == '/' && sql[j+1] == '*' { + hasNested = true + if nested { + depth++ + j += 2 + } else { + // Flat comments may close at the overlapping */ in /*/. + j++ + } + } else { + j++ + } + } + if depth != 0 { + j = len(sql) + } + if executable && depth == 0 { + out.WriteString(sql[body:j]) + out.WriteByte(' ') + } + if j < len(sql) { + j += 2 + } + start = j + i = j - 1 + continue + } + } + // This interpretation only adds matches; the raw view is always retained. + lex.step(sql, i, escapes, nested, hash, brackets) + } + if start == 0 { + return sql, hasNested + } + out.WriteString(sql[start:]) + return out.String(), hasNested +} diff --git a/pkg/sql/security/sqltext_test.go b/pkg/sql/security/sqltext_test.go new file mode 100644 index 000000000..b039f1e3e --- /dev/null +++ b/pkg/sql/security/sqltext_test.go @@ -0,0 +1,66 @@ +// 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 "testing" + +func TestNormalizeComments(t *testing.T) { + tests := []struct { + input, flat, nested string + }{ + {"a/*x*/b", "a b", "a b"}, + {"a--x\rb", "a \rb", "a \rb"}, + {"a--x\nb", "a \nb", "a \nb"}, + {"a/*unfinished", "a ", "a "}, + {"a/*!50000unfinished", "a ", "a "}, + {"a/*!50000UNION*/b", "a UNION b", "a UNION b"}, + {"a/*!UNION*/b", "a UNION b", "a UNION b"}, + {"a/*+ hint */b", "a b", "a b"}, + {"a/* /* */ */b", "a */b", "a b"}, + {"a#x\nb", "a#x\nb", "a#x\nb"}, + {"'a''/*b*/' --x", "'a''/*b*/' ", "'a''/*b*/' "}, + {"\"a\"\"/*b*/\"", "\"a\"\"/*b*/\"", "\"a\"\"/*b*/\""}, + {"`a``--b`", "`a``--b`", "`a``--b`"}, + {"\xff/*x*/\xfe", "\xff \xfe", "\xff \xfe"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + for _, nested := range []bool{false, true} { + want := tt.flat + if nested { + want = tt.nested + } + if got, _ := normalizeComments(tt.input, nested); got != want { + t.Errorf("nested=%v: got %q, want %q", nested, got, want) + } + } + }) + } +} + +func FuzzSQLTextViews(f *testing.F) { + for _, seed := range []string{"", "$", "-", "/", "'", "\xff", "/*!", "/*/*/", "$$raw$$", "'a\\' $x$"} { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, sql string) { + stripped := stripDollarQuotedStrings(sql) + if len(stripped) > len(sql) { + t.Fatal("stripping grew input") + } + for _, nested := range []bool{false, true} { + _, _ = normalizeComments(stripped, nested) + } + }) +} diff --git a/pkg/sql/security/wp_p_bypass_test.go b/pkg/sql/security/wp_p_bypass_test.go new file mode 100644 index 000000000..47bf1ce41 --- /dev/null +++ b/pkg/sql/security/wp_p_bypass_test.go @@ -0,0 +1,595 @@ +// Copyright 2026 GoSQLX Authors +// SPDX-License-Identifier: Apache-2.0 + +package security + +import ( + "fmt" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" +) + +func wpPHasPattern(result *ScanResult, pattern PatternType) bool { + for _, f := range result.Findings { + if f.Pattern == pattern { + return true + } + } + return false +} + +func TestWPPDollarOpenersRespectLexicalContext(t *testing.T) { + for _, prefix := range []string{ + `SELECT '$tag$'`, `SELECT "$tag$"`, "SELECT `$tag$`", + "SELECT 1 -- $tag$\n", "SELECT 1 # $tag$\n", + "SELECT 1 /* $tag$ */", "SELECT 1 /* outer /* $tag$ */ outer */", + } { + t.Run(prefix, func(t *testing.T) { + sql := prefix + " ; DROP TABLE accounts; SELECT $tag$" + if !wpPHasPattern(NewScanner().ScanSQL(sql), PatternStackedQuery) { + t.Fatalf("false dollar opener hid executable DROP: %q", sql) + } + }) + } + for _, sql := range []string{ + `SELECT foo$tag$; DROP TABLE accounts; $tag$`, + `SELECT foo$$; DROP TABLE accounts; $$`, + `SELECT $9tag$; DROP TABLE accounts; $9tag$`, + `SELECT $bad-tag$; DROP TABLE accounts; $bad-tag$`, + `SELECT 'backslash\' $tag$; DROP TABLE accounts; $tag$`, + } { + t.Run(sql, func(t *testing.T) { + if !wpPHasPattern(NewScanner().ScanSQL(sql), PatternStackedQuery) { + t.Fatalf("ambiguous or invalid dollar opener hid DROP: %q", sql) + } + }) + } + for _, sql := range []string{ + `SELECT $$; DROP TABLE accounts; SLEEP(1)$$`, + `SELECT $_tag9$; DROP TABLE accounts; SLEEP(1)$_tag9$`, + `SELECT $tag$' -- /* ; DROP TABLE accounts $tag$`, + } { + if result := NewScanner().ScanSQL(sql); result.HasHighOrAbove() { + t.Errorf("dollar literal body was scanned: %q: %+v", sql, result.Findings) + } + } +} + +func TestWPPStripDollarQuotedStringsLexicalRules(t *testing.T) { + for _, sql := range []string{ + `SELECT '$tag$' ; DROP TABLE t; $tag$`, + `SELECT "$tag$" ; DROP TABLE t; $tag$`, + "SELECT `$tag$` ; DROP TABLE t; $tag$", + "SELECT 1 -- $tag$\n; DROP TABLE t; $tag$", + "SELECT 1 # $tag$\n; DROP TABLE t; $tag$", + "SELECT 1 /* $tag$ */; DROP TABLE t; $tag$", + "SELECT 1 /* outer /* inner */ $tag$ outer */; DROP TABLE t; $tag$", + `SELECT 'a''$tag$' ; DROP TABLE t; $tag$`, + `SELECT "a""$tag$" ; DROP TABLE t; $tag$`, + "SELECT `a``$tag$` ; DROP TABLE t; $tag$", + `SELECT 'backslash\' $tag$; DROP TABLE t; $tag$`, + `SELECT foo$tag$; DROP TABLE t; $tag$`, + `SELECT 名$tag$; DROP TABLE t; $tag$`, + `SELECT foo$$; DROP TABLE t; $$`, + `SELECT $9tag$; DROP TABLE t; $9tag$`, + `SELECT $bad-tag$; DROP TABLE t; $bad-tag$`, + } { + if got := stripDollarQuotedStrings(sql); got != sql { + t.Errorf("strip changed non-opener context: %q -> %q", sql, got) + } + } + for _, tt := range []struct{ sql, want string }{ + {"SELECT $$payload$$", "SELECT $$$$"}, + {"SELECT $_tag9$payload$_tag9$", "SELECT $_tag9$$_tag9$"}, + {"SELECT $Case$payload$Case$", "SELECT $Case$$Case$"}, + {"SELECT $Case$payload$case$", "SELECT $Case$payload$case$"}, + {"SELECT $tag$unterminated", "SELECT $tag$unterminated"}, + } { + if got := stripDollarQuotedStrings(tt.sql); got != tt.want { + t.Errorf("strip(%q) = %q, want %q", tt.sql, got, tt.want) + } + } +} + +type wpPBypassCase struct { + name, sql string + pattern PatternType +} + +func wpPCommentBypasses() []wpPBypassCase { + return []wpPBypassCase{ + {"union empty", "SELECT 1 UNION/**/SELECT 2", PatternUnionGeneric}, + {"union all", "SELECT 1 UNION/*a*/ALL/*b*/SELECT 2", PatternUnionGeneric}, + {"union adjacent", "SELECT 1 UNION/*a*//*b*/SELECT 2", PatternUnionGeneric}, + {"union mixed case", "SELECT 1 UnIoN/*x*/aLl/**/sElEcT 2", PatternUnionGeneric}, + {"union nested", "SELECT 1 UNION/* outer /* inner */ outer */SELECT 2", PatternUnionGeneric}, + {"union line", "SELECT 1 UNION-- ignored\nSELECT 2", PatternUnionGeneric}, + {"null padding", "SELECT 1,2 UNION/**/SELECT/**/NULL,/**/NULL", PatternUnionInjection}, + {"sleep", "SELECT SLEEP/**/(5)", PatternTimeBased}, + {"pg sleep", "SELECT pg_sleep/*pause*/(5)", PatternTimeBased}, + {"waitfor", "WAITFOR/*pause*/DELAY '0:0:5'", PatternTimeBased}, + {"stacked", "SELECT 1;/*pause*/DROP TABLE accounts", PatternStackedQuery}, + {"stacked line", "SELECT 1;-- ignored\nDROP TABLE accounts", PatternStackedQuery}, + {"numeric tautology", "SELECT 1 WHERE x=2 OR/**/1/*x*/=/*x*/1", PatternTautology}, + {"true tautology", "SELECT 1 WHERE x=2 OR/*x*/TRUE", PatternTautology}, + {"executable comment", "SELECT 1 /*! UNION SELECT NULL,NULL */", PatternUnionInjection}, + {"versioned executable comment", "SELECT 1 /*!50000 UNION SELECT NULL,NULL */", PatternUnionInjection}, + {"tight versioned union", "SELECT 1 /*!50000UNION*/ SELECT password FROM users", PatternUnionGeneric}, + {"executable stacked", "SELECT * FROM t; /*!DROP TABLE users*/", PatternStackedQuery}, + } +} + +func wpPRound2Bypasses() []wpPBypassCase { + return []wpPBypassCase{ + {"escaped single union", `SELECT 1 FROM t WHERE a='\'' UNION/**/SELECT 1,2`, PatternUnionGeneric}, + {"escaped E string union", `SELECT 1 FROM t WHERE a=E'\'' UNION/**/SELECT 1,2`, PatternUnionGeneric}, + {"escaped single stacked", `SELECT 1 FROM t WHERE a='\'' ;/**/DROP TABLE x`, PatternStackedQuery}, + {"escaped single tautology", `SELECT 1 FROM t WHERE a='\'' OR /**/ 1 /**/ = /**/ 1`, PatternTautology}, + {"three backslashes union", `SELECT 'a\\\'' UNION/**/SELECT 1,2`, PatternUnionGeneric}, + {"escaped double dollar opener", `SELECT "a\" $a$ " ; DROP TABLE users; -- $a$`, PatternStackedQuery}, + {"escaped double union", `SELECT "\"" UNION/**/SELECT 1,2`, PatternUnionGeneric}, + {"bracket dollar opener", `SELECT [a $x$ ]; DROP TABLE users; SELECT 1 AS [$x$]`, PatternStackedQuery}, + {"bracket quote union", `SELECT [a'] UNION/**/SELECT 1,2 FROM [b']`, PatternUnionGeneric}, + {"hash union", "SELECT 1 UNION#\nSELECT 2", PatternUnionGeneric}, + {"mysql dash arithmetic union", `SELECT 1--1 UNION/**/SELECT 2`, PatternUnionGeneric}, + {"mysql dash arithmetic stacked", `SELECT 1--1 ;/**/DROP TABLE x`, PatternStackedQuery}, + {"pg E string then plain backslash union", `SELECT E'\'', 'a\' UNION/**/SELECT 1,2`, PatternUnionGeneric}, + {"pg E string then plain backslash stacked", `SELECT E'\'', 'a\' ;/**/DROP TABLE x`, PatternStackedQuery}, + {"pg plain E plain union", `SELECT 'a\' || E'\'' || 'b\' UNION/**/SELECT 1,2`, PatternUnionGeneric}, + {"pg E string dollar opener", `SELECT E'\'', 'a\' $x$'; DROP TABLE users; SELECT '$x$'`, PatternStackedQuery}, + {"hash stacked", "SELECT 1 ;#\nDROP TABLE x", PatternStackedQuery}, + {"hash sleep", "SELECT SLEEP#\n(5)", PatternTimeBased}, + {"overlapping comment union", "SELECT 1 /* x /*/ UNION/**/SELECT 2", PatternUnionGeneric}, + {"overlapping comment sleep", "SELECT 1 /* x /*/ SLEEP/**/(5)", PatternTimeBased}, + {"mariadb executable union", "SELECT 1 /*M!100100 UNION*/ /*M!100100 SELECT 2*/", PatternUnionGeneric}, + {"vertical tab union", "SELECT 1 UNION\vSELECT 2", PatternUnionGeneric}, + {"form feed union", "SELECT 1 UNION\fSELECT 2", PatternUnionGeneric}, + } +} + +func TestWPPRound2Bypasses(t *testing.T) { + for _, tt := range wpPRound2Bypasses() { + t.Run(tt.name, func(t *testing.T) { + result := NewScanner().ScanSQL(tt.sql) + found := false + for _, f := range result.Findings { + if f.Pattern == tt.pattern { + found = true + if f.Severity != SeverityHigh && f.Severity != SeverityCritical { + t.Errorf("%s severity = %s, want HIGH or CRITICAL", tt.pattern, f.Severity) + } + } + } + if !found { + t.Errorf("missing %s for %q: %+v", tt.pattern, tt.sql, result.Findings) + } + if strings.Contains(tt.sql, "/*M!") && !wpPHasPattern(result, PatternComment) { + t.Errorf("missing MariaDB executable comment finding: %+v", result.Findings) + } + }) + } +} + +func TestWPPTautologyAllocationBound(t *testing.T) { + const size = 1 << 20 + sql := strings.Repeat("1=2 ", size/4) + scanner := NewScanner() + _ = scanner.ScanSQL(sql) // Warm pattern initialization and the folded buffer pool. + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + result := scanner.ScanSQL(sql) + runtime.ReadMemStats(&after) + runtime.KeepAlive(result) + allocated := after.TotalAlloc - before.TotalAlloc + t.Logf("ScanSQL allocated %d bytes for %d bytes of unequal pairs", allocated, len(sql)) + if allocated > 4*uint64(len(sql)) { + t.Errorf("ScanSQL allocated %d bytes for %d bytes of unequal pairs; want <= 4x input", allocated, len(sql)) + } +} + +func TestWPPCommentNormalizedDetection(t *testing.T) { + for _, tt := range wpPCommentBypasses() { + t.Run(tt.name, func(t *testing.T) { + result := NewScanner().ScanSQL(tt.sql) + if !wpPHasPattern(result, tt.pattern) { + t.Fatalf("missing %s: %+v", tt.pattern, result.Findings) + } + if strings.Contains(tt.sql, "/*!") && !wpPHasPattern(result, PatternComment) { + t.Fatalf("lost raw COMMENT_BYPASS finding: %+v", result.Findings) + } + if strings.Contains(tt.sql, "/*!") { + conditional := false + for _, f := range result.Findings { + if f.Pattern == PatternComment && strings.Contains(f.Description, "MySQL conditional") { + conditional = true + } + } + if !conditional { + t.Errorf("missing MySQL conditional comment description: %+v", result.Findings) + } + } + }) + } +} + +func TestWPPCommentFindingsPreserveMetadataAndDeduplicate(t *testing.T) { + result := NewScanner().ScanSQL("SELECT 1 UNION/**/SELECT 2; --") + want := map[string]Severity{ + "Trailing SQL comment may indicate injection": SeverityMedium, + "Statement terminator followed by comment": SeverityHigh, + } + unionCount := 0 + for _, finding := range result.Findings { + if finding.Pattern == PatternUnionGeneric { + unionCount++ + } + if finding.Pattern != PatternComment { + continue + } + severity, ok := want[finding.Description] + if !ok { + t.Fatalf("unexpected or duplicated comment finding: %+v", finding) + } + if finding.Severity != severity { + t.Errorf("comment severity = %s, want %s", finding.Severity, severity) + } + delete(want, finding.Description) + } + if len(want) != 0 { + t.Errorf("missing raw comment metadata: %+v", want) + } + if unionCount != 1 { + t.Errorf("UNION finding count = %d, want 1: %+v", unionCount, result.Findings) + } +} + +func TestWPPDollarQuotedFunctionBodyIsInert(t *testing.T) { + sql := "CREATE FUNCTION cleanup() RETURNS void AS $body$ BEGIN; DROP TABLE accounts; END; $body$ LANGUAGE plpgsql" + if result := NewScanner().ScanSQL(sql); wpPHasPattern(result, PatternStackedQuery) { + t.Fatalf("dollar-quoted function body reported stacked query: %+v", result.Findings) + } +} + +func TestWPPBenignCommentGuards(t *testing.T) { + if result := NewScanner().ScanSQL("SELECT 'UNION/*' , x FROM t WHERE y = '*/SELECT'"); wpPHasPattern(result, PatternUnionGeneric) { + t.Errorf("normalization joined markers across strings: %+v", result.Findings) + } + for _, sql := range []string{ + "SELECT /* fetch users */ id FROM users WHERE id = 42", + "SELECT id -- pick id\nFROM users WHERE id = 42", + "SELECT a /* note */, b FROM t WHERE c = 'x'", + "SELECT '/* not a comment */' AS s FROM t", + "SELECT 'a--b' AS s FROM t", + "SELECT /*+ INDEX(t idx) */ a FROM t WHERE b = 2", + } { + if result := NewScanner().ScanSQL(sql); !result.IsClean() { + t.Errorf("benign comment or string marker produced findings: %q: %+v", sql, result.Findings) + } + } + for _, sql := range []string{ + `SELECT '/*' AS marker, 1 UNION SELECT '*/', 2`, + `SELECT '--' AS marker, 1 UNION SELECT '#', 2`, + } { + result := NewScanner().ScanSQL(sql) + if !wpPHasPattern(result, PatternUnionGeneric) { + t.Errorf("string comment marker hid UNION: %q", sql) + } + } +} + +func TestWPPFullInputDetection(t *testing.T) { + for _, size := range []int{10050, 200000} { + for _, whitespace := range []string{" ", "\n"} { + padding := strings.Repeat(whitespace, size) + for _, tt := range []wpPBypassCase{ + {"tautology", padding + "SELECT 1 WHERE x=2 OR 1=1", PatternTautology}, + {"trailing comment", padding + "SELECT 1 --", PatternComment}, + {"union comment", padding + "SELECT 1 UNION/**/SELECT NULL,NULL", PatternUnionGeneric}, + {"trailing comment padding", "SELECT 1 --" + padding, PatternComment}, + } { + t.Run(fmt.Sprintf("%d/%q/%s", size, whitespace, tt.name), func(t *testing.T) { + if result := NewScanner().ScanSQL(tt.sql); !wpPHasPattern(result, tt.pattern) { + t.Errorf("missing %s beyond old cutoff: %+v", tt.pattern, result.Findings) + } + }) + } + } + } +} + +func TestWPPParsedNullUnion(t *testing.T) { + for _, tt := range []struct { + sql string + want bool + }{ + {"SELECT a,b FROM accounts UNION SELECT NULL,NULL", true}, + {"SELECT a,b FROM accounts UNION SELECT NULL AS x,NULL AS y", true}, + {"SELECT a,b FROM accounts UNION SELECT NULL,NULL FROM u", true}, + {"SELECT a,b FROM accounts UNION SELECT NULL AS x,NULL AS y FROM u", true}, + {"SELECT a,b FROM accounts UNION SELECT c,NULL FROM u", false}, + {"SELECT a,b FROM accounts UNION SELECT NULL,7", false}, + {"SELECT a,b FROM accounts UNION SELECT 7 AS x,8 AS y", false}, + } { + t.Run(tt.sql, func(t *testing.T) { + tree := parseForScan(t, tt.sql) + defer ast.ReleaseAST(tree) + result := NewScanner().Scan(tree) + count := 0 + for _, f := range result.Findings { + if f.Pattern == PatternUnionBased && f.Description == "UNION SELECT with multiple NULL columns detected" { + count++ + if f.Severity != SeverityHigh { + t.Errorf("NULL padding severity = %s, want HIGH", f.Severity) + } + } + } + want := 0 + if tt.want { + want = 1 + } + if count != want { + t.Errorf("NULL UNION finding count = %d, want %d: %+v", count, want, result.Findings) + } + }) + } +} + +func TestWPPScanInputLimit(t *testing.T) { + if MaxScanInputSize != tokenizer.MaxInputSize { + t.Fatalf("scan limit = %d, tokenizer limit = %d", MaxScanInputSize, tokenizer.MaxInputSize) + } + // A valid clean input at the boundary must be scanned rather than rejected. + clean := "SELECT 1" + strings.Repeat(" ", MaxScanInputSize-len("SELECT 1")) + if result := NewScanner().ScanSQL(clean); !result.IsClean() { + t.Fatalf("exact-limit input rejected: %+v", result.Findings) + } + for _, sql := range []string{clean + " ", strings.Repeat(" ", MaxScanInputSize+1) + " OR 1=1; DROP TABLE accounts --"} { + result := NewScanner().ScanSQL(sql) + if len(result.Findings) != 1 || result.Findings[0].Pattern != PatternInputTooLarge || result.Findings[0].Severity != SeverityCritical { + t.Fatalf("oversized input needs exactly one CRITICAL INPUT_TOO_LARGE finding: %+v", result.Findings) + } + wantDescription := fmt.Sprintf("input exceeds maximum scannable size (%d bytes > %d bytes); not scanned", len(sql), MaxScanInputSize) + if result.Findings[0].Description != wantDescription { + t.Errorf("oversize description = %q, want %q", result.Findings[0].Description, wantDescription) + } + if result.TotalCount != 1 || result.HighCount != 0 || result.CriticalCount != 1 || !result.HasHighOrAbove() || !result.HasCritical() || result.IsClean() { + t.Fatalf("oversize summary counts incorrect: %+v", result) + } + critical, err := NewScannerWithSeverity(SeverityCritical) + if err != nil { + t.Fatal(err) + } + for _, scanner := range []*Scanner{critical, {MinSeverity: Severity("UNKNOWN")}} { + result := scanner.ScanSQL(sql) + if len(result.Findings) != 1 || result.Findings[0].Pattern != PatternInputTooLarge || result.Findings[0].Severity != SeverityCritical || result.IsClean() || !result.HasCritical() { + t.Fatalf("oversize input looked clean under MinSeverity %q: %+v", scanner.MinSeverity, result) + } + } + } +} + +func TestWPPPrefilterEquivalence(t *testing.T) { + for _, tt := range append(wpPCommentBypasses(), wpPRound2Bypasses()...) { + t.Run(tt.name, func(t *testing.T) { + s := NewScanner() + if got, want := s.scanSQL(tt.sql, true), s.scanSQL(tt.sql, false); !reflect.DeepEqual(got, want) { + t.Errorf("prefilter changed findings: got %+v, want %+v", got, want) + } + }) + } + for _, pattern := range allCompiledPatterns(t) { + for _, literal := range requiredLiterals(pattern.re.String()) { + if strings.ContainsAny(string(literal), " \t\n\r\f\v") { + t.Errorf("requiredLiterals(%q) includes whitespace: %q", pattern.re.String(), literal) + } + } + } +} + +func TestWPPScanSQLLinear(t *testing.T) { + if testing.Short() { + t.Skip("timing regression") + } + input := func(size int) string { + const chunk = "/**/ --x\n a = b " + return strings.Repeat(chunk, (size+len(chunk)-1)/len(chunk))[:size] + } + scanner := NewScanner() + measure := func(sql string) time.Duration { + best := time.Duration(1<<63 - 1) + for i := 0; i < 5; i++ { + start := time.Now() + _ = scanner.ScanSQL(sql) + if elapsed := time.Since(start); elapsed < best { + best = elapsed + } + } + return best + } + small, large := input(64<<10), input(256<<10) + _ = scanner.ScanSQL(small) + _ = scanner.ScanSQL(large) + a, b := measure(small), measure(large) + t.Logf("64KiB %s, 256KiB %s, ratio %.2fx", a, b, float64(b)/float64(a)) + if float64(b)/float64(a) >= 10 { + t.Errorf("4x input took %.2fx time (64KiB %s, 256KiB %s)", float64(b)/float64(a), a, b) + } +} + +// The old captures provide a small-input oracle for the streaming implementation. +func TestWPPStreamingTautologyCaptureEquivalence(t *testing.T) { + scanner := NewScanner() + for i, sql := range []string{ + "", "SELECT 1", "1=2 3=4 a=b", "1=1 2=2 'a'='a' col=COL", + strings.Repeat("1=2 a=b 'x'='y' ", 40) + " 7=7 'tail'='TAIL' tail=TAIL", + "'a=b' = 'A=B'", "'a=b' = 'a=c' 'c=d'='C=D'", "''=''", + "'a = b' = 'A = B'", "'a'='b' 'x=y'='X=Y' 'q'='q'", + "ſ=S K=k", "xſ=XS xK=XK", "S=ſ K=K", "ſ=ſ K=K", + "a1=1 12=12z _1=1 0=0", "11=12,13=13", "a=b/c=C", "foo.bar=FOO.BAR", + "1234567=1234567", "x1=x2 x3=X3", "1\v=\f1 a\v=\fA", + strings.Repeat("a1=2 ", 10000) + "1=2 3=3", + strings.Repeat("1=2x ", 100) + "1=2 3=3", + strings.Repeat("0a=b ", 100) + "a=b c=C", + } { + t.Run(fmt.Sprintf("case%d", i), func(t *testing.T) { + var want []Finding + for _, p := range tautologyCapturePatterns { + for _, match := range p.re.FindAllStringSubmatch(sql, -1) { + if len(match) >= 3 && strings.EqualFold(match[1], match[2]) { + want = append(want, Finding{ + Severity: SeverityCritical, Pattern: PatternTautology, + Description: "Always-true condition detected (tautology): " + match[0], + Risk: "Authentication bypass via always-true condition", + Suggestion: "Use parameterized queries to prevent tautology injection", + }) + break + } + } + } + got := &ScanResult{} + scanner.detectTautologyInSQL([]string{sql}, nil, got) + if !reflect.DeepEqual(got.Findings, want) { + t.Errorf("stream differs from capture oracle: got %+v, want %+v", got.Findings, want) + } + }) + } +} + +func TestWPPStreamingTautologyFullInput(t *testing.T) { + falsePairs := strings.Repeat("1=2 ", (1<<20)/4) + for _, sql := range []string{falsePairs + " 3=3", "3=3 " + falsePairs} { + result := &ScanResult{} + NewScanner().detectTautologyInSQL([]string{sql}, nil, result) + if len(result.Findings) != 1 || result.Findings[0].Description != "Always-true condition detected (tautology): 3=3" { + t.Errorf("lost first true numeric pair in full input: %+v", result.Findings) + } + } +} + +func TestWPPBracketRegions(t *testing.T) { + for _, sql := range []string{ + `SELECT [a]] $x$ ]; DROP TABLE users; SELECT [$x$]`, + `SELECT [a']] $x$ ]; DROP TABLE users; SELECT [$x$]`, + } { + if got := stripDollarQuotedStrings(sql); got != sql { + t.Errorf("stripped dollar marker inside escaped bracket identifier: %q -> %q", sql, got) + } + if result := NewScanner().ScanSQL(sql); !wpPHasPattern(result, PatternStackedQuery) { + t.Errorf("bracket identifier hid DROP: %+v", result.Findings) + } + } + for _, sql := range []string{ + `SELECT [a]]b], [c'd] FROM t`, + `SELECT [a/*b], [c*/d], [e--f], [g#h] FROM t`, + `SELECT [a']]b] /* fetch identifiers */ FROM t`, + } { + if result := NewScanner().ScanSQL(sql); !result.IsClean() { + t.Errorf("benign bracket identifier produced findings for %q: %+v", sql, result.Findings) + } + } +} + +func TestWPPCommentEscapeInterpretationCrossProduct(t *testing.T) { + prefixes := []string{ + `SELECT '\''`, `SELECT E'\''`, `SELECT "\""`, + `SELECT [a']]name]`, `SELECT [a']]name], '\''`, + `SELECT [a']]name], "\""`, + } + gaps := []string{"/**/", "--x\n", "#x\n", "/* outer /* inner */ outer */", "/* x /*/"} + for _, prefix := range prefixes { + for _, gap := range gaps { + sql := prefix + " UNION" + gap + "SELECT 1,2" + t.Run(sql, func(t *testing.T) { + result := NewScanner().ScanSQL(sql) + if !wpPHasPattern(result, PatternUnionGeneric) { + t.Errorf("dialect interpretation lost UNION: %+v", result.Findings) + } + if got, want := NewScanner().scanSQL(sql, true), NewScanner().scanSQL(sql, false); !reflect.DeepEqual(got, want) { + t.Errorf("prefilter differs across dialect interpretations: got %+v, want %+v", got, want) + } + }) + } + } +} + +func TestWPPNumericPrefilterNecessaryCondition(t *testing.T) { + scanner := NewScanner() + corpus := []string{ + "", "=", "1=", "=1", "1=2", "1=1", "01=1", "01=01", + "1==1", "=1=1=", "1=2=2", "1=1=2", "12=12 13=14", + "ſ1=1ſ", "K1=1K", "ſ123=123", "123=123ſ", + "a1=1a", "_1=1_", "11=11z 3=3", "'123=123'", "'1'='1'", + "123456=123456", "1234567=1234567", "0123456789=0123456789", + "1234567=123456 123456=1234567", "1234567.123456=123456", + strings.Repeat("1=2 ", 256) + "3=3", "3=3 " + strings.Repeat("1=2 ", 256), + "1/**/=/**/1", "1#x\n=1", "SELECT '\\'' OR/**/1/**/=/**/1", + } + for _, whitespace := range []string{"", " ", "\t", "\n", "\r", "\v", "\f", " \t\n\r\v\f"} { + for _, left := range []string{"0", "1", "01", "123456", "1234567"} { + for _, right := range []string{"0", "1", "01", "123456", "1234567"} { + corpus = append(corpus, left+whitespace+"="+whitespace+right) + } + } + } + for i, sql := range corpus { + t.Run(fmt.Sprintf("case%d", i), func(t *testing.T) { + for _, match := range tautologyCapturePatterns[0].re.FindAllStringSubmatch(sql, -1) { + if len(match) >= 3 && strings.EqualFold(match[1], match[2]) && !hasEqualNumericPair(sql) { + t.Errorf("numeric prefilter rejected equal capture %q in %q", match[0], sql) + } + } + if got, want := scanner.scanSQL(sql, true), scanner.scanSQL(sql, false); !reflect.DeepEqual(got, want) { + t.Errorf("numeric prefilter changed findings for %q: got %+v, want %+v", sql, got, want) + } + }) + } +} + +func TestWPPStreamingMatchEndBoundaries(t *testing.T) { + _ = NewScanner() + for i, tt := range []struct { + pattern int + prefix, match, suffix string + }{ + {0, "a1=2 ", "1=2", ""}, + {0, "1=2x ", "1=2", ""}, + {0, "_1=2 1=2_ ", "1=2", ""}, + {0, "ſ", "1=2", "K"}, + {0, strings.Repeat("a1=2 ", 10000), "1=2", " 3=3"}, + {1, "x", "'a'='b'", "y"}, + {1, "ſ", "'a=b'='a=c'", "K"}, + {1, "'x'='y' ", "'a'='b'", ""}, + {2, "0a=b ", "a=b", ""}, + {2, "a=" + strings.Repeat("b", 60) + " ", "a=b", ""}, + {2, "ſ", "a=b", "K"}, + {2, "0foo=BAZ ", "foo=BAZ", ""}, + } { + t.Run(fmt.Sprintf("case%d", i), func(t *testing.T) { + sql := tt.prefix + tt.match + tt.suffix + p := tautologyCapturePatterns[tt.pattern] + indices := p.re.FindStringIndex(sql) + if len(indices) != 2 { + t.Fatalf("expected capture-pattern match for %q", sql) + } + // The earlier quoted pair is itself valid; unlike numeric and + // identifier patterns it requires no surrounding word boundaries. + want := len(tt.prefix) + len(tt.match) + if tt.pattern == 1 && strings.HasPrefix(tt.prefix, "'x'") { + want = len("'x'='y'") + } + if indices[1] != want { + t.Fatalf("regex match end = %d, want %d", indices[1], want) + } + if got := sqlPairMatchEnd(sql, p.re.FindString(sql)); got != indices[1] { + t.Errorf("stream match end = %d, regex match end = %d", got, indices[1]) + } + }) + } +} From 301c634d6f4c914a669df163aa89f1b40bd79c0f Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 29 Oct 2026 00:12:18 +0530 Subject: [PATCH 2/2] test(security): keep the race run of pkg/sql/security within the package timeout Scanning the whole input made the large-input tests much slower under the race detector, and CI's race job hit the 120 s package timeout. Under -race, TestScanSQL_Concurrent_FoldPool runs 25 iterations per goroutine instead of 200, TestScanSQL_PrefilterEquivalence checks a quarter of the corpus without inputs over 16 KiB, and the timing-ratio test is skipped. Non-race runs keep the full coverage. Local race run: 50 s -> 17 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/sql/security/prefilter_test.go | 13 ++++++++++++- pkg/sql/security/race_enabled_norace_test.go | 20 ++++++++++++++++++++ pkg/sql/security/race_enabled_race_test.go | 20 ++++++++++++++++++++ pkg/sql/security/wp_p_bypass_test.go | 4 ++-- 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 pkg/sql/security/race_enabled_norace_test.go create mode 100644 pkg/sql/security/race_enabled_race_test.go diff --git a/pkg/sql/security/prefilter_test.go b/pkg/sql/security/prefilter_test.go index 08da8665e..8f2e25040 100644 --- a/pkg/sql/security/prefilter_test.go +++ b/pkg/sql/security/prefilter_test.go @@ -416,6 +416,11 @@ func TestScanSQL_PrefilterEquivalence(t *testing.T) { corpus := equivalenceCorpus(t) for name, s := range scanners { for i, in := range corpus { + // Equivalence is not a concurrency property; under -race check a + // quarter of the corpus and no large inputs. Non-race runs check all. + if raceEnabled && (i%4 != 0 || len(in) > 16<<10) { + continue + } got := s.scanSQL(in, true) want := s.scanSQL(in, false) if !reflect.DeepEqual(got, want) { @@ -447,7 +452,13 @@ func TestScanSQL_Concurrent_FoldPool(t *testing.T) { want[i] = s.ScanSQL(in) } - const goroutines, iterations = 32, 200 + // The race detector makes each 64 KiB+ scan ~20x slower; fewer iterations + // still interleave every input across all goroutines. + const goroutines = 32 + iterations := 200 + if raceEnabled { + iterations = 25 + } var wg sync.WaitGroup errs := make(chan string, goroutines) for g := 0; g < goroutines; g++ { diff --git a/pkg/sql/security/race_enabled_norace_test.go b/pkg/sql/security/race_enabled_norace_test.go new file mode 100644 index 000000000..5a5fe7a85 --- /dev/null +++ b/pkg/sql/security/race_enabled_norace_test.go @@ -0,0 +1,20 @@ +// 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. + +//go:build !race + +package security + +// raceEnabled reports whether the race detector instruments this test binary. +const raceEnabled = false diff --git a/pkg/sql/security/race_enabled_race_test.go b/pkg/sql/security/race_enabled_race_test.go new file mode 100644 index 000000000..a1a068b3b --- /dev/null +++ b/pkg/sql/security/race_enabled_race_test.go @@ -0,0 +1,20 @@ +// 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. + +//go:build race + +package security + +// raceEnabled reports whether the race detector instruments this test binary. +const raceEnabled = true diff --git a/pkg/sql/security/wp_p_bypass_test.go b/pkg/sql/security/wp_p_bypass_test.go index 47bf1ce41..d66ad7e20 100644 --- a/pkg/sql/security/wp_p_bypass_test.go +++ b/pkg/sql/security/wp_p_bypass_test.go @@ -394,8 +394,8 @@ func TestWPPPrefilterEquivalence(t *testing.T) { } func TestWPPScanSQLLinear(t *testing.T) { - if testing.Short() { - t.Skip("timing regression") + if testing.Short() || raceEnabled { + t.Skip("timing regression; skipped under -short and -race") } input := func(size int) string { const chunk = "/**/ --x\n a = b "