Spaces:
Sleeping
Sleeping
File size: 5,796 Bytes
62c290b 53c2169 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | from dataclasses import asdict, dataclass
import re
from typing import Optional
try:
from pglast import parse_sql
except Exception: # pragma: no cover - optional until requirements are installed
parse_sql = None
DANGEROUS_FUNCTIONS = (
"pg_read_file",
"pg_ls_dir",
"pg_stat_file",
"dblink",
"lo_export",
"copy",
)
DANGEROUS_KEYWORDS = (
"alter",
"analyze",
"call",
"comment",
"create",
"delete",
"drop",
"execute",
"grant",
"insert",
"merge",
"refresh",
"reindex",
"revoke",
"truncate",
"update",
"vacuum",
)
@dataclass
class ValidationResult:
valid: bool
reason: str = "SELECT-only query accepted"
statement_type: Optional[str] = None
def to_dict(self) -> dict:
return asdict(self)
def _strip_sql_comments(sql: str) -> str:
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
return re.sub(r"--.*?$", " ", sql, flags=re.MULTILINE)
def _fallback_validate(sql: str) -> ValidationResult:
cleaned = _strip_sql_comments(sql).strip().rstrip(";")
lowered = cleaned.lower()
if not lowered:
return ValidationResult(False, "Empty SQL")
if not (lowered.startswith("select") or lowered.startswith("with")):
first_word = lowered.split(None, 1)[0].upper()
return ValidationResult(False, f"Only SELECT statements are allowed; got {first_word}")
if ";" in cleaned:
return ValidationResult(False, "Multiple SQL statements are not allowed")
tokens = set(re.findall(r"\b[a-z_][a-z0-9_]*\b", lowered))
blocked = sorted(tokens.intersection(DANGEROUS_KEYWORDS))
if blocked:
return ValidationResult(False, f"Unsafe SQL keyword rejected: {blocked[0].upper()}")
for fn in DANGEROUS_FUNCTIONS:
if re.search(rf"\b{re.escape(fn)}\b", lowered):
return ValidationResult(False, f"Unsafe function rejected: {fn}")
return ValidationResult(True, statement_type="SELECT")
def validate_sql(sql: str) -> ValidationResult:
"""Validate model-generated SQL before it can reach a database connection."""
if parse_sql is None:
return _fallback_validate(sql)
try:
statements = parse_sql(sql)
except Exception as exc:
return ValidationResult(False, f"Syntax error: {exc}")
if len(statements) != 1:
return ValidationResult(False, "Exactly one SQL statement is allowed")
stmt = getattr(statements[0], "stmt", None)
statement_type = stmt.__class__.__name__ if stmt is not None else "UNKNOWN"
if statement_type != "SelectStmt":
return ValidationResult(
False,
f"Only SELECT statements are allowed; got {statement_type}",
statement_type=statement_type,
)
fallback_result = _fallback_validate(sql)
if not fallback_result.valid:
fallback_result.statement_type = statement_type
return fallback_result
return ValidationResult(True, statement_type=statement_type)
def strip_trailing_semicolon(sql: str) -> str:
return sql.strip().rstrip(";").strip()
def parameterize_sql_ast(sql: str):
"""
Spec 1: AST-Based SQL Query Parameterization
Parses the SELECT statement, extracts all literal constants (A_Const),
replaces them with positional parameters ($1, $2, etc.), and returns
the parameterized SQL string and the list of extracted parameters.
"""
if parse_sql is None:
# Fallback: No pglast, return raw SQL with no params
return sql, []
try:
from pglast.visitors import Visitor
statements = parse_sql(sql)
if not statements:
return sql, []
# Find all A_Const nodes in the parsed query tree
class ConstVisitor(Visitor):
def __init__(self):
self.constants = []
def visit_A_Const(self, ancestors, node):
val_node = getattr(node, "val", None)
if val_node is not None:
val_type = val_node.__class__.__name__
if val_type == "Integer":
self.constants.append(val_node.ival)
elif val_type == "Float":
self.constants.append(float(val_node.fval))
elif val_type == "String":
self.constants.append(val_node.sval)
elif val_type == "Null":
self.constants.append(None)
visitor = ConstVisitor()
visitor(statements)
parameterized_sql = sql
params = []
idx = 1
for c in visitor.constants:
if isinstance(c, str):
# Match single quoted string matching this constant exactly
escaped_c = re.escape(c)
pattern = rf"'{escaped_c}'"
if re.search(pattern, parameterized_sql):
parameterized_sql = re.sub(pattern, f"${idx}", parameterized_sql, count=1)
params.append(c)
idx += 1
elif isinstance(c, (int, float)):
# Match standalone numeric values matching this constant
pattern = rf"\b{c}\b"
if isinstance(c, float):
parts = str(c).split('.')
pattern = rf"\b{parts[0]}\.\d+\b|\b{re.escape(str(c))}\b"
if re.search(pattern, parameterized_sql):
parameterized_sql = re.sub(pattern, f"${idx}", parameterized_sql, count=1)
params.append(c)
idx += 1
return parameterized_sql, params
except Exception:
# Graceful fallback: return original SQL with no params on parsing issues
return sql, []
|