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 @@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
are removed.

### Performance
- **Security**: `Scanner.ScanSQL` now skips the dollar-quote stripping lexer, the identifier-tautology regex and the ClickHouse table-function regex on inputs that provably cannot match them, using linear-time necessary-condition guards (`hasDollarOpener`, `hasEqualIdentPair`, `hasTableFnCandidate`). The regexes still confirm every finding, so results are byte-identical (guarded by a guards-on/off equivalence test over the real-world corpus and 200k random inputs); no API or behaviour change. [W6-01]
- **Tokenizer**: `time.Now`/`time.Since` are now read only when metrics are enabled (previously twice per tokenization even when disabled). If metrics are toggled on mid-call the call is not recorded. No API or token-output change [W1-02].
- **Tokenizer**: keyword and compound-keyword-start lookup now uses a single fused-hash open-addressed table (one FNV-1a hash computed while upper-casing, one probe) instead of two Go map lookups per identifier. The table is filled in a fixed order (hot keywords first, then sorted) so its probe layout is deterministic across processes. No behaviour change; token output is identical [W2-01].
- **Pools**: the tokenizer, parser and AST pools now count Get/Put through precreated, cache-line padded atomic counters (`internal/poolstats`) instead of an RWMutex-guarded map keyed by pool name, removing a lock, map lookup and string hash from every pool operation. `metrics.GetPoolStats` and `metrics.ResetPoolStats` behave exactly as before; named custom pools keep the map path. [W1-01]
Expand Down
19 changes: 18 additions & 1 deletion pkg/sql/security/dollar_quote.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import "strings"
// 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 {
if strings.IndexByte(sql, '$') < 0 {
if strings.IndexByte(sql, '$') < 0 || !hasDollarOpener(sql) {
return sql
}
// Cross product of escapes, nesting, bracket identifiers, hash comments and
Expand Down Expand Up @@ -71,3 +71,20 @@ func resetDollarStates(states *[32]sqlLexState) {
states[j] = sqlLexState{eOnly: j&16 != 0}
}
}

// hasDollarOpener reports whether any '$' could open a dollar quote (the same
// opener condition the lexer loop below uses); when none can, the 32-state
// lexer can never strip anything, so it is skipped.
func hasDollarOpener(sql string) bool {
for i := strings.IndexByte(sql, '$'); i >= 0; {
if (i == 0 || !identifierByte(sql[i-1])) && dollarTagEnd(sql, i) != 0 {
return true
}
j := strings.IndexByte(sql[i+1:], '$')
if j < 0 {
return false
}
i += 1 + j
}
return false
}
4 changes: 4 additions & 0 deletions pkg/sql/security/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ func FuzzScanSQL(f *testing.F) {
f.Add(s)
}

for _, sql := range scanSQLGuardSeeds {
f.Add(sql)
}

f.Fuzz(func(t *testing.T, sql string) {
scanner := NewScanner()
_ = scanner.ScanSQL(sql)
Expand Down
127 changes: 127 additions & 0 deletions pkg/sql/security/guards.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// 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"

func withGuard(p compiledPattern, g func(string) bool) compiledPattern { p.guard = g; return p }

func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return false
}
}
return true
}

func identRunByte(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '.'
}

// hasEqualIdentPair is a necessary condition for the identifier tautology
// capture pattern: around some '=', a suffix of the left identifier run equals
// (case-insensitively) a prefix of the right run. The runs include every byte
// accepted by [a-z_][a-z0-9_.]*; sqlWhitespace includes [\s\v]. Trying all
// suffix/prefix lengths up to 51 permits the regex's unanchored and partial
// matches: initTautologyCapturePatterns uses [a-z_][a-z0-9_.]{0,50}, so each
// ASCII capture has at most 51 bytes. A confirmed equality cannot require a
// longer comparison. Bounding k also keeps comparison work per '=' constant
// rather than quadratic in the lengths of unequal identifier runs.
// Call only for ASCII input: (?i) also folds U+017F (long s) to S and U+212A
// (Kelvin sign) to K, which these byte runs cannot recognize.
func hasEqualIdentPair(sql string) bool {
for off := 0; off < len(sql); {
r := strings.IndexByte(sql[off:], '=')
if r < 0 {
return false
}
eq := off + r
off = eq + 1
l, rr := eq, eq+1
for l > 0 && sqlWhitespace(sql[l-1]) {
l--
}
for rr < len(sql) && sqlWhitespace(sql[rr]) {
rr++
}
s, e := l, rr
for s > 0 && identRunByte(sql[s-1]) {
s--
}
for e < len(sql) && identRunByte(sql[e]) {
e++
}
left, right := sql[s:l], sql[rr:e]
for k := 1; k <= 51 && k <= len(left) && k <= len(right); k++ {
if strings.EqualFold(left[len(left)-k:], right[:k]) {
return true
}
}
}
return false
}

var chNames = map[string]bool{}

func init() {
for _, n := range strings.Split("url|urlcluster|file|filecluster|remote|remotesecure|s3|s3cluster|gcs|azureblobstorage|hdfs|hdfscluster|jdbc|odbc|mysql|postgresql|mongodb|redis|sqlite|executable", "|") {
chNames[n] = true
}
}

func wordByte(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_'
}

// hasTableFnCandidate: some '(' is preceded by optional whitespace, a listed
// table-function name as a whole word, and a whitespace byte. Any match of the
// FROM/JOIN regex must contain that suffix; the regex still confirms FROM/JOIN.
// sqlWhitespace includes exactly [\s\v], and the longest listed name fits buf.
// Call only for ASCII input: (?i) can match U+017F (long s) as S and U+212A
// (Kelvin sign) as K; ASCII name scanning would otherwise miss such matches.
func hasTableFnCandidate(sql string) bool {
var buf [16]byte
for off := 0; off < len(sql); {
r := strings.IndexByte(sql[off:], '(')
if r < 0 {
return false
}
p := off + r
off = p + 1
e := p
for e > 0 && sqlWhitespace(sql[e-1]) {
e--
}
s := e
for s > 0 && wordByte(sql[s-1]) {
s--
}
if e-s < 2 || e-s > len(buf) || s == 0 || !sqlWhitespace(sql[s-1]) {
continue
}
for i := s; i < e; i++ {
c := sql[i]
if 'A' <= c && c <= 'Z' {
c += 32
}
buf[i-s] = c
}
if chNames[string(buf[:e-s])] {
return true
}
}
return false
}
113 changes: 113 additions & 0 deletions pkg/sql/security/guards_boundary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// 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 (
"os"
"reflect"
"regexp"
"strings"
"testing"
)

// TestHasEqualIdentPairCaptureBoundary pins the 51-byte capture bound of
// [a-z_][a-z0-9_.]{0,50}. A border-free identifier ("a" followed by 50 "b")
// equal on both sides of '=' can only be matched by the full 51-byte comparison.
func TestHasEqualIdentPairCaptureBoundary(t *testing.T) {
id51 := "a" + strings.Repeat("b", 50)
id52 := "a" + strings.Repeat("b", 51)
scanner := NewScanner()
cases := []struct {
name, sql string
want bool
}{
{"51 plain", id51 + "=" + id51, true},
{"51 spaced", id51 + " = " + id51, true},
{"51 case-folded", strings.ToUpper(id51) + "=" + id51, true},
{"51 left only prefix", "x " + id51 + "=" + id51 + " y", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := hasEqualIdentPair(tc.sql); got != tc.want {
t.Fatalf("hasEqualIdentPair(%q) = %v, want %v", tc.sql, got, tc.want)
}
if r := scanner.ScanSQL(tc.sql); r.TotalCount == 0 {
t.Fatalf("ScanSQL(%q) reported no finding", tc.sql)
}
})
}
// 52 bytes exceeds the regex capture bound; the guard must agree with the
// regex-only path whatever it decides.
for _, sql := range []string{id52 + "=" + id52, id52 + " = " + id52} {
got, want := scanner.ScanSQL(sql), scanSQLRegexOnly(scanner, sql)
if !reflect.DeepEqual(got, want) {
t.Fatalf("52-byte case %q: guarded %+v, regex-only %+v", sql, got, want)
}
}
}

