Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,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).
- **Format**: with `AddSemicolon` and a comment anywhere in the input, raw-text statements (PostgreSQL `DO $$...$$`, `CREATE FUNCTION` bodies, Oracle `BEGIN ... END` blocks) lost their `;`, so the output no longer parsed or merged two statements. The comment-preserving path now adds terminators the same way as the plain path (`formatter.EnsureStatementTerminator`).
- **Extract**: `gosqlx.Extract*` and `ExtractMetadata` (and the MCP `extract_metadata`/`analyze_sql` tools) took exponential time on nested CTEs and UNION chains because CTE, set-operation and DML children were visited twice. Each node is now visited once; nested CTEs at depth 10 took about 59 s and now finish in microseconds.
- **Transpile**: a LIMIT on a whole UNION/INTERSECT/EXCEPT no longer becomes `TOP` on the last branch when targeting SQL Server (that silently changed results); it is emitted as `ORDER BY ... OFFSET n ROWS FETCH NEXT m ROWS ONLY`, and without ORDER BY Transpile returns an error. Very long operator chains (for example 490,000 `OR` terms) no longer crash the process with an unrecoverable stack overflow: the operator and string-literal rewrites walk the tree iteratively, and rewrites that would nest output more than 512 levels deep return an error.
Expand Down
97 changes: 47 additions & 50 deletions pkg/sql/security/dollar_quote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
}
17 changes: 14 additions & 3 deletions pkg/sql/security/prefilter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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++ {
Expand Down
20 changes: 20 additions & 0 deletions pkg/sql/security/race_enabled_norace_test.go
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions pkg/sql/security/race_enabled_race_test.go
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading