- Enhanced the `AnalystAgent` class to include an `_audit_terminal` method for logging query denials, clarifications, and errors, ensuring compliance and traceability. - Updated error handling paths to call the new audit method, capturing relevant details such as question, user authentication, and SQL context. - Introduced new validation checks in `sql_guard.py` to enforce ownership filters for sensitive queries, improving security measures. - Added unit tests to verify the correct logging behavior and ownership filter enforcement, ensuring robust functionality. This update significantly strengthens the auditing capabilities of the analyst agent, enhancing security and compliance in query handling.
215 lines
8.5 KiB
Python
215 lines
8.5 KiB
Python
"""SQL 五层校验(只读白名单 / 多语句拦截 / 表白名单 / 行级归属 / 粒度控制)。
|
|
|
|
说明:本环境无法安装 sqlglot,此处用「关键字 + 正则 + 白名单」实现自包含的只读强校验;
|
|
生产环境在数据库层叠加只读账号(双保险),并建议替换为 sqlglot AST 解析。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
# 禁止出现的写操作/危险关键字
|
|
FORBIDDEN_KEYWORDS = (
|
|
"insert", "update", "delete", "drop", "alter", "create", "truncate",
|
|
"grant", "revoke", "replace", "call", "exec", "load_file",
|
|
"into outfile", "into dumpfile", "sleep(",
|
|
)
|
|
|
|
# 表白名单(数据分析 Agent 可读的表)
|
|
CORE_TABLES = {
|
|
"core_customer", "core_customer_risk", "core_customer_advisor",
|
|
"core_holding", "core_trade", "core_cash_flow", "core_product",
|
|
"core_product_nav", "core_staff", "core_risk_grade",
|
|
"core_suitability_rule", "core_industry",
|
|
}
|
|
AGENT_TABLES = {
|
|
"risk_alert", "customer_profile_l1", "customer_profile_l2", "customer_profile_l3",
|
|
}
|
|
ALL_TABLES = CORE_TABLES | AGENT_TABLES
|
|
|
|
# 涉及客户维度的表(行级归属 / 粒度控制用)
|
|
CUSTOMER_TABLES = {
|
|
"core_customer", "core_customer_risk", "core_customer_advisor",
|
|
"core_holding", "core_trade", "core_cash_flow",
|
|
}
|
|
|
|
# 含 customer_id 维度的 Agent 表(须与 Core 客户表同等行级约束)
|
|
AGENT_ROW_SCOPED_TABLES = {
|
|
"risk_alert", "customer_profile_l1", "customer_profile_l2", "customer_profile_l3",
|
|
}
|
|
|
|
ROW_SCOPED_TABLES = CUSTOMER_TABLES | AGENT_ROW_SCOPED_TABLES
|
|
|
|
# 运营/聚合域禁止查明细(SELECT * 不含 customer_id 子串时仍可能泄露)
|
|
OPS_FORBIDDEN_DETAIL_TABLES = AGENT_ROW_SCOPED_TABLES
|
|
|
|
# 敏感列(存储层已脱敏;此处供下游解读/日志二次校验,避免引用明文)
|
|
SENSITIVE_COLUMNS = {
|
|
"mobile", "phone", "id_card", "id_no", "idcard", "bank_card", "card_no",
|
|
"display_name", "real_name", "customer_name",
|
|
}
|
|
|
|
|
|
class SqlGuardError(Exception):
|
|
"""SQL 校验失败(403)。"""
|
|
|
|
def __init__(self, error_code: str, message: str) -> None:
|
|
super().__init__(message)
|
|
self.error_code = error_code
|
|
self.message = message
|
|
|
|
|
|
@dataclass
|
|
class ValidationResult:
|
|
allowed: bool
|
|
error_code: str = ""
|
|
message: str = ""
|
|
tables: list[str] = field(default_factory=list)
|
|
has_customer_detail: bool = False
|
|
|
|
|
|
def _split_statements(sql: str) -> list[str]:
|
|
return [s.strip() for s in sql.split(";") if s.strip()]
|
|
|
|
|
|
def extract_tables(sql: str) -> list[str]:
|
|
"""从 FROM/JOIN 提取表名(去 db 前缀、反引号)。"""
|
|
names: list[str] = []
|
|
for m in re.finditer(r"\b(?:from|join)\s+([`\w.]+)", sql, re.IGNORECASE):
|
|
t = m.group(1).strip("`")
|
|
names.append(t.split(".")[-1])
|
|
return names
|
|
|
|
|
|
def extract_cte_names(sql: str) -> set[str]:
|
|
"""提取 WITH ... AS 定义的 CTE 别名(这些不是物理表,应从白名单校验中排除)。"""
|
|
low = sql.lower()
|
|
m = re.search(r"\bwith\b(.+?)\bselect\b", low, re.DOTALL)
|
|
if not m:
|
|
return set()
|
|
names: set[str] = set()
|
|
for part in re.split(r",", m.group(1)):
|
|
mm = re.search(r"([\w]+)\s+as\s*\(", part)
|
|
if mm:
|
|
names.add(mm.group(1).lower())
|
|
return names
|
|
|
|
|
|
def extract_customer_literals(sql: str) -> list[str]:
|
|
"""提取 SQL 中出现的客户编号字面量。"""
|
|
return re.findall(r"CUST-[\w-]+", sql, re.IGNORECASE)
|
|
|
|
|
|
def _is_select(sql: str) -> bool:
|
|
s = sql.lstrip().lower()
|
|
return s.startswith("select") or s.startswith("with")
|
|
|
|
|
|
def _ops_has_customer_detail(sql: str) -> bool:
|
|
"""运营(ops)粒度控制:剔除 COUNT(DISTINCT customer_id) 后仍出现 customer_id 即视为下钻客户维度。"""
|
|
low = sql.lower()
|
|
cleaned = re.sub(r"count\s*\(\s*(distinct\s+)?customer_id\s*\)", "", low)
|
|
return "customer_id" in cleaned
|
|
|
|
|
|
def _has_ownership_filter(sql: str) -> bool:
|
|
"""WHERE/JOIN ON 中是否存在 customer_id 或 advisor_id 的 = / IN 过滤(非 SELECT 列名占位)。"""
|
|
low = sql.lower()
|
|
if re.search(r"\bwhere\b[\s\S]*?\b(customer_id|advisor_id)\s*(=|in\b)", low):
|
|
return True
|
|
if re.search(r"\bjoin\b[\s\S]*?\bon\b[\s\S]*?\b(customer_id|advisor_id)\s*=", low):
|
|
return True
|
|
return False
|
|
|
|
|
|
def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None) -> ValidationResult:
|
|
"""对生成/改写的 SQL 做只读 + 表白名单 + 域规则校验。
|
|
|
|
domain: full(analyst) / assigned(advisor) / risk(risk_officer) / aggregate(ops)
|
|
"""
|
|
if not sql or not sql.strip():
|
|
raise SqlGuardError("SQL_EMPTY", "SQL 为空")
|
|
|
|
# 1) 只读 + 单语句
|
|
stmts = _split_statements(sql)
|
|
if len(stmts) != 1:
|
|
raise SqlGuardError("SQL_MULTI_STATEMENT", "禁止多语句")
|
|
if not _is_select(stmts[0]):
|
|
raise SqlGuardError("SQL_NOT_SELECT", "仅允许 SELECT 只读查询")
|
|
low = sql.lower()
|
|
for kw in FORBIDDEN_KEYWORDS:
|
|
if " " in kw or "(" in kw:
|
|
if kw in low:
|
|
raise SqlGuardError("SQL_FORBIDDEN", f"检测到危险关键字:{kw}")
|
|
continue
|
|
if re.search(rf"\b{re.escape(kw)}\b", low):
|
|
raise SqlGuardError("SQL_FORBIDDEN", f"检测到危险关键字:{kw}")
|
|
|
|
# 2) 表白名单(CTE 别名除外)
|
|
tables = extract_tables(sql)
|
|
cte_names = extract_cte_names(sql)
|
|
unknown = [t for t in tables if t not in ALL_TABLES and t.lower() not in cte_names]
|
|
if unknown:
|
|
raise SqlGuardError("SQL_TABLE_NOT_ALLOWED", f"表不在白名单:{', '.join(unknown)}")
|
|
|
|
result = ValidationResult(allowed=True, tables=tables)
|
|
|
|
# 3) 域规则
|
|
if domain == "ops" or domain == "aggregate":
|
|
if any(t in OPS_FORBIDDEN_DETAIL_TABLES for t in tables):
|
|
raise SqlGuardError(
|
|
"AUTH_403_SCOPE",
|
|
"运营角色不可查预警台账或画像明细表",
|
|
)
|
|
if _ops_has_customer_detail(sql):
|
|
raise SqlGuardError("AUTH_403_SCOPE", "运营角色仅可查客户维度之上的聚合结果")
|
|
if extract_customer_literals(sql):
|
|
raise SqlGuardError("AUTH_403_SCOPE", "运营角色不可查指定客户")
|
|
|
|
elif domain == "advisor" or domain == "assigned":
|
|
literals = extract_customer_literals(sql)
|
|
scope = set(scope_customer_ids or [])
|
|
out = [c for c in literals if c.upper() not in {s.upper() for s in scope}]
|
|
if out:
|
|
raise SqlGuardError("AUTH_403_NOT_ASSIGNED", f"无权访问客户:{', '.join(out)}")
|
|
touches_row_scoped = any(t in ROW_SCOPED_TABLES for t in tables)
|
|
if touches_row_scoped and not _has_ownership_filter(sql):
|
|
raise SqlGuardError("AUTH_403_SCOPE", "涉及客户数据的查询必须包含归属过滤条件")
|
|
result.has_customer_detail = touches_row_scoped
|
|
|
|
elif domain == "self":
|
|
literals = extract_customer_literals(sql)
|
|
scope = set(scope_customer_ids or [])
|
|
if not scope:
|
|
raise SqlGuardError("AUTH_403_SCOPE", "客户问数缺少本人 customer_id")
|
|
out = [c for c in literals if c.upper() not in {s.upper() for s in scope}]
|
|
if out:
|
|
raise SqlGuardError("AUTH_403_NOT_OWNER", f"仅能查询本人数据,无权访问:{', '.join(out)}")
|
|
touches_row_scoped = any(t in ROW_SCOPED_TABLES for t in tables)
|
|
if touches_row_scoped and not _has_ownership_filter(sql):
|
|
raise SqlGuardError("AUTH_403_SCOPE", "客户问数涉及客户维度表时必须带本人 customer_id 过滤")
|
|
result.has_customer_detail = touches_row_scoped
|
|
|
|
elif domain == "risk" or domain == "risk_officer":
|
|
# 台账全量 + 客户只读:允许白名单内全部表
|
|
result.has_customer_detail = any(t in CUSTOMER_TABLES for t in tables)
|
|
|
|
elif domain == "full" or domain == "analyst":
|
|
result.has_customer_detail = any(t in CUSTOMER_TABLES for t in tables)
|
|
|
|
else:
|
|
raise SqlGuardError("AUTH_403_ROLE", f"未知数据域:{domain}")
|
|
|
|
return result
|
|
|
|
|
|
def inject_ownership(sql: str, customer_ids: list[str]) -> str:
|
|
"""行级归属强制注入(advisor):把名下客户白名单包成子查询过滤。
|
|
|
|
仅当 SQL 结果暴露 customer_id 时可安全包裹;否则由生成阶段的 prompt 注入口径。
|
|
"""
|
|
if not customer_ids:
|
|
raise SqlGuardError("AUTH_403_SCOPE", "无可用归属白名单")
|
|
id_list = ", ".join(f"'{c}'" for c in customer_ids)
|
|
return f"SELECT * FROM ({sql.strip().rstrip(';')}) AS _scoped WHERE customer_id IN ({id_list})"
|