// TestTableFnGuardCoversEveryRegexName extracts the names from the ClickHouse
// table-function alternation in scanner.go and requires the guard's name set to
// match it exactly, and every name (mixed case, tab/VT whitespace) to be both a
// guard candidate and a ScanSQL finding.
func TestTableFnGuardCoversEveryRegexName(t *testing.T) {
src, err := os.ReadFile("scanner.go")
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`\(\?:FROM\|JOIN\)\[\\s\\v\]\+\(\?:([A-Za-z0-9_|]+)\)\[\\s\\v\]\*\\\(`).FindSubmatch(src)
if m == nil {
t.Fatal("ClickHouse table-function alternation not found in scanner.go")
}
names := strings.Split(string(m[1]), "|")
if len(names) != len(chNames) {
t.Fatalf("regex has %d names, guard has %d", len(names), len(chNames))
}
scanner := NewScanner()
for _, name := range names {
if !chNames[strings.ToLower(name)] {
t.Errorf("guard is missing %q", name)
}
mixed := mixCase(name)
for _, sql := range []string{
"SELECT * FROM " + mixed + " ('x')",
"SELECT * FROM t JOIN " + mixed + "\v('x')",
"select * from\t" + strings.ToUpper(name) + "(1)",
} {
if !hasTableFnCandidate(sql) {
t.Errorf("hasTableFnCandidate(%q) = false", sql)
}
found := false
for _, f := range scanner.ScanSQL(sql).Findings {
if f.Pattern == PatternOutOfBand {
found = true
}
}
if !found {
t.Errorf("ScanSQL(%q) did not report an out-of-band finding", sql)
}
}
}
}

func mixCase(s string) string {
b := []byte(s)
for i := range b {
if i%2 == 1 && b[i] >= 'a' && b[i] <= 'z' {
b[i] -= 32
}
}
return string(b)
}
5 changes: 3 additions & 2 deletions pkg/sql/security/prefilter.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ import (
// the regex cannot match and is skipped. A nil or empty lits means the regex always
// runs.
type compiledPattern struct {
re *regexp.Regexp
lits [][]byte
re *regexp.Regexp
lits [][]byte
guard func(string) bool // optional exact necessary condition (ASCII input only)
}

// compilePattern compiles expr and derives its literal prefilter.
Expand Down
8 changes: 7 additions & 1 deletion pkg/sql/security/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ func initCompiledPatterns() {
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\v]+(?:url|urlCluster|file|fileCluster|remote|remoteSecure|s3|s3Cluster|gcs|azureBlobStorage|hdfs|hdfsCluster|jdbc|odbc|mysql|postgresql|mongodb|redis|sqlite|executable)[\s\v]*\(`),
withGuard(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]*\(`), hasTableFnCandidate),
},
severity: SeverityCritical,
risk: "Out-of-band data exfiltration or command execution",
Expand Down Expand Up @@ -1214,6 +1214,9 @@ func (s *Scanner) detectTautologyInSQL(views []string, folded []byte, result *Sc
if patternIndex == 0 && folded != nil && !hasEqualNumericPair(input) {
continue
}
if patternIndex == 2 && folded != nil && isASCII(input) && !hasEqualIdentPair(input) {
continue
}
for rest := input; rest != ""; {
// FindString uses a stack capture buffer; SubmatchIndex allocates
// an index slice for every unequal pair on large inputs.
Expand Down Expand Up @@ -1337,6 +1340,9 @@ func (s *Scanner) detectRegexPatterns(views []string, folded []byte, patternType
// no match, and a non-empty result is the same leftmost-first match that
// FindStringSubmatch(sql)[0] would return.
for _, sql := range views {
if p.guard != nil && folded != nil && isASCII(sql) && !p.guard(sql) {
continue
}
if m := p.re.FindString(sql); m != "" {
finding := Finding{
Severity: group.severity,
Expand Down
Loading
Loading