Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
in dialects without them.

### Fixed
- **CLI security scan**: `gosqlx analyze --security` and `gosqlx lint`'s security pass now run the string scanner (`ScanSQL`) as well as the AST scan and merge the findings, so `SELECT 1; DROP TABLE users` is reported as CRITICAL instead of clean. Inline SQL that contains `/` (for example `UNION/**/SELECT` or `4 / 2`) is no longer mistaken for a missing file path.
- **Transpile row limits**: for SQL Server, `LIMIT n OFFSET m` and bare `OFFSET` without ORDER BY add `ORDER BY (SELECT NULL)`, and `FETCH FIRST` gains the required `OFFSET 0 ROWS`. Row limits inside derived tables, JOIN operands and scalar subqueries are now rewritten for the target dialect (Oracle kept `LIMIT` there). `WITH TIES` survives set-operation tails, and targets that cannot express it return an error instead of dropping it.
- **LSP**: requests for unknown methods are answered with `-32601` (MethodNotFound) instead of `-32603`.
- **Formatter fidelity**: Oracle SQL*Plus `/` execute lines are kept by FormatAST, `Formatter.Format`, `FormatWithDialect` and `gosqlx format` (they were dropped, and the CLI refused such scripts). Statement counts do not change: the AST records which statements a `/` follows in `AST.ExecuteSlash`. PostgreSQL `U&"..."` identifiers keep their original spelling when formatting for PostgreSQL or no target (other targets get the decoded name; AST name fields stay decoded), so names with escaped control characters no longer produce output that fails to re-tokenize. Comments next to SQL Server `GO` lines and trailing comments of statements written without `;` stay where they were, and `GO 2 /* c */` keeps both count and comment on every entry point.
- **Formatter performance**: lower-case keyword mode streams into a single builder instead of copying and re-rendering subtrees; it went from 3.5–10x to 1.2–1.5x the cost of upper-case output, with 5x to 500x fewer allocations.
- **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).
Expand Down
116 changes: 116 additions & 0 deletions cmd/gosqlx/cmd/analyze_security_regression_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// 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 cmd

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/ajitpratap0/GoSQLX/pkg/sql/security"
)

func TestAnalyzeSecurityRawSQLFindings(t *testing.T) {
for _, sql := range []string{"SELECT 1; DROP TABLE users", "SELECT '$x$'; DROP TABLE users; SELECT '$x$'"} {
t.Run(sql, func(t *testing.T) {
var out, errOut bytes.Buffer
analyzer := NewAnalyzer(&out, &errOut, CLIAnalyzerOptions{Security: true, Format: "json"})
result, err := analyzer.Analyze(sql)
if err != nil {
t.Fatal(err)
}
if result.Report.CriticalIssues == 0 || result.Report.SecurityScore >= 100 {
t.Fatalf("missing critical finding/score: %+v", result.Report)
}
if err := analyzer.DisplayReport(result.Report); err != nil {
t.Fatal(err)
}
var report AnalysisReport
if err := json.Unmarshal(out.Bytes(), &report); err != nil {
t.Fatal(err)
}
found := false
for _, issue := range report.Issues {
if issue.ID == string(security.PatternStackedQuery) && issue.Severity == IssueSeverityCritical {
found = true
}
}
if !found {
t.Fatalf("missing stacked query in JSON: %s", out.String())
}
out.Reset()
analyzer.Opts.Format = "table"
if err := analyzer.DisplayReport(result.Report); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "CRITICAL") {
t.Fatalf("missing critical finding in text: %s", out.String())
}
})
}
var out bytes.Buffer
analyzer := NewAnalyzer(&out, &out, CLIAnalyzerOptions{Security: true})
result, err := analyzer.Analyze("SELECT id FROM users WHERE id = 1")
if err != nil {
t.Fatal(err)
}
if issues := filterIssuesByCategory(result.Report.Issues, IssueCategorySecurity); len(issues) != 0 {
t.Fatalf("clean SQL security issues: %+v", issues)
}
if _, err := analyzer.Analyze("SELECT 1 UNION/**/SELECT 2"); err != nil {
t.Fatalf("inline SQL treated as path: %v", err)
}
}

