From fb33344adaad30c8f6d8f93a314c2c6b7cd81b3c Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Tue, 27 Oct 2026 14:19:52 +0530 Subject: [PATCH] fix(security,cbinding): scan raw-text statements, recover from panics in C exports - Scanner.Scan(tree) scans standalone VALUES and statements the parser keeps as text (utility, SET, transaction, USE, unsupported, T-SQL blocks, PREPARE/EXECUTE, CREATE POLICY/TRIGGER) with the ScanSQL rules, so payloads such as VALUES (SLEEP(5)) and IF 1=1 WAITFOR DELAY are reported; no finding is reported twice. - Every C-binding export recovers from a panic and returns its normal error result with a fixed message instead of crashing the host. - DialectExtras()/DialectModifiers() read accessors return values, so the shared zero-value stand-ins can no longer be mutated through them; EnsureExtras() stays the mutable path. No new allocations. - tokscratch panics with a clear message when the tokenizer is not linked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- CHANGELOG.md | 4 +- internal/tokscratch/tokscratch.go | 9 + internal/tokscratch/tokscratch_test.go | 28 + pkg/cbinding/cbinding.go | 37 +- pkg/cbinding/cbinding_ext.go | 44 +- pkg/cbinding/cbinding_panic_test.go | 76 ++ pkg/cbinding/cbinding_recover.go | 49 + pkg/cbinding/cbinding_testhelpers.go | 6 + pkg/formatter/render_dml.go | 4 +- pkg/formatter/render_select_ext.go | 14 +- pkg/gosqlx/realworld_corpus_test.go | 14 +- pkg/sql/ast/ast_dml.go | 28 +- pkg/sql/ast/dialect_extras.go | 54 +- pkg/sql/ast/dialect_extras_test.go | 69 +- pkg/sql/ast/select_ext.go | 14 +- pkg/sql/security/raw_statement_test.go | 159 ++++ pkg/sql/security/review_payloads_test.go | 109 +++ pkg/sql/security/scanner.go | 23 + .../testdata/review_payload_findings.json | 881 ++++++++++++++++++ 19 files changed, 1523 insertions(+), 99 deletions(-) create mode 100644 internal/tokscratch/tokscratch_test.go create mode 100644 pkg/cbinding/cbinding_panic_test.go create mode 100644 pkg/cbinding/cbinding_recover.go create mode 100644 pkg/sql/security/raw_statement_test.go create mode 100644 pkg/sql/security/review_payloads_test.go create mode 100644 pkg/sql/security/testdata/review_payload_findings.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 508e16cd5..7b89dd7fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ORDER BY / LIMIT, RETURNING INTO), `FunctionCall.Extras`, `BinaryExpression.Extras` and `TableReference.Modifiers` (including derived `VALUES`). Read them through `DialectExtras()` / `DialectModifiers()`, which - return a shared read-only empty value when the pointer is nil, and call + return values for read access without shared mutable stand-ins; call `EnsureExtras()` / `EnsureModifiers()` before writing. - **Transpilation**: string literals and quoted identifiers are rendered using the target dialect's escape rules and delimiters. Unrepresentable literals @@ -64,6 +64,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 in dialects without them. ### Fixed +- Scan raw dialect statements and standalone VALUES for injection patterns, recover + panics at every C export, and guard unlinked tokenizer scratch calls. - **AST walkers** (`ast.Inspect`, `Walk`, the security scanner, linter and table extraction) reach SELECT `TOP` and ClickHouse `ARRAY JOIN`, the ENGINE/ORDER BY/PARTITION BY/TTL/SETTINGS clauses of `CREATE TABLE` and diff --git a/internal/tokscratch/tokscratch.go b/internal/tokscratch/tokscratch.go index 6c6be6c93..758546aff 100644 --- a/internal/tokscratch/tokscratch.go +++ b/internal/tokscratch/tokscratch.go @@ -46,18 +46,27 @@ var impl Func // tokenizer's init and must not be called by anyone else. func Register(f Func) { impl = f } +func requireImplementation() { + if impl == nil { + panic("tokscratch: tokenizer not linked") + } +} + // Bytes is t.Tokenize(input) into t's scratch buffer. func Bytes(t any, input []byte) ([]models.TokenWithSpan, error) { + requireImplementation() return impl(context.Background(), t, input, "", false) } // String is t.TokenizeString(s) into t's scratch buffer. func String(t any, s string) ([]models.TokenWithSpan, error) { + requireImplementation() return impl(context.Background(), t, nil, s, true) } // StringContext is t.TokenizeStringContext(ctx, s) into t's scratch buffer. func StringContext(ctx context.Context, t any, s string) ([]models.TokenWithSpan, error) { + requireImplementation() if err := ctx.Err(); err != nil { return nil, err } diff --git a/internal/tokscratch/tokscratch_test.go b/internal/tokscratch/tokscratch_test.go new file mode 100644 index 000000000..059481708 --- /dev/null +++ b/internal/tokscratch/tokscratch_test.go @@ -0,0 +1,28 @@ +package tokscratch + +import ( + "context" + "testing" +) + +func TestTokenizerNotLinked(t *testing.T) { + for _, name := range []string{"Bytes", "String", "StringContext"} { + t.Run(name, func(t *testing.T) { + defer func() { + r := recover() + message, ok := r.(string) + if !ok || message != "tokscratch: tokenizer not linked" { + t.Fatalf("panic = %v, want tokscratch: tokenizer not linked", r) + } + }() + switch name { + case "Bytes": + _, _ = Bytes(nil, nil) + case "String": + _, _ = String(nil, "") + case "StringContext": + _, _ = StringContext(context.Background(), nil, "") + } + }) + } +} diff --git a/pkg/cbinding/cbinding.go b/pkg/cbinding/cbinding.go index 3bd4670fa..f9fc4ec09 100644 --- a/pkg/cbinding/cbinding.go +++ b/pkg/cbinding/cbinding.go @@ -207,7 +207,9 @@ func statementTypeName(stmt ast.Statement) string { // pointer with gosqlx_free. // //export gosqlx_parse -func gosqlx_parse(sql *C.char) *C.char { +func gosqlx_parse(sql *C.char) (result *C.char) { + defer recoverExport(&result, panicSuccessJSON) + checkExportPanic("gosqlx_parse") return toJSON(parseResultFor(C.GoString(sql), "")) } @@ -222,7 +224,9 @@ func gosqlx_parse(sql *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_validate -func gosqlx_validate(sql *C.char) *C.char { +func gosqlx_validate(sql *C.char) (result *C.char) { + defer recoverExport(&result, panicValidationJSON) + checkExportPanic("gosqlx_validate") return toJSON(validationResultFor(C.GoString(sql), "")) } @@ -237,7 +241,9 @@ func gosqlx_validate(sql *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_format -func gosqlx_format(sql *C.char) *C.char { +func gosqlx_format(sql *C.char) (result *C.char) { + defer recoverExport(&result, panicSuccessJSON) + checkExportPanic("gosqlx_format") return toJSON(formatResultFor(C.GoString(sql), "")) } @@ -252,7 +258,9 @@ func gosqlx_format(sql *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_extract_tables -func gosqlx_extract_tables(sql *C.char) *C.char { +func gosqlx_extract_tables(sql *C.char) (result *C.char) { + defer recoverExport(&result, panicErrorJSON) + checkExportPanic("gosqlx_extract_tables") return toJSON(extractListFor(C.GoString(sql), "", "tables", gosqlx.ExtractTables)) } @@ -267,7 +275,9 @@ func gosqlx_extract_tables(sql *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_extract_columns -func gosqlx_extract_columns(sql *C.char) *C.char { +func gosqlx_extract_columns(sql *C.char) (result *C.char) { + defer recoverExport(&result, panicErrorJSON) + checkExportPanic("gosqlx_extract_columns") return toJSON(extractListFor(C.GoString(sql), "", "columns", gosqlx.ExtractColumns)) } @@ -282,7 +292,9 @@ func gosqlx_extract_columns(sql *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_extract_functions -func gosqlx_extract_functions(sql *C.char) *C.char { +func gosqlx_extract_functions(sql *C.char) (result *C.char) { + defer recoverExport(&result, panicErrorJSON) + checkExportPanic("gosqlx_extract_functions") return toJSON(extractListFor(C.GoString(sql), "", "functions", gosqlx.ExtractFunctions)) } @@ -297,7 +309,9 @@ func gosqlx_extract_functions(sql *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_extract_metadata -func gosqlx_extract_metadata(sql *C.char) *C.char { +func gosqlx_extract_metadata(sql *C.char) (result *C.char) { + defer recoverExport(&result, panicErrorJSON) + checkExportPanic("gosqlx_extract_metadata") return toJSON(metadataFor(C.GoString(sql), "")) } @@ -308,12 +322,16 @@ func gosqlx_extract_metadata(sql *C.char) *C.char { // *_dialect variant, gosqlx_lint, gosqlx_security_scan, gosqlx_transpile, // gosqlx_dialects and gosqlx_version) must be freed exactly once with this // function to avoid memory leaks. +// Recoverable Go panics are contained at this boundary; this void function +// has no error return channel. // // Parameters: // - ptr: a C string pointer previously returned by a gosqlx_* function. // //export gosqlx_free func gosqlx_free(ptr *C.char) { + defer recoverExport(nil, "") + checkExportPanic("gosqlx_free") C.free(unsafe.Pointer(ptr)) } @@ -324,9 +342,12 @@ func gosqlx_free(ptr *C.char) { // other gosqlx_* result, the caller owns it and must free it exactly once with // gosqlx_free. (Earlier releases returned a process-wide cached pointer here, // which made the documented free a double free on the second call.) +// Returns NULL if a recoverable Go panic occurs. // //export gosqlx_version -func gosqlx_version() *C.char { +func gosqlx_version() (result *C.char) { + defer recoverExport(&result, "") + checkExportPanic("gosqlx_version") return C.CString(VERSION) } diff --git a/pkg/cbinding/cbinding_ext.go b/pkg/cbinding/cbinding_ext.go index 4b397a82f..55b04b76d 100644 --- a/pkg/cbinding/cbinding_ext.go +++ b/pkg/cbinding/cbinding_ext.go @@ -264,7 +264,9 @@ func metadataFor(sql, dialect string) MetadataResult { // pointer with gosqlx_free. // //export gosqlx_parse_dialect -func gosqlx_parse_dialect(sql, dialect *C.char) *C.char { +func gosqlx_parse_dialect(sql, dialect *C.char) (result *C.char) { + defer recoverExport(&result, panicSuccessJSON) + checkExportPanic("gosqlx_parse_dialect") return toJSON(parseResultFor(C.GoString(sql), C.GoString(dialect))) } @@ -276,7 +278,9 @@ func gosqlx_parse_dialect(sql, dialect *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_validate_dialect -func gosqlx_validate_dialect(sql, dialect *C.char) *C.char { +func gosqlx_validate_dialect(sql, dialect *C.char) (result *C.char) { + defer recoverExport(&result, panicValidationJSON) + checkExportPanic("gosqlx_validate_dialect") return toJSON(validationResultFor(C.GoString(sql), C.GoString(dialect))) } @@ -289,7 +293,9 @@ func gosqlx_validate_dialect(sql, dialect *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_format_dialect -func gosqlx_format_dialect(sql, dialect *C.char) *C.char { +func gosqlx_format_dialect(sql, dialect *C.char) (result *C.char) { + defer recoverExport(&result, panicSuccessJSON) + checkExportPanic("gosqlx_format_dialect") return toJSON(formatResultFor(C.GoString(sql), C.GoString(dialect))) } @@ -301,7 +307,9 @@ func gosqlx_format_dialect(sql, dialect *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_extract_tables_dialect -func gosqlx_extract_tables_dialect(sql, dialect *C.char) *C.char { +func gosqlx_extract_tables_dialect(sql, dialect *C.char) (result *C.char) { + defer recoverExport(&result, panicErrorJSON) + checkExportPanic("gosqlx_extract_tables_dialect") return toJSON(extractListFor(C.GoString(sql), C.GoString(dialect), "tables", gosqlx.ExtractTables)) } @@ -313,7 +321,9 @@ func gosqlx_extract_tables_dialect(sql, dialect *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_extract_columns_dialect -func gosqlx_extract_columns_dialect(sql, dialect *C.char) *C.char { +func gosqlx_extract_columns_dialect(sql, dialect *C.char) (result *C.char) { + defer recoverExport(&result, panicErrorJSON) + checkExportPanic("gosqlx_extract_columns_dialect") return toJSON(extractListFor(C.GoString(sql), C.GoString(dialect), "columns", gosqlx.ExtractColumns)) } @@ -326,7 +336,9 @@ func gosqlx_extract_columns_dialect(sql, dialect *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_extract_functions_dialect -func gosqlx_extract_functions_dialect(sql, dialect *C.char) *C.char { +func gosqlx_extract_functions_dialect(sql, dialect *C.char) (result *C.char) { + defer recoverExport(&result, panicErrorJSON) + checkExportPanic("gosqlx_extract_functions_dialect") return toJSON(extractListFor(C.GoString(sql), C.GoString(dialect), "functions", gosqlx.ExtractFunctions)) } @@ -339,7 +351,9 @@ func gosqlx_extract_functions_dialect(sql, dialect *C.char) *C.char { // pointer with gosqlx_free. // //export gosqlx_extract_metadata_dialect -func gosqlx_extract_metadata_dialect(sql, dialect *C.char) *C.char { +func gosqlx_extract_metadata_dialect(sql, dialect *C.char) (result *C.char) { + defer recoverExport(&result, panicErrorJSON) + checkExportPanic("gosqlx_extract_metadata_dialect") return toJSON(metadataFor(C.GoString(sql), C.GoString(dialect))) } @@ -481,7 +495,9 @@ func lintFor(sql, optionsJSON string) LintResult { // pointer with gosqlx_free. // //export gosqlx_lint -func gosqlx_lint(sql, options_json *C.char) *C.char { +func gosqlx_lint(sql, options_json *C.char) (result *C.char) { + defer recoverExport(&result, panicSuccessJSON) + checkExportPanic("gosqlx_lint") return toJSON(lintFor(C.GoString(sql), C.GoString(options_json))) } @@ -540,7 +556,9 @@ func securityScanFor(sql string) SecurityScanResult { // pointer with gosqlx_free. // //export gosqlx_security_scan -func gosqlx_security_scan(sql *C.char) *C.char { +func gosqlx_security_scan(sql *C.char) (result *C.char) { + defer recoverExport(&result, panicSuccessJSON) + checkExportPanic("gosqlx_security_scan") return toJSON(securityScanFor(C.GoString(sql))) } @@ -594,7 +612,9 @@ func transpileFor(sql, from, to string) TranspileResult { // pointer with gosqlx_free. // //export gosqlx_transpile -func gosqlx_transpile(sql, from, to *C.char) *C.char { +func gosqlx_transpile(sql, from, to *C.char) (result *C.char) { + defer recoverExport(&result, panicSuccessJSON) + checkExportPanic("gosqlx_transpile") return toJSON(transpileFor(C.GoString(sql), C.GoString(from), C.GoString(to))) } @@ -615,6 +635,8 @@ type DialectsResult struct { // pointer with gosqlx_free. // //export gosqlx_dialects -func gosqlx_dialects() *C.char { +func gosqlx_dialects() (result *C.char) { + defer recoverExport(&result, panicErrorJSON) + checkExportPanic("gosqlx_dialects") return toJSON(DialectsResult{Dialects: keywords.DialectNames()}) } diff --git a/pkg/cbinding/cbinding_panic_test.go b/pkg/cbinding/cbinding_panic_test.go new file mode 100644 index 000000000..6534530d7 --- /dev/null +++ b/pkg/cbinding/cbinding_panic_test.go @@ -0,0 +1,76 @@ +// 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 main + +import "testing" + +func TestExportsRecoverPanics(t *testing.T) { + sql, dialect := strPtr("SELECT 1"), strPtr("postgresql") + type panicCase struct { + name string + call func() string + flag string + } + var cases []panicCase + for _, e := range allExports { + flag := "" + if e == exportValidate { + flag = "valid" + } else if e == exportParse || e == exportFormat { + flag = "success" + } + cases = append(cases, + panicCase{"gosqlx_" + e.String(), func() string { return callLegacyRaw(e, sql) }, flag}, + panicCase{"gosqlx_" + e.String() + "_dialect", func() string { return callDialectRaw(e, sql, dialect) }, flag}) + } + cases = append(cases, + panicCase{"gosqlx_lint", func() string { return lintRaw(*sql, nil) }, "success"}, + panicCase{"gosqlx_security_scan", func() string { return securityScanRaw(sql) }, "success"}, + panicCase{"gosqlx_transpile", func() string { return transpileRaw(sql, dialect, dialect) }, "success"}, + panicCase{"gosqlx_dialects", dialectsRaw, ""}) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + exportPanicHook.Store(&tc.name) + defer exportPanicHook.Store(nil) + result := decodeMap(t, tc.call()) + if got := errorOf(result); got != "internal error" { + t.Errorf("error = %q, want sanitized internal error", got) + } + if tc.flag != "" && result[tc.flag] != false { + t.Errorf("%s = %v, want false", tc.flag, result[tc.flag]) + } + }) + } + t.Run("gosqlx_version", func(t *testing.T) { + name := "gosqlx_version" + exportPanicHook.Store(&name) + defer exportPanicHook.Store(nil) + if !versionIsNull() { + t.Fatal("version panic must return NULL") + } + }) + t.Run("gosqlx_free", func(t *testing.T) { + name := "gosqlx_free" + exportPanicHook.Store(&name) + defer exportPanicHook.Store(nil) + freeNil() + }) +} + +func TestExportRecoveryDoesNotAllocate(t *testing.T) { + if got := testing.AllocsPerRun(100, freeNil); got != 0 { + t.Fatalf("normal free boundary allocates: %v", got) + } +} diff --git a/pkg/cbinding/cbinding_recover.go b/pkg/cbinding/cbinding_recover.go new file mode 100644 index 000000000..5cd8a5f43 --- /dev/null +++ b/pkg/cbinding/cbinding_recover.go @@ -0,0 +1,49 @@ +// 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 main + +// #include +import "C" + +import "sync/atomic" + +const ( + panicErrorJSON = `{"error":"internal error"}` + panicSuccessJSON = `{"success":false,"error":"internal error"}` + panicValidationJSON = `{"valid":false,"error":"internal error"}` +) + +// exportPanicHook injects a panic at a selected ABI boundary for regression tests. +// Atomic access also keeps concurrent calls safe while the hook is installed. +var exportPanicHook atomic.Pointer[string] + +func checkExportPanic(name string) { + if target := exportPanicHook.Load(); target != nil && *target == name { + panic("injected C binding panic") + } +} + +// recoverExport is deferred directly by every C export. Static error messages +// avoid exposing panic values, which can contain internal paths or SQL input. +// Version has no JSON error channel and returns NULL; free has no return value. +func recoverExport(result **C.char, failure string) { + if recover() != nil && result != nil { + if failure == "" { + *result = nil + } else { + *result = C.CString(failure) + } + } +} diff --git a/pkg/cbinding/cbinding_testhelpers.go b/pkg/cbinding/cbinding_testhelpers.go index 19d892dc1..aa3432545 100644 --- a/pkg/cbinding/cbinding_testhelpers.go +++ b/pkg/cbinding/cbinding_testhelpers.go @@ -280,3 +280,9 @@ func transpileRaw(sql, from, to *string) string { func dialectsRaw() string { return takeResult(gosqlx_dialects()) } + +func versionIsNull() bool { + p := gosqlx_version() + defer freeCString(p) + return p == nil +} diff --git a/pkg/formatter/render_dml.go b/pkg/formatter/render_dml.go index 7eeb7ad10..8b5690b74 100644 --- a/pkg/formatter/render_dml.go +++ b/pkg/formatter/render_dml.go @@ -46,7 +46,7 @@ func writeDMLTop(sb *strings.Builder, f *nodeFormatter, top *ast.TopClause) { } // writeDMLTargets writes the UPDATE / DELETE target list. -func writeDMLTargets(sb *strings.Builder, f *nodeFormatter, d *ast.DMLTargetFields, name string, nameQ ast.NameQuote, alias string, aliasQ ast.NameQuote) { +func writeDMLTargets(sb *strings.Builder, f *nodeFormatter, d ast.DMLTargetFields, name string, nameQ ast.NameQuote, alias string, aliasQ ast.NameQuote) { if d.Only { sb.WriteString(f.kw("ONLY")) sb.WriteString(" ") @@ -100,7 +100,7 @@ func writeDMLOutput(sb *strings.Builder, f *nodeFormatter, output []ast.Expressi } // writeDMLOrderLimit writes the single-table ORDER BY ... LIMIT n clauses. -func writeDMLOrderLimit(sb *strings.Builder, f *nodeFormatter, d *ast.DMLTargetFields) { +func writeDMLOrderLimit(sb *strings.Builder, f *nodeFormatter, d ast.DMLTargetFields) { if len(d.OrderBy) > 0 { sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("ORDER BY")) diff --git a/pkg/formatter/render_select_ext.go b/pkg/formatter/render_select_ext.go index 9598cd156..e711aa961 100644 --- a/pkg/formatter/render_select_ext.go +++ b/pkg/formatter/render_select_ext.go @@ -27,17 +27,9 @@ import ( "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" ) -// noSelectExtras is the read-only stand-in selectExtras returns for a -// statement without Extras. Nothing may write through it. -var noSelectExtras ast.SelectExtras - -// selectExtras returns s.Extras, or a read-only empty SelectExtras when it is -// nil, so the renderers can read the rarer clauses without nil checks. -func selectExtras(s *ast.SelectStatement) *ast.SelectExtras { - if s.Extras != nil { - return s.Extras - } - return &noSelectExtras +// selectExtras returns a copy of the optional SELECT dialect clauses. +func selectExtras(s *ast.SelectStatement) ast.SelectExtras { + return s.DialectExtras() } // writeSelectPrologue writes the MySQL select modifiers and the TOP clause, diff --git a/pkg/gosqlx/realworld_corpus_test.go b/pkg/gosqlx/realworld_corpus_test.go index d6082e20a..f194c4faa 100644 --- a/pkg/gosqlx/realworld_corpus_test.go +++ b/pkg/gosqlx/realworld_corpus_test.go @@ -218,13 +218,13 @@ func assertDialectStandInsUntouched(t *testing.T) { name string got, want any }{ - {"FunctionCall", *(&ast.FunctionCall{}).DialectExtras(), ast.FunctionExtras{}}, - {"BinaryExpression", *(&ast.BinaryExpression{}).DialectExtras(), ast.BinaryExtras{}}, - {"InsertStatement", *(&ast.InsertStatement{}).DialectExtras(), ast.InsertDialectFields{}}, - {"UpdateStatement", *(&ast.UpdateStatement{}).DialectExtras(), ast.DMLTargetFields{}}, - {"DeleteStatement", *(&ast.DeleteStatement{}).DialectExtras(), ast.DMLTargetFields{}}, - {"SelectStatement", *(&ast.SelectStatement{}).DialectExtras(), ast.SelectExtras{}}, - {"TableReference", *(&ast.TableReference{}).DialectModifiers(), ast.TableModifiers{}}, + {"FunctionCall", (&ast.FunctionCall{}).DialectExtras(), ast.FunctionExtras{}}, + {"BinaryExpression", (&ast.BinaryExpression{}).DialectExtras(), ast.BinaryExtras{}}, + {"InsertStatement", (&ast.InsertStatement{}).DialectExtras(), ast.InsertDialectFields{}}, + {"UpdateStatement", (&ast.UpdateStatement{}).DialectExtras(), ast.DMLTargetFields{}}, + {"DeleteStatement", (&ast.DeleteStatement{}).DialectExtras(), ast.DMLTargetFields{}}, + {"SelectStatement", (&ast.SelectStatement{}).DialectExtras(), ast.SelectExtras{}}, + {"TableReference", (&ast.TableReference{}).DialectModifiers(), ast.TableModifiers{}}, {"LiteralValue", (&ast.LiteralValue{}).DialectExtras(), ast.LiteralExtras{}}, } for _, c := range checks { diff --git a/pkg/sql/ast/ast_dml.go b/pkg/sql/ast/ast_dml.go index c1b39fbda..49dfe98bf 100644 --- a/pkg/sql/ast/ast_dml.go +++ b/pkg/sql/ast/ast_dml.go @@ -52,26 +52,22 @@ type DMLTargetFields struct { ReturningInto []Expression } -// noDMLTargetFields is the read-only stand-in returned for a statement -// without Extras. Nothing may write through it. -var noDMLTargetFields DMLTargetFields - -// dmlExtras returns x, or a read-only empty DMLTargetFields when it is nil, -// so renderers and walkers can read the fields without nil checks. -func dmlExtras(x *DMLTargetFields) *DMLTargetFields { +// dmlExtras returns a copy of x, or a zero value when it is nil, so renderers +// and walkers can read the fields without nil checks. +func dmlExtras(x *DMLTargetFields) DMLTargetFields { if x != nil { - return x + return *x } - return &noDMLTargetFields + return DMLTargetFields{} } -// DialectExtras returns the optional dialect fields, or a read-only empty -// value when absent. Call EnsureExtras before writing; never mutate this result. -func (u *UpdateStatement) DialectExtras() *DMLTargetFields { return dmlExtras(u.Extras) } +// DialectExtras returns a copy of the optional dialect fields, or a zero +// value when absent. Use EnsureExtras to modify fields on this node. +func (u *UpdateStatement) DialectExtras() DMLTargetFields { return dmlExtras(u.Extras) } -// DialectExtras returns the optional dialect fields, or a read-only empty -// value when absent. Call EnsureExtras before writing; never mutate this result. -func (d *DeleteStatement) DialectExtras() *DMLTargetFields { return dmlExtras(d.Extras) } +// DialectExtras returns a copy of the optional dialect fields, or a zero +// value when absent. Use EnsureExtras to modify fields on this node. +func (d *DeleteStatement) DialectExtras() DMLTargetFields { return dmlExtras(d.Extras) } // EnsureExtras returns u.Extras, allocating it first when it is nil. Use it // to set one of the DMLTargetFields. @@ -202,7 +198,7 @@ func (l LogErrorsClause) Children() []Node { } // insertDialectChildren appends the nodes of d to children. -func (d *InsertDialectFields) insertDialectChildren(children []Node) []Node { +func (d InsertDialectFields) insertDialectChildren(children []Node) []Node { if d.TableFunction != nil { children = append(children, d.TableFunction) } diff --git a/pkg/sql/ast/dialect_extras.go b/pkg/sql/ast/dialect_extras.go index 50b17cd8f..9cd1d53ac 100644 --- a/pkg/sql/ast/dialect_extras.go +++ b/pkg/sql/ast/dialect_extras.go @@ -80,15 +80,13 @@ func (n *LiteralValue) SetRawBackslash(raw bool) { n.extras.RawBackslash = raw } -var noFunctionExtras FunctionExtras - -// DialectExtras returns the optional dialect metadata, or a read-only empty -// value when absent. Call EnsureExtras before writing; never mutate this result. -func (n *FunctionCall) DialectExtras() *FunctionExtras { +// DialectExtras returns a copy of the optional dialect metadata, or a zero +// value when absent. Use EnsureExtras to modify metadata on this node. +func (n *FunctionCall) DialectExtras() FunctionExtras { if n.Extras != nil { - return n.Extras + return *n.Extras } - return &noFunctionExtras + return FunctionExtras{} } // EnsureExtras returns mutable metadata, allocating it on first use. @@ -99,15 +97,13 @@ func (n *FunctionCall) EnsureExtras() *FunctionExtras { return n.Extras } -var noBinaryExtras BinaryExtras - -// DialectExtras returns the optional dialect metadata, or a read-only empty -// value when absent. Call EnsureExtras before writing; never mutate this result. -func (n *BinaryExpression) DialectExtras() *BinaryExtras { +// DialectExtras returns a copy of the optional dialect metadata, or a zero +// value when absent. Use EnsureExtras to modify metadata on this node. +func (n *BinaryExpression) DialectExtras() BinaryExtras { if n.Extras != nil { - return n.Extras + return *n.Extras } - return &noBinaryExtras + return BinaryExtras{} } // EnsureExtras returns mutable metadata, allocating it on first use. @@ -118,15 +114,13 @@ func (n *BinaryExpression) EnsureExtras() *BinaryExtras { return n.Extras } -var noInsertDialectFields InsertDialectFields - -// DialectExtras returns the optional dialect metadata, or a read-only empty -// value when absent. Call EnsureExtras before writing; never mutate this result. -func (n *InsertStatement) DialectExtras() *InsertDialectFields { +// DialectExtras returns a copy of the optional dialect metadata, or a zero +// value when absent. Use EnsureExtras to modify metadata on this node. +func (n *InsertStatement) DialectExtras() InsertDialectFields { if n.Extras != nil { - return n.Extras + return *n.Extras } - return &noInsertDialectFields + return InsertDialectFields{} } // EnsureExtras returns mutable metadata, allocating it on first use. @@ -137,19 +131,17 @@ func (n *InsertStatement) EnsureExtras() *InsertDialectFields { return n.Extras } -// DialectExtras returns the SELECT dialect clauses for read access. Call -// EnsureExtras before writing; never mutate the empty stand-in it may return. -func (s *SelectStatement) DialectExtras() *SelectExtras { return s.extras() } - -var noTableModifiers TableModifiers +// DialectExtras returns a copy of the SELECT dialect clauses. Use EnsureExtras +// to modify clauses on this node. +func (s *SelectStatement) DialectExtras() SelectExtras { return s.extras() } -// DialectModifiers returns the optional table modifiers for read access. Call -// EnsureModifiers before writing; never mutate the empty stand-in it may return. -func (t *TableReference) DialectModifiers() *TableModifiers { +// DialectModifiers returns a copy of the optional table modifiers. Use +// EnsureModifiers to modify modifiers on this node. +func (t *TableReference) DialectModifiers() TableModifiers { if t.Modifiers != nil { - return t.Modifiers + return *t.Modifiers } - return &noTableModifiers + return TableModifiers{} } // EnsureModifiers returns mutable modifiers, allocating them on first use. diff --git a/pkg/sql/ast/dialect_extras_test.go b/pkg/sql/ast/dialect_extras_test.go index 0c0975521..2af6ed756 100644 --- a/pkg/sql/ast/dialect_extras_test.go +++ b/pkg/sql/ast/dialect_extras_test.go @@ -8,10 +8,10 @@ import ( "testing" ) -func checkLazyDialectMetadata[T any](t *testing.T, stored, read, ensure func() *T) { +func checkLazyDialectMetadata[T any](t *testing.T, stored func() *T, read func() T, ensure func() *T) { t.Helper() var zero T - if stored() != nil || read() == nil || !reflect.DeepEqual(*read(), zero) { + if stored() != nil || !reflect.DeepEqual(read(), zero) { t.Fatal("ordinary node must expose empty metadata without storing it") } if allocs := testing.AllocsPerRun(100, func() { _ = read() }); allocs != 0 { @@ -21,7 +21,7 @@ func checkLazyDialectMetadata[T any](t *testing.T, stored, read, ensure func() * t.Fatal("reading metadata allocated a side struct") } first := ensure() - if first == nil || stored() != first || read() != first || ensure() != first { + if first == nil || stored() != first || !reflect.DeepEqual(read(), *first) || ensure() != first { t.Fatal("Ensure must allocate once and reuse the node's metadata") } } @@ -65,12 +65,75 @@ func TestDialectMetadataLazy(t *testing.T) { n := &SelectStatement{} checkLazyDialectMetadata(t, func() *SelectExtras { return n.Extras }, n.DialectExtras, n.EnsureExtras) }) + t.Run("update", func(t *testing.T) { + n := &UpdateStatement{} + checkLazyDialectMetadata(t, func() *DMLTargetFields { return n.Extras }, n.DialectExtras, n.EnsureExtras) + }) + t.Run("delete", func(t *testing.T) { + n := &DeleteStatement{} + checkLazyDialectMetadata(t, func() *DMLTargetFields { return n.Extras }, n.DialectExtras, n.EnsureExtras) + }) t.Run("table", func(t *testing.T) { n := &TableReference{} checkLazyDialectMetadata(t, func() *TableModifiers { return n.Modifiers }, n.DialectModifiers, n.EnsureModifiers) }) } +// Mutating a read snapshot must never change the defaults exposed by another +// node. This also checks that Ensure keeps metadata specific to its node. +func checkDialectMetadataIsolation[T any](t *testing.T, read, other func() T, ensure func() *T, mutate func(*T)) { + t.Helper() + var zero T + copy := read() + mutate(©) + if reflect.DeepEqual(copy, zero) { + t.Fatal("test mutation did not change metadata") + } + if !reflect.DeepEqual(read(), zero) || !reflect.DeepEqual(other(), zero) { + t.Fatal("read metadata mutation changed node defaults") + } + mutate(ensure()) + copy = read() + if !reflect.DeepEqual(copy, *ensure()) || !reflect.DeepEqual(other(), zero) { + t.Fatal("Ensure must mutate only its own node") + } + copy = zero + if reflect.DeepEqual(read(), copy) { + t.Fatal("read metadata must be a detached value") + } +} + +func TestDialectMetadataReadIsolation(t *testing.T) { + t.Run("function", func(t *testing.T) { + a, b := &FunctionCall{}, &FunctionCall{} + checkDialectMetadataIsolation(t, a.DialectExtras, b.DialectExtras, a.EnsureExtras, func(x *FunctionExtras) { x.Separator = &LiteralValue{} }) + }) + t.Run("binary", func(t *testing.T) { + a, b := &BinaryExpression{}, &BinaryExpression{} + checkDialectMetadataIsolation(t, a.DialectExtras, b.DialectExtras, a.EnsureExtras, func(x *BinaryExtras) { x.Escape = &Identifier{} }) + }) + t.Run("insert", func(t *testing.T) { + a, b := &InsertStatement{}, &InsertStatement{} + checkDialectMetadataIsolation(t, a.DialectExtras, b.DialectExtras, a.EnsureExtras, func(x *InsertDialectFields) { x.RowAlias = "changed" }) + }) + t.Run("select", func(t *testing.T) { + a, b := &SelectStatement{}, &SelectStatement{} + checkDialectMetadataIsolation(t, a.DialectExtras, b.DialectExtras, a.EnsureExtras, func(x *SelectExtras) { x.LimitAll = true }) + }) + t.Run("update", func(t *testing.T) { + a, b := &UpdateStatement{}, &UpdateStatement{} + checkDialectMetadataIsolation(t, a.DialectExtras, b.DialectExtras, a.EnsureExtras, func(x *DMLTargetFields) { x.Only = true }) + }) + t.Run("delete", func(t *testing.T) { + a, b := &DeleteStatement{}, &DeleteStatement{} + checkDialectMetadataIsolation(t, a.DialectExtras, b.DialectExtras, a.EnsureExtras, func(x *DMLTargetFields) { x.Only = true }) + }) + t.Run("table", func(t *testing.T) { + a, b := &TableReference{}, &TableReference{} + checkDialectMetadataIsolation(t, a.DialectModifiers, b.DialectModifiers, a.EnsureModifiers, func(x *TableModifiers) { x.Values = &Values{} }) + }) +} + // The parser draws tuple expressions from a pool even when their parent is // heap-allocated. Both release modes must reach tuples held in cold metadata; // releaseDrawn must continue to leave ordinary expressions to the GC. diff --git a/pkg/sql/ast/select_ext.go b/pkg/sql/ast/select_ext.go index 04661243f..0e72b7dd7 100644 --- a/pkg/sql/ast/select_ext.go +++ b/pkg/sql/ast/select_ext.go @@ -65,17 +65,13 @@ type SelectExtras struct { RowsExamined Expression // MariaDB LIMIT ... ROWS EXAMINED n } -// noSelectExtras is the read-only stand-in extras returns for a statement -// without Extras. Nothing may write through it. -var noSelectExtras SelectExtras - -// extras returns s.Extras, or a read-only empty SelectExtras when it is nil, -// so renderers and walkers can read the clauses without nil checks. -func (s *SelectStatement) extras() *SelectExtras { +// extras returns a copy of s.Extras, or a zero value when it is nil, so +// renderers and walkers can read the clauses without nil checks. +func (s *SelectStatement) extras() SelectExtras { if s.Extras != nil { - return s.Extras + return *s.Extras } - return &noSelectExtras + return SelectExtras{} } // EnsureExtras returns s.Extras, allocating it first when it is nil. Use it diff --git a/pkg/sql/security/raw_statement_test.go b/pkg/sql/security/raw_statement_test.go new file mode 100644 index 000000000..bacf553d2 --- /dev/null +++ b/pkg/sql/security/raw_statement_test.go @@ -0,0 +1,159 @@ +// 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 ( + "encoding/json" + "fmt" + "os" + "reflect" + "sort" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" +) + +// The supplied review matrix contains 91 payloads (the brief calls it 93). +// Expected findings were frozen from the unchanged origin/main ScanSQL rules. +func TestScanSQL_ReviewPayloadMatrix(t *testing.T) { + data, err := os.ReadFile("testdata/review_payload_findings.json") + if err != nil { + t.Fatal(err) + } + var expected [][]Finding + if err := json.Unmarshal(data, &expected); err != nil { + t.Fatal(err) + } + if len(expected) != len(reviewPayloads) { + t.Fatalf("golden payload count %d, want %d", len(expected), len(reviewPayloads)) + } + scanner := NewScanner() + for i, sql := range reviewPayloads { + t.Run(fmt.Sprint(i), func(t *testing.T) { + if got := scanner.ScanSQL(sql).Findings; !reflect.DeepEqual(got, expected[i]) { + t.Errorf("ScanSQL(%q) = %+v, want %+v", sql, got, expected[i]) + } + }) + } +} + +func TestScan_ReviewPayloadMatrixRawStatements(t *testing.T) { + scanner := NewScanner() + for _, dialect := range []keywords.SQLDialect{ + "", keywords.DialectPostgreSQL, keywords.DialectMySQL, keywords.DialectMariaDB, + keywords.DialectSQLServer, keywords.DialectOracle, keywords.DialectSQLite, + keywords.DialectSnowflake, keywords.DialectClickHouse, + } { + for i, sql := range reviewPayloads { + t.Run(fmt.Sprintf("%s/%d", dialect, i), func(t *testing.T) { + tree, err := parser.ParseWithDialect(sql, dialect) + if err != nil { + return // Only statements parseable in this dialect have an AST to scan. + } + defer ast.ReleaseAST(tree) + if len(tree.Statements) != 1 { + return + } + switch tree.Statements[0].(type) { + case *ast.Values, *ast.UtilityStatement, *ast.SetStatement, + *ast.TransactionStatement, *ast.UseStatement, *ast.UnsupportedStatement: + default: + return + } + got, want := scanner.Scan(tree), scanner.ScanSQL(sql) + if !reflect.DeepEqual(findingSignatures(got), findingSignatures(want)) { + t.Errorf("Scan(%q) = %+v, ScanSQL = %+v", sql, got.Findings, want.Findings) + } + }) + } + } +} + +func TestScan_RawStatementsMatchScanSQL(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectPostgreSQL, "VALUES (SLEEP(5))"}, + {keywords.DialectPostgreSQL, "VALUES (pg_sleep(5))"}, + {keywords.DialectMySQL, "VALUES ROW(SLEEP(5))"}, + {keywords.DialectMySQL, "VALUES ROW(pg_sleep(5))"}, + {keywords.DialectPostgreSQL, "VALUES (SLEEP(LOAD_FILE('/x')))"}, + {keywords.DialectPostgreSQL, "VALUES (CAST(SLEEP(5) AS INTEGER))"}, + {keywords.DialectPostgreSQL, "VALUES (CASE WHEN id = 1 THEN SLEEP(5) ELSE 0 END)"}, + {keywords.DialectPostgreSQL, "VALUES ((SELECT pg_sleep(5)))"}, + {keywords.DialectPostgreSQL, "VALUES ('OR 1=1')"}, + {keywords.DialectSQLServer, "IF 1=1 WAITFOR DELAY '0:0:5'"}, + {keywords.DialectSQLServer, "WHILE 1=1 BEGIN WAITFOR DELAY '0:0:5' END"}, + {keywords.DialectSQLServer, "BEGIN WAITFOR DELAY '0:0:5' END"}, + {keywords.DialectMySQL, "SET @a = SLEEP(5)"}, + {keywords.DialectMySQL, "PREPARE stmt FROM 'SELECT SLEEP(5)'"}, + {keywords.DialectSQLServer, "EXEC sp_executesql N'SELECT SLEEP(5)'"}, + {keywords.DialectSnowflake, "EXECUTE IMMEDIATE 'SELECT SLEEP(5)'"}, + {keywords.DialectPostgreSQL, "CREATE POLICY p ON t USING (pg_sleep(5) IS NULL)"}, + {keywords.DialectPostgreSQL, "CREATE TRIGGER trg BEFORE UPDATE ON t FOR EACH ROW WHEN (pg_sleep(5) IS NULL) EXECUTE FUNCTION f()"}, + } + scanner := NewScanner() + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + tree, err := parser.ParseWithDialect(tt.sql, tt.dialect) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + want, got := scanner.ScanSQL(tt.sql), scanner.Scan(tree) + if want.IsClean() { + t.Fatal("payload must produce findings") + } + if !reflect.DeepEqual(findingSignatures(got), findingSignatures(want)) { + t.Errorf("Scan = %+v, ScanSQL = %+v", got.Findings, want.Findings) + } + if got.TotalCount != len(got.Findings) { + t.Errorf("incorrect count: %+v", got) + } + }) + } +} + +func findingSignatures(result *ScanResult) []string { + signatures := make([]string, 0, len(result.Findings)) + for _, finding := range result.Findings { + signatures = append(signatures, string(finding.Pattern)+"/"+string(finding.Severity)) + } + sort.Strings(signatures) + return signatures +} + +func TestScan_RetainedStatementText(t *testing.T) { + // Exercise every raw-text statement type with all raw scanner rules, including + // comments that may be absent from a parser's token-rebuilt statement text. + text := "SELECT * FROM t WHERE id = 1 OR 1=1 --\n; DROP TABLE t; SELECT SLEEP(5), LOAD_FILE('/x') UNION SELECT NULL, NULL FROM information_schema.tables; EXEC('x');" + statements := []ast.Statement{ + &ast.UtilityStatement{Text: text}, &ast.SetStatement{Text: text}, + &ast.TransactionStatement{Text: text}, &ast.UseStatement{Text: text}, + &ast.UnsupportedStatement{RawSQL: text}, + } + scanner := NewScanner() + for _, stmt := range statements { + t.Run(fmt.Sprintf("%T", stmt), func(t *testing.T) { + tree := &ast.AST{Statements: []ast.Statement{stmt}} + if got, want := scanner.Scan(tree), scanner.ScanSQL(text); !reflect.DeepEqual(got, want) { + t.Errorf("Scan = %+v, ScanSQL = %+v", got, want) + } + }) + } +} diff --git a/pkg/sql/security/review_payloads_test.go b/pkg/sql/security/review_payloads_test.go new file mode 100644 index 000000000..cc22e9ddf --- /dev/null +++ b/pkg/sql/security/review_payloads_test.go @@ -0,0 +1,109 @@ +// 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 + +var reviewPayloads = []string{ + "SELECT * FROM users WHERE id = 1 OR 1=1", + "SELECT * FROM users WHERE id = '1' OR '1'='1'", + "SELECT * FROM users WHERE name = '' OR 1=1 --", + "SELECT * FROM users WHERE id = 1 OR TRUE", + "SELECT * FROM users WHERE id = 1 OR 2>1", + "SELECT * FROM users WHERE (id = 1 OR 1=1)", + "SELECT * FROM users WHERE id = 1 OR 1=1 ORDER BY 1", + "SELECT * FROM users WHERE id = 1 OR 1=1 LIMIT 1", + "SELECT * FROM users WHERE id = 1 OR 1=1 FETCH FIRST 1 ROWS ONLY", + "SELECT * FROM users WHERE id = 1 OR 1=1 FOR UPDATE", + "SELECT a FROM users GROUP BY a HAVING 1=1 OR 1=1", + "SELECT * FROM users WHERE id = 1 UNION SELECT username, password FROM users", + "SELECT * FROM users WHERE id = 1 UNION ALL SELECT table_name, NULL FROM information_schema.tables", + "(SELECT * FROM t WHERE id=1) UNION (SELECT password FROM users)", + "SELECT a FROM t WHERE id = 1 UNION SELECT name FROM sys.objects", + "SELECT a FROM t WHERE id = 1 UNION SELECT name FROM sqlite_master", + "SELECT a FROM t WHERE id = 1 UNION SELECT username FROM all_users", + "SELECT a FROM t WHERE id = 1 UNION SELECT usename FROM pg_catalog.pg_user", + "SELECT * FROM users WHERE id = 1; DROP TABLE users", + "SELECT * FROM users WHERE id = 1 AND SLEEP(5)", + "SELECT * FROM users WHERE id = 1 AND SLEEP(5) = 0", + "SELECT * FROM users WHERE id = 1 AND pg_sleep(5) IS NULL", + "SELECT pg_sleep(5)", + "SELECT * FROM users WHERE id = 1 AND BENCHMARK(1000000, MD5('a')) = 0", + "SELECT * FROM users WHERE id = 1; WAITFOR DELAY '0:0:5'", + "SELECT * FROM users WHERE id = 1; EXEC xp_cmdshell 'dir'", + "SELECT LOAD_FILE('/etc/passwd')", + "SELECT * FROM users WHERE id = 1 /**/OR/**/1=1", + "SELECT UTL_HTTP.REQUEST('http://x') FROM dual", + "SELECT DBMS_LOCK.SLEEP(5) FROM dual", + "SELECT * FROM url('http://x', CSV)", + "SELECT * FROM t WHERE id = 1 AND 1 = (SELECT 1 FROM pg_sleep(5))", + "SELECT * FROM generate_series(1, 2) WHERE 1=1 OR 1=1", + "UPDATE users SET pw = 'x' WHERE id = 1 OR 1=1", + "UPDATE users SET pw = SLEEP(5) WHERE id = 1", + "UPDATE users SET pw = 'x' WHERE id = 1 OR 1=1 RETURNING *", + "UPDATE t SET a = 1 FROM t JOIN u ON t.id = u.id WHERE t.id = 1 OR 1=1", + "UPDATE t SET a = 1 FROM u WHERE t.id = u.id OR 1=1", + "UPDATE TOP (1) t SET a = 1 WHERE id = 1 OR 1=1", + "UPDATE t SET a = 1 WHERE id = 1 OR 1=1 ORDER BY a LIMIT 1", + "UPDATE t SET a = 1 OUTPUT inserted.a WHERE id = 1 OR 1=1", + "DELETE FROM users WHERE id = 1 OR 1=1", + "DELETE FROM t USING u WHERE t.id = 1 OR 1=1", + "DELETE t FROM t JOIN u ON t.id = u.id WHERE t.id = 1 OR 1=1", + "DELETE FROM t WHERE id = 1 OR 1=1 RETURNING *", + "DELETE FROM t WHERE id = 1 OR 1=1 ORDER BY a LIMIT 1", + "DELETE TOP (1) FROM t WHERE id = 1 OR 1=1", + "INSERT INTO t VALUES (SLEEP(5))", + "INSERT INTO t (a) VALUES (pg_sleep(5))", + "INSERT INTO t VALUES (1), (SLEEP(5))", + "INSERT INTO t SET a = SLEEP(5)", + "INSERT INTO t (a) VALUES (1) ON DUPLICATE KEY UPDATE a = SLEEP(5)", + "SELECT TOP 1 * FROM t WHERE id = 1 OR 1=1", + "SELECT * FROM t WITH (NOLOCK) WHERE id = 1 OR 1=1", + "SELECT * FROM t AS t1 JOIN u ON 1=1 WHERE t1.id = 1 OR 1=1", + "SELECT * FROM t WHERE id = 1 OR 1=1 QUALIFY ROW_NUMBER() OVER (ORDER BY a) = 1", + "SELECT * FROM t PREWHERE a = 1 WHERE id = 1 OR 1=1", + "SELECT * FROM t WHERE id = 1 OR 1=1 OPTION (RECOMPILE)", + "SELECT * FROM t WHERE id = 1 OR 1=1 INTO OUTFILE '/tmp/x'", + "SELECT DISTINCT ON (a) * FROM t WHERE id = 1 OR 1=1", + "SELECT * FROM t WHERE id = 1 OR 1=1 START WITH a = 1 CONNECT BY PRIOR a = b", + "WITH x AS (SELECT 1 AS id) SELECT * FROM x WHERE id = 1 OR 1=1", + "SELECT * FROM (SELECT 1 AS id) AS q WHERE id = 1 OR 1=1", + "SELECT * FROM t WHERE id = 1 OR 1=1 WINDOW w AS (ORDER BY a)", + "SELECT * FROM t WHERE id = 1 OR 1=1 GROUP BY a WITH ROLLUP", + "SELECT * FROM t LEFT JOIN u ON t.a = u.a WHERE t.id = 1 OR 1=1", + "SELECT * FROM t CROSS APPLY f(t.a) WHERE t.id = 1 OR 1=1", + "SELECT * FROM t SAMPLE 0.1 WHERE id = 1 OR 1=1", + "SELECT * FROM t FINAL WHERE id = 1 OR 1=1", + "SELECT * FROM t TABLESAMPLE SYSTEM (10) WHERE id = 1 OR 1=1", + "SELECT * FROM t WHERE id = 1 OR 1=1 LOCK IN SHARE MODE", + "SELECT * FROM t AS OF TIMESTAMP '2020-01-01' WHERE id = 1 OR 1=1", + "SELECT SQL_CALC_FOUND_ROWS * FROM t WHERE id = 1 OR 1=1", + "SELECT * FROM t WHERE id = 1 OR 1=1 FOR XML PATH('')", + "SELECT * FROM t WHERE id = 1 OR 1=1 LIMIT 1 BY a", + "SELECT * FROM t WHERE id = 1 OR 1=1 SETTINGS max_threads = 1", + "SELECT * INTO newt FROM t WHERE id = 1 OR 1=1", + "VALUES (SLEEP(5))", + "SELECT * FROM t WHERE id = 1 UNION SELECT @@version", + "SELECT * FROM t WHERE id = 1 EXCEPT SELECT password FROM users", + "SELECT a FROM t WHERE id = 1 UNION SELECT b FROM u ORDER BY 1", + "SELECT a FROM t WHERE id = 1 UNION SELECT b FROM u LIMIT 1", + "SELECT 1\nGO\nSELECT * FROM t WHERE id = 1 OR 1=1", + "IF 1=1 WAITFOR DELAY '0:0:5'", + "SELECT * FROM t WHERE id = 1 OR 1=1 AND a = SLEEP(5)", + "SELECT IF(1=1, SLEEP(5), 0)", + "SELECT * FROM t WHERE id = 1 AND MATCH(a) AGAINST (SLEEP(5))", + "SELECT * FROM t WHERE id = 1 OR 'a' = 'a' ESCAPE", + "SELECT * FROM t WHERE a = 1 OR 1=1 MINUS SELECT 1 FROM dual", + "SELECT * FROM t WHERE id = 1 OR 1 = 1 FOR SYSTEM_TIME AS OF '2020-01-01'", + "MERGE INTO t USING s ON 1=1 WHEN MATCHED THEN UPDATE SET a = SLEEP(5)", +} diff --git a/pkg/sql/security/scanner.go b/pkg/sql/security/scanner.go index b16b8b01b..f7e39094a 100644 --- a/pkg/sql/security/scanner.go +++ b/pkg/sql/security/scanner.go @@ -736,9 +736,32 @@ func (s *Scanner) scanStatement(stmt ast.Statement, result *ScanResult) { 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 +// cannot report the same occurrence twice. Counts are updated once by Scan. +func (s *Scanner) scanStatementText(text string, result *ScanResult) { + findings := s.ScanSQL(text) + 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 diff --git a/pkg/sql/security/testdata/review_payload_findings.json b/pkg/sql/security/testdata/review_payload_findings.json new file mode 100644 index 000000000..79cbd4633 --- /dev/null +++ b/pkg/sql/security/testdata/review_payload_findings.json @@ -0,0 +1,881 @@ +[ + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): '1'='1'", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "MEDIUM", + "pattern": "COMMENT_BYPASS", + "description": "Trailing SQL comment may indicate injection", + "risk": "SQL injection via comment-based bypass", + "suggestion": "Sanitize input to remove SQL comments" + }, + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Pattern detected: OR TRUE", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "HIGH", + "pattern": "UNION_GENERIC", + "description": "Pattern detected: UNION SELECT", + "risk": "Possible UNION-based data extraction; review for legitimacy", + "suggestion": "Verify UNION is intentional and all inputs are parameterized" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "UNION_INJECTION", + "description": "Pattern detected: UNION ALL SELECT table_name, NULL FROM information_schema", + "risk": "UNION-based SQL injection with injection fingerprint (system table or NULL padding)", + "suggestion": "Use parameterized queries and block system table access" + }, + { + "severity": "CRITICAL", + "pattern": "UNION_INJECTION", + "description": "Pattern detected: information_schema", + "risk": "UNION-based SQL injection with injection fingerprint (system table or NULL padding)", + "suggestion": "Use parameterized queries and block system table access" + }, + { + "severity": "HIGH", + "pattern": "UNION_GENERIC", + "description": "Pattern detected: UNION ALL SELECT", + "risk": "Possible UNION-based data extraction; review for legitimacy", + "suggestion": "Verify UNION is intentional and all inputs are parameterized" + } + ], + [], + [ + { + "severity": "CRITICAL", + "pattern": "UNION_INJECTION", + "description": "Pattern detected: UNION SELECT name FROM sys.", + "risk": "UNION-based SQL injection with injection fingerprint (system table or NULL padding)", + "suggestion": "Use parameterized queries and block system table access" + }, + { + "severity": "HIGH", + "pattern": "UNION_GENERIC", + "description": "Pattern detected: UNION SELECT", + "risk": "Possible UNION-based data extraction; review for legitimacy", + "suggestion": "Verify UNION is intentional and all inputs are parameterized" + } + ], + [ + { + "severity": "HIGH", + "pattern": "UNION_GENERIC", + "description": "Pattern detected: UNION SELECT", + "risk": "Possible UNION-based data extraction; review for legitimacy", + "suggestion": "Verify UNION is intentional and all inputs are parameterized" + } + ], + [ + { + "severity": "HIGH", + "pattern": "UNION_GENERIC", + "description": "Pattern detected: UNION SELECT", + "risk": "Possible UNION-based data extraction; review for legitimacy", + "suggestion": "Verify UNION is intentional and all inputs are parameterized" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "UNION_INJECTION", + "description": "Pattern detected: UNION SELECT usename FROM pg_catalog", + "risk": "UNION-based SQL injection with injection fingerprint (system table or NULL padding)", + "suggestion": "Use parameterized queries and block system table access" + }, + { + "severity": "HIGH", + "pattern": "UNION_GENERIC", + "description": "Pattern detected: UNION SELECT", + "risk": "Possible UNION-based data extraction; review for legitimacy", + "suggestion": "Verify UNION is intentional and all inputs are parameterized" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "STACKED_QUERY", + "description": "Pattern detected: ; DROP", + "risk": "Stacked query injection with destructive operations", + "suggestion": "Block semicolons in user input or use parameterized queries" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: pg_sleep(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: pg_sleep(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: BENCHMARK(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: WAITFOR DELAY", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "OUT_OF_BAND", + "description": "Pattern detected: xp_cmdshell", + "risk": "Out-of-band data exfiltration or command execution", + "suggestion": "Review and sanitize SQL input" + }, + { + "severity": "CRITICAL", + "pattern": "STACKED_QUERY", + "description": "Pattern detected: ; EXEC", + "risk": "Stacked query injection with destructive operations", + "suggestion": "Block semicolons in user input or use parameterized queries" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "OUT_OF_BAND", + "description": "Pattern detected: LOAD_FILE(", + "risk": "Out-of-band data exfiltration or command execution", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "OUT_OF_BAND", + "description": "Pattern detected: UTL_HTTP", + "risk": "Out-of-band data exfiltration or command execution", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + }, + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: DBMS_LOCK.SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "OUT_OF_BAND", + "description": "Pattern detected: FROM url(", + "risk": "Out-of-band data exfiltration or command execution", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: pg_sleep(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: pg_sleep(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + }, + { + "severity": "CRITICAL", + "pattern": "OUT_OF_BAND", + "description": "Pattern detected: INTO OUTFILE", + "risk": "Out-of-band data exfiltration or command execution", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "UNION_GENERIC", + "description": "Pattern detected: UNION SELECT", + "risk": "Possible UNION-based data extraction; review for legitimacy", + "suggestion": "Verify UNION is intentional and all inputs are parameterized" + } + ], + [], + [ + { + "severity": "HIGH", + "pattern": "UNION_GENERIC", + "description": "Pattern detected: UNION SELECT", + "risk": "Possible UNION-based data extraction; review for legitimacy", + "suggestion": "Verify UNION is intentional and all inputs are parameterized" + } + ], + [ + { + "severity": "HIGH", + "pattern": "UNION_GENERIC", + "description": "Pattern detected: UNION SELECT", + "risk": "Possible UNION-based data extraction; review for legitimacy", + "suggestion": "Verify UNION is intentional and all inputs are parameterized" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + }, + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: WAITFOR DELAY", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + }, + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + }, + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 'a' = 'a'", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1 = 1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + } + ], + [ + { + "severity": "CRITICAL", + "pattern": "TAUTOLOGY", + "description": "Always-true condition detected (tautology): 1=1", + "risk": "Authentication bypass via always-true condition", + "suggestion": "Use parameterized queries to prevent tautology injection" + }, + { + "severity": "HIGH", + "pattern": "TIME_BASED", + "description": "Pattern detected: SLEEP(", + "risk": "Time-based blind SQL injection", + "suggestion": "Review and sanitize SQL input" + } + ] +]