| """Safety layer for SQL query validation and enforcement.""" |
|
|
| import re |
| from enum import Enum |
| from dataclasses import dataclass |
|
|
|
|
| class QueryType(Enum): |
| SAFE_READ = "safe_read" |
| UNSAFE_READ = "unsafe_read" |
| WRITE = "write" |
| DANGEROUS = "dangerous" |
| UNKNOWN = "unknown" |
|
|
|
|
| @dataclass |
| class SafetyResult: |
| approved: bool |
| query_type: QueryType |
| reason: str |
|
|
|
|
| SAFE_READ_PATTERNS = [ |
| re.compile(r"^\s*SELECT\b", re.IGNORECASE), |
| re.compile(r"^\s*EXPLAIN\b", re.IGNORECASE), |
| re.compile(r"^\s*PRAGMA\b", re.IGNORECASE), |
| re.compile(r"^\s*SHOW\b", re.IGNORECASE), |
| re.compile(r"^\s*DESCRIBE\b", re.IGNORECASE), |
| re.compile(r"^\s*WITH\b.*\bSELECT\b", re.IGNORECASE | re.DOTALL), |
| ] |
|
|
| WRITE_PATTERNS = [ |
| re.compile(r"^\s*INSERT\b", re.IGNORECASE), |
| re.compile(r"^\s*UPDATE\b", re.IGNORECASE), |
| re.compile(r"^\s*DELETE\b", re.IGNORECASE), |
| re.compile(r"^\s*REPLACE\b", re.IGNORECASE), |
| re.compile(r"^\s*MERGE\b", re.IGNORECASE), |
| ] |
|
|
| DANGEROUS_PATTERNS = [ |
| re.compile(r"^\s*DROP\b", re.IGNORECASE), |
| re.compile(r"^\s*ALTER\b", re.IGNORECASE), |
| re.compile(r"^\s*CREATE\b", re.IGNORECASE), |
| re.compile(r"^\s*TRUNCATE\b", re.IGNORECASE), |
| re.compile(r"^\s*GRANT\b", re.IGNORECASE), |
| re.compile(r"^\s*REVOKE\b", re.IGNORECASE), |
| ] |
|
|
| SQL_INJECTION_PATTERNS = [ |
| re.compile(r"(?:--|#|/\*)", re.IGNORECASE), |
| re.compile(r";\s*\w", re.IGNORECASE), |
| re.compile(r"'\s*OR\s+'", re.IGNORECASE), |
| re.compile(r"'\s*OR\s+\"", re.IGNORECASE), |
| re.compile(r"UNION\s+ALL\s+SELECT", re.IGNORECASE), |
| re.compile(r"INTO\s+OUTFILE", re.IGNORECASE), |
| re.compile(r"LOAD_FILE\s*\(", re.IGNORECASE), |
| re.compile(r"BENCHMARK\s*\(", re.IGNORECASE), |
| re.compile(r"SLEEP\s*\(", re.IGNORECASE), |
| re.compile(r"WAITFOR\s+DELAY", re.IGNORECASE), |
| ] |
|
|
|
|
| class SafetyValidator: |
| def __init__(self, read_only: bool = True, max_rows: int = 1000, max_query_time: int = 30): |
| self.read_only = read_only |
| self.max_rows = max_rows |
| self.max_query_time = max_query_time |
|
|
| def classify(self, sql: str) -> QueryType: |
| stripped = sql.strip() |
| if not stripped: |
| return QueryType.UNKNOWN |
|
|
| for pattern in DANGEROUS_PATTERNS: |
| if pattern.search(stripped): |
| return QueryType.DANGEROUS |
|
|
| for pattern in WRITE_PATTERNS: |
| if pattern.search(stripped): |
| return QueryType.WRITE |
|
|
| for pattern in SAFE_READ_PATTERNS: |
| if pattern.search(stripped): |
| return QueryType.SAFE_READ |
|
|
| return QueryType.UNKNOWN |
|
|
| def check_injection(self, sql: str) -> str | None: |
| for pattern in SQL_INJECTION_PATTERNS: |
| if pattern.search(sql): |
| return f"Suspected SQL injection pattern detected" |
| return None |
|
|
| def validate(self, sql: str) -> SafetyResult: |
| if not sql or not sql.strip(): |
| return SafetyResult( |
| approved=False, |
| query_type=QueryType.UNKNOWN, |
| reason="Empty query", |
| ) |
|
|
| injection = self.check_injection(sql) |
| if injection: |
| return SafetyResult( |
| approved=False, |
| query_type=QueryType.UNKNOWN, |
| reason=injection, |
| ) |
|
|
| qtype = self.classify(sql) |
|
|
| if self.read_only and qtype in (QueryType.WRITE, QueryType.DANGEROUS): |
| return SafetyResult( |
| approved=False, |
| query_type=qtype, |
| reason=f"Query type '{qtype.value}' is not allowed in read-only mode. " |
| f"Set DATABASE_WRITE_ENABLED=true to allow write operations.", |
| ) |
|
|
| if qtype == QueryType.UNKNOWN: |
| return SafetyResult( |
| approved=False, |
| query_type=qtype, |
| reason=f"Unrecognized query type. Only SELECT, EXPLAIN, PRAGMA, SHOW, DESCRIBE are allowed.", |
| ) |
|
|
| if qtype == QueryType.UNSAFE_READ: |
| return SafetyResult( |
| approved=True, |
| query_type=qtype, |
| reason="Query approved with warning: contains potentially unsafe read operations", |
| ) |
|
|
| return SafetyResult( |
| approved=True, |
| query_type=qtype, |
| reason="Query approved", |
| ) |
|
|
| def ensure_limit(self, sql: str) -> str: |
| if self.max_rows <= 0: |
| return sql |
| stripped = sql.strip().rstrip(";") |
| lower = stripped.lower() |
| if "limit " in lower or "top " in lower: |
| return sql |
| if lower.startswith("select"): |
| return f"{stripped} LIMIT {self.max_rows}" |
| return sql |
|
|