func TestLintSecurityRawSQLFindings(t *testing.T) {
for _, sql := range []string{"SELECT 1; DROP TABLE users", "SELECT '$x$'; DROP TABLE users; SELECT '$x$'"} {
var out bytes.Buffer
if count := runSecurityScan(sql, "inline", &out); count == 0 || !strings.Contains(out.String(), "[CRITICAL]") || !strings.Contains(out.String(), string(security.PatternStackedQuery)) {
t.Fatalf("missing critical finding: %d %s", count, out.String())
}
}
var out bytes.Buffer
if count := runSecurityScan("SELECT id FROM users WHERE id = 1", "inline", &out); count != 0 {
t.Fatalf("clean SQL findings: %d %s", count, out.String())
}
}

func TestMergeSecurityFindingsDeduplicatesAndCounts(t *testing.T) {
critical := security.Finding{Pattern: security.PatternStackedQuery, Severity: security.SeverityCritical, Description: "stacked"}
high := security.Finding{Pattern: security.PatternUnionGeneric, Severity: security.SeverityHigh, Description: "union"}
result := mergeSecurityFindings(&security.ScanResult{Findings: []security.Finding{critical}}, &security.ScanResult{Findings: []security.Finding{critical, high}})
if len(result.Findings) != 2 || result.TotalCount != 2 || result.CriticalCount != 1 || result.HighCount != 1 {
t.Fatalf("incorrect merged summary: %+v", result)
}
}

func TestAnalyzeSecurityExistingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "SELECT query.sql")
if err := os.WriteFile(path, []byte("SELECT 1; DROP TABLE users"), 0600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
analyzer := NewAnalyzer(&out, &out, CLIAnalyzerOptions{Security: true})
result, err := analyzer.Analyze(path)
if err != nil {
t.Fatal(err)
}
if result.Report.CriticalIssues == 0 {
t.Fatalf("file contents not scanned: %+v", result.Report)
}
}
11 changes: 5 additions & 6 deletions cmd/gosqlx/cmd/input_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,8 @@ type InputResult struct {
// Detection logic:
// 1. Check if input is a valid file path (os.Stat succeeds)
// 2. If file exists, validate security and read content
// 3. If not a file, check if it looks like a file path (.sql extension, path separators)
// 4. If looks like file path, return file not found error
// 5. Otherwise treat as direct SQL query
// 3. If not a file, accept input that looks like SQL
// 4. Otherwise report missing file paths or invalid input
//
// Security measures:
// - File path validation (path traversal prevention)
Expand Down Expand Up @@ -140,9 +139,9 @@ func DetectAndReadInput(input string) (*InputResult, error) {
}, nil
}

