File size: 2,279 Bytes
464c149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Validates LLM-generated SQL before it touches the database.

Rules enforced:
- Exactly one statement, and it must be a SELECT.
- No DDL/DML keywords anywhere (belt and suspenders alongside the read-only
  DB connection used in app.py).
- Every table referenced must be in the whitelist.
- A LIMIT is added if the query doesn't already have one and isn't an
  aggregate-only query.
"""

import re

ALLOWED_TABLES = {
    "devices",
    "interfaces",
    "traffic_flows",
    "alerts",
    "metrics_timeseries",
}

FORBIDDEN_KEYWORDS = {
    "insert", "update", "delete", "drop", "alter", "create",
    "truncate", "replace", "attach", "detach", "pragma", "vacuum",
}

DEFAULT_LIMIT = 50


class SQLGuardrailError(Exception):
    pass


def _extract_tables(sql: str) -> set:
    # crude but effective for this schema: look for tokens after FROM/JOIN
    tables = set()
    for m in re.finditer(r"\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)", sql, re.IGNORECASE):
        tables.add(m.group(1).lower())
    return tables


def validate_and_sanitize(sql: str) -> str:
    """Raises SQLGuardrailError on anything unsafe. Returns a safe SQL string."""
    if sql is None:
        raise SQLGuardrailError("Model returned no SQL.")

    cleaned = sql.strip().strip("`").strip()
    # strip a leading "sql" language tag if the model added a fenced block anyway
    cleaned = re.sub(r"^sql\s*", "", cleaned, flags=re.IGNORECASE)
    cleaned = cleaned.rstrip(";").strip()

    if not cleaned:
        raise SQLGuardrailError("Empty SQL after cleaning.")

    if ";" in cleaned:
        raise SQLGuardrailError("Multiple statements are not allowed.")

    lowered = cleaned.lower()
    if not lowered.startswith("select"):
        raise SQLGuardrailError("Only SELECT statements are allowed.")

    tokens = set(re.findall(r"[a-zA-Z_]+", lowered))
    forbidden_hit = tokens & FORBIDDEN_KEYWORDS
    if forbidden_hit:
        raise SQLGuardrailError(f"Forbidden keyword(s) detected: {forbidden_hit}")

    referenced = _extract_tables(cleaned)
    unknown = referenced - ALLOWED_TABLES
    if unknown:
        raise SQLGuardrailError(f"Unknown table(s) referenced: {unknown}")

    if "limit" not in lowered:
        cleaned = f"{cleaned} LIMIT {DEFAULT_LIMIT}"

    return cleaned + ";"