Files
replypier/backend/app/services/sql_guardrails.py
T
WangShen007 83a16ed07d feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
2026-10-17 22:05:00 +08:00

370 lines
15 KiB
Python

"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SQL guardrails for the AI ticket investigator's database connector.
The model's SQL is untrusted input. Every guarantee here is enforced OUTSIDE
the model, on the parsed AST (sqlglot):
- exactly one statement, and it must be a plain SELECT (no INTO, no locking)
- every referenced table — including inside CTEs, subqueries, joins and
unions — must be on the connector's explicit allowlist
- dangerous functions (sleep/file/network/admin) are denied
- a LIMIT is always enforced (added or clamped to the connector's max_rows)
- row-scoped tables are rewritten into a subquery pre-filtered to the ticket
customer's own rows, so the agent cannot read across customers
- the statement is re-rendered from the AST, so only canonical SQL executes
The executor adds the second, independent barrier: a read-only transaction
with a statement timeout. Column masking happens on the result rows.
"""
from dataclasses import dataclass
from typing import Iterable, List, Optional, Sequence
import sqlglot
from sqlglot import exp
MASK_VALUE = "***MASKED***"
# Time-wasting, file, network and admin functions. The read-only transaction
# already blocks writes — this denylist is defense-in-depth against DoS and
# data exfiltration via server-side functions.
BLOCKED_FUNCTIONS = {
# postgres
"pg_sleep", "pg_sleep_for", "pg_sleep_until",
"pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file",
"pg_terminate_backend", "pg_cancel_backend", "pg_reload_conf",
"pg_rotate_logfile", "set_config", "pg_promote",
"dblink", "dblink_exec", "dblink_connect", "dblink_connect_u",
"lo_import", "lo_export",
"query_to_xml", "database_to_xml", "table_to_xml",
"pg_logical_slot_get_changes", "pg_logical_slot_peek_changes",
"pg_create_logical_replication_slot", "pg_drop_replication_slot",
# mysql
"sleep", "benchmark", "load_file", "get_lock", "release_lock",
"master_pos_wait", "sys_eval", "sys_exec",
}
# Statement/expression node types that must never appear anywhere in the tree.
BLOCKED_NODE_TYPES = (
exp.Insert, exp.Update, exp.Delete, exp.Merge,
exp.Create, exp.Drop, exp.Alter, exp.TruncateTable,
exp.Grant, exp.Command, exp.Transaction, exp.Set,
exp.Into, exp.Lock,
)
# Default schema names: a bare table reference resolves to these, so an
# allowlist entry "public.orders" also authorizes "orders".
DEFAULT_SCHEMAS = {"public", "dbo"}
_DIALECTS = {"postgresql": "postgres", "postgres": "postgres", "mysql": "mysql"}
@dataclass
class SqlValidationResult:
ok: bool
sql: Optional[str] = None # canonical SQL, only when ok
reason: Optional[str] = None # block reason, only when not ok
def _normalize_allowlist(allowed_tables: Iterable[str]) -> set:
"""Expand allowlist entries so both qualified and bare references match
when the schema is a default one."""
allowed = set()
for entry in allowed_tables or []:
entry = (entry or "").strip().lower()
if not entry:
continue
allowed.add(entry)
if "." in entry:
schema, _, name = entry.rpartition(".")
if schema in DEFAULT_SCHEMAS:
allowed.add(name)
return allowed
def _table_reference(table: exp.Table) -> str:
name = (table.name or "").lower()
if table.db:
return f"{table.db.lower()}.{name}"
return name
def _cte_names(statement: exp.Expression) -> set:
return {cte.alias_or_name.lower() for cte in statement.find_all(exp.CTE)}
def _blocked_function(statement: exp.Expression) -> Optional[str]:
for func in statement.find_all(exp.Func):
name = (func.sql_name() or "").lower()
if name in BLOCKED_FUNCTIONS:
return name
if isinstance(func, exp.Anonymous):
anon = (func.name or "").lower()
if anon in BLOCKED_FUNCTIONS:
return anon
return None
def normalize_scope_map(row_scope: Optional[dict]) -> dict:
"""Expand row-scope entries the same way as the allowlist, so a rule on
"public.orders" also binds a bare "orders" reference."""
scoped = {}
for raw_table, raw_column in (row_scope or {}).items():
table = (raw_table or "").strip().lower()
column = (raw_column or "").strip()
if not table or not column:
continue
scoped[table] = column
if "." in table:
schema, _, name = table.rpartition(".")
if schema in DEFAULT_SCHEMAS:
scoped.setdefault(name, column)
return scoped
def _apply_row_scope(
statement: exp.Select,
scoped: dict,
scope_value: Optional[str],
cte_names: set,
case_insensitive: bool = False,
) -> tuple:
"""Rewrite every scoped base table into a pre-filtered subquery:
FROM orders o -> FROM (SELECT * FROM orders WHERE email = 'x') AS o
Filtering the *relation* rather than checking the model's WHERE clause is
what makes this unbypassable. Whatever the model writes outside — OR 1=1,
a UNION arm, a correlated subquery — it can only ever see rows the wrapper
already returned. Validating a model-supplied predicate instead would mean
proving that no OR, no join and no outer reference widens it, which is not
a thing you can check reliably.
Returns (statement, error). Fails closed: a scoped table with no value to
scope by is refused, never silently unscoped.
"""
targets = []
for table in statement.find_all(exp.Table):
reference = _table_reference(table)
if not reference:
continue
if not table.db and reference in cte_names:
continue
column = scoped.get(reference)
if column is None and table.name:
column = scoped.get(table.name.lower())
if column:
targets.append((table, column))
if not targets:
return statement, None
if not (scope_value or "").strip():
return None, (
"This table is restricted to the rows of the ticket's own customer, "
"and this ticket has no customer identity to scope by. Set the "
"ticket's customer, or query a table that is not customer-scoped."
)
value = scope_value.strip()
for table, column in targets:
alias_name = table.alias or table.name
inner_table = table.copy()
inner_table.set("alias", None)
column_ref = exp.Column(this=exp.to_identifier(column))
# Emails are compared case-insensitively — the same person may be
# stored as Bob@x.com here and bob@x.com there, and an exact match
# would silently return nothing rather than their rows. Costs an index
# on the raw column unless one exists on LOWER(col).
predicate = exp.EQ(
this=exp.Lower(this=column_ref) if case_insensitive else column_ref,
# Literal.string escapes on render — never string-format a value
# into SQL.
expression=exp.Literal.string(value.lower() if case_insensitive else value),
)
inner = (
exp.Select().select(exp.Star()).from_(inner_table).where(predicate)
)
table.replace(
exp.Subquery(this=inner, alias=exp.TableAlias(this=exp.to_identifier(alias_name)))
)
return statement, None
def _force_limit(statement: exp.Select, max_rows: int) -> exp.Select:
"""Add a LIMIT, or clamp an existing numeric one, to max_rows."""
limit_node = statement.args.get("limit")
if limit_node is not None:
literal = limit_node.expression
if isinstance(literal, exp.Literal) and literal.is_int:
if int(literal.this) <= max_rows:
return statement
return statement.limit(max_rows, copy=False)
def validate_sql(
raw_sql: str,
allowed_tables: Sequence[str],
max_rows: int,
engine: str = "postgresql",
masked_columns: Optional[Iterable[str]] = None,
row_scope: Optional[dict] = None,
scope_value: Optional[str] = None,
scope_case_insensitive: bool = False,
) -> SqlValidationResult:
"""Validate untrusted SQL against the connector policy. Returns canonical
SQL to execute, or the reason it was blocked.
row_scope maps a table to the column identifying the customer who owns the
row ({"orders": "customer_email"}); scope_value is that customer's value
for the ticket being investigated. Listed tables are rewritten so only
their rows are visible."""
dialect = _DIALECTS.get((engine or "").lower(), "postgres")
if not (raw_sql or "").strip():
return SqlValidationResult(ok=False, reason="Empty query")
try:
statements = sqlglot.parse(raw_sql, read=dialect)
except sqlglot.errors.ParseError as e:
return SqlValidationResult(ok=False, reason=f"SQL could not be parsed: {e}")
statements = [s for s in statements if s is not None]
if len(statements) != 1:
return SqlValidationResult(
ok=False, reason="Exactly one SQL statement is allowed"
)
statement = statements[0]
if not isinstance(statement, exp.Select):
return SqlValidationResult(
ok=False,
reason="Only plain SELECT statements are allowed "
"(wrap set operations like UNION in a subquery)",
)
for node_type in BLOCKED_NODE_TYPES:
found = statement.find(node_type)
if found is not None:
return SqlValidationResult(
ok=False, reason=f"Disallowed SQL construct: {found.key.upper()}"
)
blocked_func = _blocked_function(statement)
if blocked_func is not None:
return SqlValidationResult(ok=False, reason=f"Disallowed function: {blocked_func}")
allowed = _normalize_allowlist(allowed_tables)
if not allowed:
return SqlValidationResult(ok=False, reason="No tables are allowlisted for this connector")
cte_names = _cte_names(statement)
for table in statement.find_all(exp.Table):
reference = _table_reference(table)
if not reference:
continue
# References to CTE names defined in this query are fine — the CTE
# bodies themselves are walked by this same loop.
if not table.db and reference in cte_names:
continue
if reference not in allowed:
return SqlValidationResult(
ok=False, reason=f"Table not on the allowlist: {reference}"
)
# Masked columns may not be referenced at all — output-name masking alone
# is alias-bypassable (SELECT email AS x), and blocking references also
# prevents value probing via WHERE clauses. SELECT * and t.* stay allowed:
# they expand to real column names that mask_rows redacts by name.
masked = {c.strip().lower() for c in masked_columns or [] if c and c.strip()}
if masked:
# Names that denote a whole row when used as a bare value.
scope_names = {t.name.lower() for t in statement.find_all(exp.Table) if t.name}
scope_names |= {
t.alias.lower() for t in statement.find_all(exp.Table) if t.alias
}
scope_names |= cte_names
for column in statement.find_all(exp.Column):
# A qualified/bare star (t.* or *) nested inside a function or cast
# collapses the row into one value (to_jsonb(c.*), (c.*)::text),
# defeating name-based masking — only a top-level projection star
# is safe. Same for a bare Star node.
if isinstance(column.this, exp.Star) and not isinstance(column.parent, exp.Select):
return SqlValidationResult(
ok=False,
reason="Row expansion (t.*) inside an expression is not allowed with masked columns",
)
if (column.name or "").lower() in masked and not isinstance(column.this, exp.Star):
return SqlValidationResult(
ok=False,
reason=f"Column '{column.name}' is masked and cannot be referenced",
)
# A bare (unqualified) reference to a table/alias/CTE name is a
# whole-row value — SELECT customers, to_jsonb(c), c::text, row(c).
# These serialize masked fields past name-based masking.
if not column.table and (column.name or "").lower() in scope_names:
return SqlValidationResult(
ok=False,
reason=(
f"Whole-row reference to '{column.name}' is not allowed with "
"masked columns — select individual columns instead"
),
)
for star in statement.find_all(exp.Star):
# Safe: top-level projection (SELECT *), the star of a t.* Column
# node (handled above), and count(*) — a cardinality marker that
# never exposes row values.
if not isinstance(star.parent, (exp.Select, exp.Column, exp.Count)):
return SqlValidationResult(
ok=False,
reason="Row expansion (*) inside an expression is not allowed with masked columns",
)
# Last, so the rewrite can't perturb the checks above: they inspect what
# the model actually wrote, not the wrappers added on its behalf.
scoped = normalize_scope_map(row_scope)
if scoped:
statement, scope_error = _apply_row_scope(
statement, scoped, scope_value, cte_names,
case_insensitive=scope_case_insensitive,
)
if scope_error is not None:
return SqlValidationResult(ok=False, reason=scope_error)
statement = _force_limit(statement, max_rows)
# comments=False: comments are dropped from the canonical SQL — MySQL
# executable comments (/*! ... */) must never reach the server.
return SqlValidationResult(
ok=True, sql=statement.sql(dialect=dialect, comments=False)
)
def mask_rows(
columns: List[str], rows: List[tuple], masked_columns: Iterable[str]
) -> List[tuple]:
"""Replace values of masked columns (matched by column name, case
insensitive) so PII never reaches the model."""
masked = {c.strip().lower() for c in masked_columns or [] if c and c.strip()}
if not masked:
return rows
mask_indexes = {i for i, col in enumerate(columns) if col.lower() in masked}
if not mask_indexes:
return rows
return [
tuple(MASK_VALUE if i in mask_indexes else value for i, value in enumerate(row))
for row in rows
]