#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Dependency-free Spark SQL allowlist guard. The guard answers one question only: is this SQL statement type safe enough to show or pass to spark.sql? It does not prove business correctness. """ from __future__ import annotations import re from dataclasses import dataclass ALLOWED_FIRST = {"select", "with", "insert"} FORBIDDEN = { "delete", "truncate", "drop", "alter", "create", "replace", "merge", "update", "msck", "refresh", "analyze", "cache", "uncache", "vacuum", "optimize", "call", "grant", "revoke", } @dataclass(frozen=True) class GuardResult: status: str first_keyword: str reason: str = "" def as_dict(self) -> dict[str, str]: return { "status": self.status, "first_keyword": self.first_keyword, "reason": self.reason, } def _mask(sql: str) -> str: """Mask strings, comments, and backtick identifiers before token checks.""" out: list[str] = [] i = 0 state = "normal" quote = "" while i < len(sql): ch = sql[i] nxt = sql[i + 1] if i + 1 < len(sql) else "" if state == "normal": if ch in {"'", '"', "`"}: state = "quote" quote = ch out.append(" ") i += 1 elif ch == "-" and nxt == "-": state = "line_comment" out.extend(" ") i += 2 elif ch == "/" and nxt == "*": state = "block_comment" out.extend(" ") i += 2 else: out.append(ch) i += 1 elif state == "quote": out.append(" ") if ch == quote: if nxt == quote and quote in {"'", '"'}: out.append(" ") i += 2 else: state = "normal" i += 1 elif ch == "\\" and nxt: out.append(" ") i += 2 else: i += 1 elif state == "line_comment": out.append("\n" if ch == "\n" else " ") if ch == "\n": state = "normal" i += 1 else: out.append("\n" if ch == "\n" else " ") if ch == "*" and nxt == "/": out.append(" ") state = "normal" i += 2 else: i += 1 return "".join(out) def _body(masked: str) -> str: text = masked.strip() return text[:-1].strip() if text.endswith(";") else text def first_keyword(sql: str) -> str: match = re.search(r"\b([A-Za-z_][\w]*)\b", _body(_mask(sql))) return match.group(1).lower() if match else "unknown" def validate_sql(sql: str, *, allow_overwrite: bool = False) -> GuardResult: if not sql or not sql.strip(): return GuardResult("FAIL", "unknown", "SQL is empty") masked_body = _body(_mask(sql)) first = first_keyword(sql) if ";" in masked_body: return GuardResult("FAIL", first, "Multiple SQL statements are not allowed") if first not in ALLOWED_FIRST: return GuardResult("FAIL", first, f"Only SELECT, WITH, or INSERT is allowed; got {first!r}") lowered = masked_body.lower() found = sorted(word for word in FORBIDDEN if re.search(rf"\b{word}\b", lowered)) if found: return GuardResult("FAIL", first, "Forbidden keyword found: " + ", ".join(found)) if first == "with" and not re.search(r"\bselect\b", lowered): return GuardResult("FAIL", first, "WITH must contain SELECT") if first == "insert": if not re.search(r"\bselect\b", lowered): return GuardResult("FAIL", first, "INSERT must be based on SELECT") if re.search(r"\binsert\s+overwrite\b", lowered) and not allow_overwrite: return GuardResult("FAIL", first, "INSERT OVERWRITE requires --allow-overwrite") return GuardResult("PASS", first, "") def assert_allowed_sql(sql: str, *, allow_overwrite: bool = False) -> str: result = validate_sql(sql, allow_overwrite=allow_overwrite) if result.status != "PASS": raise ValueError(result.reason) return sql.strip().rstrip(";") def safe_spark_sql(spark, sql: str, *, allow_overwrite: bool = False): return spark.sql(assert_allowed_sql(sql, allow_overwrite=allow_overwrite))