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, []