// If stat failed, check if it looks like a file path that doesn't exist
// (contains path separators or has .sql extension)
if strings.Contains(input, string(filepath.Separator)) || strings.HasSuffix(strings.ToLower(input), ".sql") {
// SQL can contain path separators in comments, division, or string literals.
// Only report a missing path when the input does not look like SQL.
if !looksLikeSQL(input) && (strings.Contains(input, string(filepath.Separator)) || strings.HasSuffix(strings.ToLower(input), ".sql")) {
// Looks like a file path but doesn't exist - return the original stat error
return nil, fmt.Errorf("invalid file path: %w", statErr)
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/gosqlx/cmd/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,9 @@ func runSecurityScan(sql string, filename string, outWriter io.Writer) int {
fmt.Fprintf(outWriter, " [WARN] %s: could not parse for security scan: %v\n", filename, err)
return 0
}
defer ast.ReleaseAST(tree)
scanner := sqlsecurity.NewScanner()
result := scanner.Scan(tree)
result := mergeSecurityFindings(scanner.Scan(tree), scanner.ScanSQL(sql))
for _, f := range result.Findings {
fmt.Fprintf(outWriter, " [%s] %s:%d:%d - %s (%s)\n",
f.Severity, filename, f.Line, f.Column, f.Description, f.Pattern)
Expand Down
36 changes: 35 additions & 1 deletion cmd/gosqlx/cmd/sql_analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func (a *SQLAnalyzer) Analyze(astObj *ast.AST, sqlArgs ...string) (*AnalysisRepo

// Run security scanner for injection patterns (tautologies, UNION-based, time-based, etc.)
securityScanner := security.NewScanner()
securityResult := securityScanner.Scan(astObj)
securityResult := mergeSecurityFindings(securityScanner.Scan(astObj), securityScanner.ScanSQL(sql))
a.convertSecurityFindings(securityResult)

// Calculate final metrics and scores
Expand Down Expand Up @@ -591,3 +591,37 @@ func (a *SQLAnalyzer) convertSecurityFindings(result *security.ScanResult) {
a.adjustSecurityScore(sev)
}
}

// mergeSecurityFindings combines AST and raw SQL scans, preserving distinct
// descriptions while avoiding duplicate reports from the two scan paths.
func mergeSecurityFindings(results ...*security.ScanResult) *security.ScanResult {
merged := &security.ScanResult{}
type findingKey struct {
pattern security.PatternType
severity security.Severity
description string
}
seen := make(map[findingKey]bool)
for _, result := range results {
for _, finding := range result.Findings {
key := findingKey{finding.Pattern, finding.Severity, finding.Description}
if seen[key] {
continue
}
seen[key] = true
merged.Findings = append(merged.Findings, finding)
merged.TotalCount++
switch finding.Severity {
case security.SeverityCritical:
merged.CriticalCount++
case security.SeverityHigh:
merged.HighCount++
case security.SeverityMedium:
merged.MediumCount++
case security.SeverityLow:
merged.LowCount++
}
}
}
return merged
}
2 changes: 1 addition & 1 deletion cmd/gosqlx/internal/cmdutil/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func DetectAndReadInput(input string) (*InputResult, error) {
}, nil
}

if strings.Contains(input, string(filepath.Separator)) || strings.HasSuffix(strings.ToLower(input), ".sql") {
if !LooksLikeSQL(input) && (strings.Contains(input, string(filepath.Separator)) || strings.HasSuffix(strings.ToLower(input), ".sql")) {
return nil, fmt.Errorf("invalid file path: %w", statErr)
}

Expand Down
27 changes: 27 additions & 0 deletions cmd/gosqlx/internal/cmdutil/input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,30 @@ func TestLooksLikeSQL(t *testing.T) {
}
}
}

func TestDetectAndReadInputSQLWithSlashes(t *testing.T) {
for _, sql := range []string{"SELECT 1 UNION/**/SELECT 2", "SELECT 4 / 2", "SELECT '/tmp/query.sql'"} {
result, err := DetectAndReadInput(sql)
if err != nil {
t.Fatalf("DetectAndReadInput(%q): %v", sql, err)
}
if result.Type != InputTypeSQL || string(result.Content) != sql {
t.Fatalf("unexpected input: %+v", result)
}
}
// An existing file remains a file even when its name looks like SQL.
path := filepath.Join(t.TempDir(), "SELECT query.sql")
if err := os.WriteFile(path, []byte("SELECT 7"), 0600); err != nil {
t.Fatal(err)
}
result, err := DetectAndReadInput(path)
if err != nil {
t.Fatal(err)
}
if result.Type != InputTypeFile || string(result.Content) != "SELECT 7" {
t.Fatalf("unexpected file input: %+v", result)
}
if _, err := DetectAndReadInput(filepath.Join(t.TempDir(), "missing.sql")); err == nil {
t.Fatal("missing file should fail")
}
}
12 changes: 9 additions & 3 deletions pkg/formatter/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string {
// MySQL select modifiers and TOP, between SELECT [DISTINCT] and columns.
writeSelectPrologue(sb, s, f)

sb.WriteString(f.exprListSQL(s.Columns))
sb.WriteString(selectColumnsForDialect(s.Columns, f))

if into := selectExtras(s).Into; into != nil && !into.Trailing {
sb.WriteString(" ")
Expand Down Expand Up @@ -772,6 +772,12 @@ func renderSetOperationBody(s *ast.SetOperation, opts ast.FormatOptions) string
if t := s.Tail; t != nil {
// ORDER BY / LIMIT / OFFSET / FETCH of the whole operation.
view := t.Select()
// A set tail cannot use TOP, and SQL Server OFFSET/FETCH needs
// ORDER BY. Keep unsupported LIMIT visible for direct formatting;
// Transpile rejects this case before rendering.
if opts.Dialect != "sqlserver" || len(view.OrderBy) > 0 {
normalizeSelectForDialect(view, opts.Dialect)
}
if len(view.OrderBy) > 0 {
sb.WriteString(f.clauseSep())
sb.WriteString(f.kw("ORDER BY"))
Expand Down Expand Up @@ -1249,7 +1255,7 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string {
}
if t.Subquery != nil {
sb.WriteString("(")
sb.WriteString(f.tableSubquerySQL(t.Subquery))
sb.WriteString(tableSubqueryForDialect(t.Subquery, f))
sb.WriteString(")")
} else if rows := ast.RowsFromSQL(t, f.kw); rows != "" {
sb.WriteString(rows)
Expand All @@ -1261,7 +1267,7 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string {
}
} else if t.SetSubquery != nil {
sb.WriteString("(")
sb.WriteString(f.tableSubquerySQL(t.SetSubquery))
sb.WriteString(tableSubqueryForDialect(t.SetSubquery, f))
sb.WriteString(")")
} else if t.DialectModifiers().Values != nil {
sb.WriteString("(")
Expand Down
88 changes: 88 additions & 0 deletions pkg/formatter/render_select_ext.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
package formatter

import (
"reflect"
"strconv"
"strings"

Expand Down Expand Up @@ -476,3 +477,90 @@ func normalizeLimitForDialect(s *ast.SelectStatement, dialect string) {
clearLimit()
}
}

// tableSubqueryForDialect uses the same renderer as a top-level statement so
// derived tables and JOIN operands receive target-dialect row-limit syntax.
func tableSubqueryForDialect(s ast.Statement, f *nodeFormatter) string {
if f.opts.Dialect == "" {
return f.tableSubquerySQL(s)
}
return dialectSubquerySQL(s, f.opts)
}

// dialectSubquerySQL takes options by value so callers' closures need not
// capture (and heap-allocate) the node formatter.
func dialectSubquerySQL(s ast.Statement, parent ast.FormatOptions) string {
opts := nestedOpts(parent)
opts.NewlinePerClause = false
return formatStatement(s, opts)
}

// selectColumnsForDialect preserves expression SQL's parentheses and aliases
// while routing embedded scalar queries through target-dialect rendering.
// Only expressions containing a query need copying; the caller's AST stays intact.
func selectColumnsForDialect(columns []ast.Expression, f *nodeFormatter) string {
plain := f.exprListSQL(columns)
// A scalar subquery always renders a SELECT keyword; without one the
// plain rendering is already correct and costs nothing extra.
if f.opts.Dialect == "" || !containsFoldASCII(plain, "select") {
return plain
}
hasQuery := false
visit := func(n ast.Node) bool {
if _, ok := n.(*ast.SubqueryExpression); ok {
hasQuery = true
}
return !hasQuery
}
queries := make([]bool, len(columns))
for i, column := range columns {
hasQuery = false
ast.Inspect(column, visit)
queries[i] = hasQuery
}
rendered := make([]string, len(columns))
for i, column := range columns {
if !queries[i] {
rendered[i] = f.exprSQL(column)
continue
}
value := copyRenderValue(reflect.ValueOf(column), make(map[uintptr]reflect.Value))
copy, ok := value.Interface().(ast.Expression)
if !ok {
rendered[i] = f.exprSQL(column)
continue
}
opts := f.opts
ast.Inspect(copy, func(n ast.Node) bool {
query, ok := n.(*ast.SubqueryExpression)
if !ok {
return true
}
query.Subquery = &ast.UtilityStatement{Text: dialectSubquerySQL(query.Subquery, opts)}
return false
})
rendered[i] = f.exprSQL(copy)
}
return strings.Join(rendered, ", ")
}

// containsFoldASCII reports whether s contains the lower-case ASCII word sub,
// ignoring ASCII case, without allocating.
func containsFoldASCII(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
j := 0
for ; j < len(sub); j++ {
c := s[i+j]
if 'A' <= c && c <= 'Z' {
c += 'a' - 'A'
}
if c != sub[j] {
break
}
}
if j == len(sub) {
return true
}
}
return false
}
Loading
Loading