mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 18:41:59 +00:00
d071bf119f
一、表结构、实体与引文
- 数据源工具查库时顺带存一份表结构快照(schema_snapshot),和查询快照一起进台账、进封存;
台账里的查询记下 SQL 用到的表
- 报告里的表名、字段名可以点:反引号里的名字一律核对,正文里的裸名字只在明显是标识符时才自动链接,
常见英文单词不链接;点开看到它出现在哪次查询、类型、所属的表,快照被截断时写明
- 找不到的名字标成可疑实体:在快照、SQL、结果列里都没有的记「可能是编造的名字」并给出最接近的 3 个;
快照被截断、核对不了的记「核对不了」,不冤枉成编造
- 检索引文 [[q:K1|原文]]:原文在检索快照的命中片段里逐字出现(空白归一化后)才算有出处,点开高亮原文位置
- 自动链接的名字不算句子的引用,只有写作者写的引用才算
二、结论句与出具
- 报告节点新增 claims:off(默认,保持原样)或 require_citation(没有引用的结论句计入缺口)
- 受管级别的正式运行里,可疑实体和没有引用的结论句按「有缺口」降档,不拦截;探索运行和已发布级别只标注
- 契约的 claims 和报告节点的设置取更严的那个;受管正式运行里写 ignore 也按降档处理,并给出警告
- 出具横幅除了数字,也写出可疑实体和没有引用的结论句
三、受管门禁 G1–G5(只看图和配置)
- G1 带契约的出口,成果字段的文字只能来自报告撰写节点
- G2 模型写的文字不绕过报告撰写节点直接流进出口
- G3 报告撰写节点写明 numbers: strict、on_violation: fail、claims: require_citation
- G4 口径卡的输入不来自计算角色的沙箱代码
- G5 给口径卡供数的 agent 配 output_schema 并开 cite_fields
- 受管级别是错误、挡住发布;已发布级别只给警告;已经发布的版本照常运行,下次发布时按新规则检查
- 每条规则都接进发布前自动修复(一键修复 / 给选项 / 交给 Copilot);禁止的改动补上关 cite_fields、
删 output_schema、放宽契约 claims;Copilot 兜底不许自己把代码节点标成 source、不许放宽报告节点
- 发布弹窗里选「交给 Copilot」时明确写「正在交给 Copilot」
四、旧运行与审计
- 没有契约的旧答案:数字和已封存的查询单元格、口径卡值按数值比对,每个数最多 3 个候选,
默认折叠,写明「猜测的来源,不能当证据」
- 有契约的旧运行:候选只从已封存的口径卡里找,人为插进工件表的行不当证据
- 证据审计:GET /api/runs/{id}/evidence/audit 按「无证据 / 可疑实体 / 有出处 / 旧运行猜测」分组,
可导出 JSON 和 CSV(带封存状态);记录页和画布上的报告节点显示「有出处 N · 无证据 M」
五、SQL 守卫修正(安全)
- 去注释改成单遍扫描,认得字符串和各方言的引号(单引号转义、MySQL 反斜杠与 #、PostgreSQL 的 $$ 与嵌套注释、
Oracle 的 q'[…]'、SQLite 的方括号);以前字符串里的 -- 会被当成注释,SELECT '--'; DROP … 这类写法能骗过多语句拦截
- 看不到服务器设置时每种读法都扫一遍,读法之间切分不一致就拒绝,任何一种读法是写操作就按写操作处理
- 执行查询和工具危险判定都按数据源的方言检查
六、其他
- 修掉检查脚本里画布检查的一处竞态,并行估时按实测更新
474 lines
20 KiB
Python
474 lines
20 KiB
Python
"""SQL 安全守卫。
|
||
|
||
这是数据源接入里唯一不能出错的地方:SQL 是 Copilot 或 agent 生成的,不是人写的。
|
||
模型会写出什么,事前无法穷举——所以防线不能建立在"模型应该不会这么干"上。
|
||
|
||
三层,从外到内:
|
||
|
||
1. **只读判定**:只读数据源只放行确定无副作用的语句。用白名单而不是黑名单——
|
||
黑名单永远漏,`TRUNCATE`、`MERGE`、`CALL`、`GRANT`、各家方言的私货写不完。
|
||
2. **多语句拦截**:`SELECT 1; DROP TABLE users` 这种拼接必须在到达驱动之前就断掉。
|
||
DBAPI 层的 `execute` 多数驱动只跑第一条,但"多数"不是"全部",MySQL 开了
|
||
`CLIENT_MULTI_STATEMENTS` 就会全跑。不赌驱动的默认值。
|
||
3. **结果集上限**:行数 + 字节 + 超时。一条 `SELECT * FROM 亿级表` 不该把后端打爆,
|
||
也不该把几百 MB 塞进模型的上下文。
|
||
|
||
刻意不引入 sqlparse 之类的重型依赖:完整解析 SQL 方言是个无底洞,而这里需要的
|
||
判断很窄——"第一个关键字是什么"和"有没有第二条语句"。窄问题用窄解法,
|
||
把复杂度留在能看懂的范围内。
|
||
|
||
所有判断都建立在同一次扫描上(_tokens):一遍从左到右,同时认注释、字符串、标识符引号和
|
||
分号。以前先用正则去注释、再认字符串,字符串里的 `--`、`/*` 被当成注释删掉,执行的 SQL
|
||
和写的不一样。扫描要和数据库的读法一致,而读法跟方言、跟服务器设置走(MySQL 的
|
||
NO_BACKSLASH_ESCAPES / ANSI_QUOTES,PG 的 standard_conforming_strings)。守卫不知道
|
||
服务器开了哪个,就按每种可能的读法各扫一遍,读出来不一致的直接拒掉。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass
|
||
|
||
# 只读数据源放行的首关键字。只有确定不产生副作用的才进这个名单。
|
||
# 注意 SHOW/DESCRIBE/EXPLAIN 要留着:agent 探查表结构靠它们。
|
||
_READONLY_VERBS = frozenset({
|
||
"select", "with", "show", "describe", "desc", "explain", "pragma", "values",
|
||
})
|
||
|
||
# 即便在可写数据源上也一律拒绝的语句。这类操作的破坏是不可逆的,
|
||
# 不该由一个实验工具里的模型来发起——要做请人去数据库客户端里做。
|
||
_ALWAYS_DENIED = frozenset({
|
||
"drop", "truncate", "grant", "revoke", "alter", "create", "shutdown",
|
||
"attach", "detach",
|
||
})
|
||
|
||
# 首关键字是查询、正文却能写的语句。只看首关键字的话它们全都能混过去:
|
||
# WITH k AS (SELECT 1) DELETE FROM t SQLite / MySQL / PG 都认
|
||
# WITH x AS (DELETE … RETURNING *) SELECT … PG 的数据修改 CTE
|
||
# SELECT … INTO backup / INTO OUTFILE '…' PG 建表 / MySQL 写服务器文件
|
||
# EXPLAIN ANALYZE DELETE … PG 的 ANALYZE 会真的执行
|
||
# 关键字后面紧跟 ( 的不算:那是同名函数(MySQL 的 INSERT()、TRUNCATE())。
|
||
_HIDDEN_WRITE = re.compile(
|
||
r"\b(insert|update|delete|merge|upsert|truncate|drop|alter|create|grant|revoke|"
|
||
r"call|copy|into|attach|detach|vacuum|reindex|lock\s+tables?|replace\s+into)\b(?!\s*\()",
|
||
re.I,
|
||
)
|
||
|
||
# 查询里一调用就有副作用的函数。函数没法白名单,这里只收一小撮要命的:
|
||
# set_config 能把 PG 连接层的只读关掉(真库实测:关掉后同一连接的下一个事务
|
||
# 就能写),dblink 另开一条不受只读约束的连接,nextval/setval 推进序列,其余是
|
||
# 杀连接、读写服务器文件、加载本地扩展。函数名可以加引号写("set_config"(…)),
|
||
# 所以名字两边允许引号,扫描时也只抹字符串、不抹标识符。
|
||
_SIDE_EFFECT_CALL = re.compile(
|
||
r"[\"`]?\b(set_config|nextval|setval|pg_terminate_backend|pg_cancel_backend|pg_reload_conf|"
|
||
r"lo_import|lo_export|lo_unlink|dblink\w*|load_extension)\b[\"`]?\s*\(",
|
||
re.I,
|
||
)
|
||
|
||
# SQLite 的 PRAGMA 有读有写,而且写有两种写法:`= 值` 和 `(值)`。
|
||
# 只放行确定只读的:前一组的参数是表名/索引名,后一组不带参数。
|
||
_PRAGMA_READ_WITH_ARG = frozenset({
|
||
"table_info", "table_xinfo", "table_list", "index_list", "index_info",
|
||
"index_xinfo", "foreign_key_list",
|
||
})
|
||
_PRAGMA_READ_NO_ARG = frozenset({
|
||
"table_list", "database_list", "collation_list", "function_list", "module_list",
|
||
"pragma_list", "compile_options", "encoding", "page_count", "page_size",
|
||
"freelist_count", "schema_version", "user_version", "application_id", "data_version",
|
||
})
|
||
_PRAGMA = re.compile(r"pragma\s+(?:\w+\s*\.\s*)?(\w+)\s*(.*)$", re.I | re.S)
|
||
|
||
|
||
class SqlRejected(ValueError):
|
||
"""SQL 没通过守卫。消息直接给模型看,所以要说清楚为什么被拒、该怎么改。"""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class QueryLimits:
|
||
max_rows: int = 1000
|
||
max_bytes: int = 1_000_000
|
||
#: 数据源可以在 options.query_timeout_s 里自己定(见 data.engine.query_timeout),可以带小数
|
||
timeout_seconds: float = 30
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _Reading:
|
||
"""数据库怎么切分一段 SQL:哪些字符开始注释、字符串、带引号的标识符。"""
|
||
|
||
name: str
|
||
#: '…' 里的反斜杠转义下一个字符(MySQL 默认;PG 关了 standard_conforming_strings 时)
|
||
backslash: bool = False
|
||
#: "…" 是字符串而不是标识符(MySQL 没开 ANSI_QUOTES 时),跟 '…' 一样认反斜杠
|
||
dq_string: bool = False
|
||
#: `…` 是标识符(MySQL、SQLite)
|
||
backtick: bool = False
|
||
#: […] 是标识符(SQLite)
|
||
brackets: bool = False
|
||
#: $$…$$、$tag$…$tag$ 是字符串(PostgreSQL)
|
||
dollar: bool = False
|
||
#: E'…' 里认反斜杠转义(PostgreSQL)
|
||
e_strings: bool = False
|
||
#: /* /* */ */ 可以嵌套(PostgreSQL)
|
||
nested: bool = False
|
||
#: # 开始行注释(MySQL)
|
||
hash_comment: bool = False
|
||
#: q'[…]' 这类自选定界符的字符串(Oracle)
|
||
q_quote: bool = False
|
||
|
||
|
||
_GENERIC = _Reading("generic", backtick=True)
|
||
_SQLITE = _Reading("sqlite", backtick=True, brackets=True)
|
||
_PG = _Reading("postgresql", dollar=True, e_strings=True, nested=True)
|
||
_PG_OLD = _Reading("postgresql-backslash", backslash=True, dollar=True, e_strings=True, nested=True)
|
||
_MYSQL = _Reading("mysql", backslash=True, dq_string=True, backtick=True, hash_comment=True)
|
||
_MYSQL_NBE = _Reading("mysql-no-backslash", dq_string=True, backtick=True, hash_comment=True)
|
||
_MYSQL_ANSI = _Reading("mysql-ansi", backslash=True, backtick=True, hash_comment=True)
|
||
_MYSQL_BOTH = _Reading("mysql-ansi-no-backslash", backtick=True, hash_comment=True)
|
||
_ORACLE = _Reading("oracle", q_quote=True)
|
||
|
||
# 每种方言的读法:第一种是服务器默认设置下的读法,其余是服务器可能开着的其他设置。
|
||
# 守卫看不到服务器的设置,所以每种都要扫,读出来不一样就拒
|
||
_READINGS: dict[str, tuple[_Reading, ...]] = {
|
||
"sqlite": (_SQLITE,),
|
||
"postgresql": (_PG, _PG_OLD),
|
||
"postgres": (_PG, _PG_OLD),
|
||
"mysql": (_MYSQL, _MYSQL_NBE, _MYSQL_ANSI, _MYSQL_BOTH),
|
||
"mariadb": (_MYSQL, _MYSQL_NBE, _MYSQL_ANSI, _MYSQL_BOTH),
|
||
"oracle": (_ORACLE,),
|
||
}
|
||
# 不知道方言时每种读法都算上
|
||
_ALL_READINGS: tuple[_Reading, ...] = (_GENERIC, *dict.fromkeys(
|
||
r for readings in _READINGS.values() for r in readings))
|
||
|
||
_AMBIGUOUS = (
|
||
"这条 SQL 里的引号、反斜杠或注释,在数据库的不同设置下会读成不同的语句"
|
||
"(比如 MySQL 的 NO_BACKSLASH_ESCAPES、ANSI_QUOTES)。"
|
||
"字符串里的单引号写成两个单引号 '',不要用反斜杠转义;注释删掉再试"
|
||
)
|
||
|
||
_DOLLAR_TAG = re.compile(r"\$(?:[^\W\d]\w*)?\$")
|
||
_Q_CLOSE = {"[": "]", "(": ")", "{": "}", "<": ">"}
|
||
|
||
|
||
def _readings(dialect: str | None) -> tuple[_Reading, ...]:
|
||
return _READINGS.get((dialect or "").lower(), _ALL_READINGS)
|
||
|
||
|
||
def _reading(dialect: str | None) -> _Reading:
|
||
"""服务器默认设置下的读法。不知道方言时用通用读法(标准 SQL 加反引号)。"""
|
||
return _READINGS.get((dialect or "").lower(), (_GENERIC,))[0]
|
||
|
||
|
||
def _word_char(ch: str) -> bool:
|
||
return ch.isalnum() or ch in "_$"
|
||
|
||
|
||
def _prefixed(sql: str, i: int, letters: str) -> bool:
|
||
"""i 处的引号前面紧挨着一个前缀字母(E'…'),而且这个字母不是某个标识符的尾巴。"""
|
||
return i >= 1 and sql[i - 1] in letters and not (i >= 2 and _word_char(sql[i - 2]))
|
||
|
||
|
||
def _q_quote_at(sql: str, i: int) -> bool:
|
||
"""Oracle 的 q'…' / nq'…':引号后面紧跟的字符是定界符,不能是空白。"""
|
||
if i + 1 >= len(sql) or sql[i + 1].isspace() or not (i >= 1 and sql[i - 1] in "qQ"):
|
||
return False
|
||
j = i - 2
|
||
if j >= 0 and sql[j] in "nN":
|
||
j -= 1
|
||
return not (j >= 0 and _word_char(sql[j]))
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _Token:
|
||
#: code / comment / string / ident / sep
|
||
kind: str
|
||
text: str
|
||
#: 开、闭定界符的长度,抹内容时保留它们
|
||
open: int = 0
|
||
close: int = 0
|
||
|
||
|
||
def _quoted_end(sql: str, i: int, quote: str, backslash: bool) -> tuple[int, int]:
|
||
"""从 i 处的开引号往后找闭引号。返回 (结束位置, 闭引号长度);没闭合的一直到末尾。"""
|
||
j = i + 1
|
||
while j < len(sql):
|
||
ch = sql[j]
|
||
if backslash and ch == "\\":
|
||
j += 2
|
||
continue
|
||
if ch == quote:
|
||
if j + 1 < len(sql) and sql[j + 1] == quote: # 两个引号是转义,不是结束
|
||
j += 2
|
||
continue
|
||
return j + 1, 1
|
||
j += 1
|
||
return len(sql), 0
|
||
|
||
|
||
def _tokens(sql: str, reading: _Reading) -> list[_Token]:
|
||
"""一遍扫完:注释、字符串、带引号的标识符、分号各自成段,其余是代码。"""
|
||
out: list[_Token] = []
|
||
n = len(sql)
|
||
code = 0 # 当前这段代码的起点
|
||
i = 0
|
||
|
||
def flush(upto: int) -> None:
|
||
if upto > code:
|
||
out.append(_Token("code", sql[code:upto]))
|
||
|
||
while i < n:
|
||
ch = sql[i]
|
||
nxt = sql[i + 1] if i + 1 < n else ""
|
||
prev = sql[i - 1] if i else ""
|
||
token: _Token | None = None
|
||
end = i
|
||
if (ch == "-" and nxt == "-") or (ch == "#" and reading.hash_comment):
|
||
end = sql.find("\n", i)
|
||
end = n if end < 0 else end
|
||
token = _Token("comment", sql[i:end])
|
||
elif ch == "/" and nxt == "*":
|
||
if reading.nested:
|
||
depth, end = 0, i
|
||
while end < n:
|
||
if sql.startswith("/*", end):
|
||
depth, end = depth + 1, end + 2
|
||
elif sql.startswith("*/", end):
|
||
depth, end = depth - 1, end + 2
|
||
if depth == 0:
|
||
break
|
||
else:
|
||
end += 1
|
||
end = min(end, n)
|
||
else:
|
||
close = sql.find("*/", i + 2)
|
||
end = n if close < 0 else close + 2
|
||
token = _Token("comment", sql[i:end])
|
||
elif ch == ";":
|
||
end = i + 1
|
||
token = _Token("sep", ";")
|
||
elif ch == "'":
|
||
if reading.q_quote and _q_quote_at(sql, i):
|
||
closer = _Q_CLOSE.get(nxt, nxt) + "'"
|
||
found = sql.find(closer, i + 2)
|
||
end, tail = (n, 0) if found < 0 else (found + 2, 2)
|
||
token = _Token("string", sql[i:end], 2, tail)
|
||
else:
|
||
e_string = reading.e_strings and _prefixed(sql, i, "eE")
|
||
end, tail = _quoted_end(sql, i, "'", reading.backslash or e_string)
|
||
token = _Token("string", sql[i:end], 1, tail)
|
||
elif ch == '"':
|
||
end, tail = _quoted_end(sql, i, '"', reading.backslash and reading.dq_string)
|
||
token = _Token("string" if reading.dq_string else "ident", sql[i:end], 1, tail)
|
||
elif ch == "`" and reading.backtick:
|
||
end, tail = _quoted_end(sql, i, "`", False)
|
||
token = _Token("ident", sql[i:end], 1, tail)
|
||
elif ch == "[" and reading.brackets:
|
||
close = sql.find("]", i + 1)
|
||
end, tail = (n, 0) if close < 0 else (close + 1, 1)
|
||
token = _Token("ident", sql[i:end], 1, tail)
|
||
elif ch == "$" and reading.dollar and not (prev and _word_char(prev)):
|
||
tag = _DOLLAR_TAG.match(sql, i)
|
||
if tag:
|
||
found = sql.find(tag.group(0), tag.end())
|
||
width = len(tag.group(0))
|
||
end, tail = (n, 0) if found < 0 else (found + width, width)
|
||
token = _Token("string", sql[i:end], width, tail)
|
||
if token is None:
|
||
i += 1
|
||
continue
|
||
flush(i)
|
||
out.append(token)
|
||
i = code = end
|
||
flush(n)
|
||
return out
|
||
|
||
|
||
def _joined(tokens: list[_Token]) -> str:
|
||
"""注释换成一个空格,其余原样拼回去。空格不能省:'a'/**/'b' 删成 'a''b' 就成了一个字符串。"""
|
||
return "".join(" " if t.kind == "comment" else t.text for t in tokens)
|
||
|
||
|
||
def _statements(sql: str, reading: _Reading) -> list[str]:
|
||
out: list[str] = []
|
||
buf: list[_Token] = []
|
||
for token in [*_tokens(sql, reading), _Token("sep", ";")]:
|
||
if token.kind == "sep":
|
||
stmt = _joined(buf).strip()
|
||
if stmt:
|
||
out.append(stmt)
|
||
buf = []
|
||
else:
|
||
buf.append(token)
|
||
return out
|
||
|
||
|
||
def strip_comments(sql: str, *, dialect: str | None = None) -> str:
|
||
"""去掉注释再判断。否则 `/*x*/ DROP TABLE t` 的首关键字会被看成注释内容。
|
||
|
||
字符串、带引号的标识符里的 `--`、`/*` 不是注释,原样保留。
|
||
"""
|
||
return _joined(_tokens(sql, _reading(dialect)))
|
||
|
||
|
||
def split_statements(sql: str, *, dialect: str | None = None) -> list[str]:
|
||
"""按分号切语句(去掉注释),但要认得字符串字面量里的分号。
|
||
|
||
`SELECT ';'` 只是一条语句。简单 split(";") 会把它切成两半,然后把后半截
|
||
当成"第二条语句"拒掉——那是误杀,比漏杀更让人无从下手。
|
||
"""
|
||
return _statements(sql, _reading(dialect))
|
||
|
||
|
||
def first_verb(sql: str, *, dialect: str | None = None) -> str:
|
||
"""取语句的首关键字(小写)。括号开头的 `(SELECT ...)` 也要认出来。"""
|
||
text = strip_comments(sql, dialect=dialect).lstrip().lstrip("(").lstrip()
|
||
match = re.match(r"[A-Za-z_]+", text)
|
||
return match.group(0).lower() if match else ""
|
||
|
||
|
||
def _blanked(tokens: list[_Token], *, identifiers: bool) -> str:
|
||
out: list[str] = []
|
||
for t in tokens:
|
||
if t.kind == "comment":
|
||
out.append(" ")
|
||
elif t.kind == "string" or (t.kind == "ident" and identifiers):
|
||
inner = len(t.text) - t.open - t.close
|
||
out.append(t.text[:t.open] + " " * inner + t.text[len(t.text) - t.close:])
|
||
else:
|
||
out.append(t.text)
|
||
return "".join(out)
|
||
|
||
|
||
def _blank_quoted(sql: str, *, identifiers: bool = True, dialect: str | None = None) -> str:
|
||
"""把引号里的内容换成空格:字符串 'please delete' 和标识符 "update" 都不是关键字。
|
||
|
||
identifiers=False 时只抹字符串、保留 "…" 和 `…`——找函数调用时要看得见
|
||
被引号括起来的函数名。
|
||
"""
|
||
return _blanked(_tokens(sql, _reading(dialect)), identifiers=identifiers)
|
||
|
||
|
||
def _pragma_write_reason(stmt: str, reading: _Reading = _GENERIC) -> str | None:
|
||
match = _PRAGMA.match(_joined(_tokens(stmt, reading)).strip())
|
||
if not match:
|
||
return "看不出这条 PRAGMA 读的是什么"
|
||
name, rest = match.group(1).lower(), match.group(2).strip()
|
||
if rest.startswith("="):
|
||
return f"PRAGMA {name} = … 会改数据库设置"
|
||
if name in _PRAGMA_READ_WITH_ARG or (name in _PRAGMA_READ_NO_ARG and not rest):
|
||
return None
|
||
return (
|
||
f"PRAGMA {name}{' ' + rest if rest else ''} 不在只读白名单里"
|
||
"(看表结构用 table_info / index_list / foreign_key_list)"
|
||
)
|
||
|
||
|
||
def _write_reason(stmt: str, reading: _Reading = _GENERIC) -> str | None:
|
||
"""这条语句会不会写。会写就返回一句说给模型听的原因,不会写返回 None。
|
||
|
||
首关键字只是第一道:以查询开头的语句正文照样能写(见 _HIDDEN_WRITE),
|
||
所以 SELECT / WITH / EXPLAIN 还要把正文扫一遍。SHOW / DESCRIBE / VALUES
|
||
结构上写不了,不扫——`SHOW CREATE TABLE` 里的 CREATE 不是在建表。
|
||
"""
|
||
tokens = _tokens(stmt, reading)
|
||
text = _joined(tokens).lstrip().lstrip("(").lstrip()
|
||
match = re.match(r"[A-Za-z_]+", text)
|
||
verb = match.group(0).lower() if match else ""
|
||
if verb not in _READONLY_VERBS:
|
||
return f"{verb.upper()} 会修改数据"
|
||
if verb == "pragma":
|
||
return _pragma_write_reason(stmt, reading)
|
||
if verb in ("select", "with", "explain"):
|
||
hidden = _HIDDEN_WRITE.search(_blanked(tokens, identifiers=True))
|
||
if hidden:
|
||
word = " ".join(hidden.group(1).split()).upper()
|
||
return f"语句里有 {word}——以查询开头的语句也能写数据"
|
||
call = _SIDE_EFFECT_CALL.search(_blanked(tokens, identifiers=False))
|
||
if call:
|
||
return f"{call.group(1)}() 有副作用"
|
||
return None
|
||
|
||
|
||
def _write_reason_any(stmt: str, readings: tuple[_Reading, ...]) -> str | None:
|
||
"""按每种读法都判一遍:哪种读法下会写,就当它会写。"""
|
||
reasons = [_write_reason(stmt, r) for r in readings]
|
||
if reasons[0]:
|
||
return reasons[0]
|
||
other = next((r for r in reasons[1:] if r), None)
|
||
return f"数据库换一种设置来读的话,{other}。{_AMBIGUOUS}" if other else None
|
||
|
||
|
||
def _one_view(sql: str, readings: tuple[_Reading, ...]) -> list[str] | None:
|
||
"""每种读法切出来的语句都一样时返回它,不一样返回 None。"""
|
||
views = [_statements(sql, r) for r in readings]
|
||
return views[0] if all(v == views[0] for v in views[1:]) else None
|
||
|
||
|
||
def check(sql: str, *, readonly: bool, source_name: str = "", dialect: str | None = None) -> str:
|
||
"""校验并返回规范化后的单条 SQL。不通过就抛 SqlRejected。
|
||
|
||
返回值是去掉注释和尾分号的那一条语句——调用方应该执行这个返回值,
|
||
而不是原始输入,否则守卫等于没做。字符串、带引号的标识符原样保留。
|
||
|
||
dialect 是数据源的 kind(sqlite / postgresql / mysql / oracle …)。
|
||
不知道的话按所有方言、所有设置的读法各判一遍,任何一种读法不过都拒。
|
||
"""
|
||
if not sql or not sql.strip():
|
||
raise SqlRejected("SQL 是空的")
|
||
|
||
readings = _readings(dialect)
|
||
statements = _one_view(sql, readings)
|
||
if statements is None:
|
||
raise SqlRejected(_AMBIGUOUS)
|
||
if not statements:
|
||
raise SqlRejected("SQL 里没有可执行的语句")
|
||
if len(statements) > 1:
|
||
raise SqlRejected(
|
||
f"一次只能执行一条语句,这里有 {len(statements)} 条。"
|
||
"拆成多次调用——分号拼接的语句不会被执行。"
|
||
)
|
||
|
||
stmt = statements[0]
|
||
# 交给数据库的就是 stmt:它在每种读法下都得还是完整的一条,没有注释、没有分号
|
||
if any(_tokens_have(stmt, r, ("comment", "sep")) for r in readings):
|
||
raise SqlRejected(_AMBIGUOUS)
|
||
|
||
verb = first_verb(stmt, dialect=dialect)
|
||
if not verb:
|
||
raise SqlRejected("看不出这条 SQL 要做什么(没有识别到关键字)")
|
||
|
||
if verb in _ALWAYS_DENIED:
|
||
raise SqlRejected(
|
||
f"{verb.upper()} 属于结构变更或权限操作,任何数据源上都不允许。"
|
||
"这类操作请在数据库客户端里人工执行。"
|
||
)
|
||
|
||
if readonly:
|
||
reason = _write_reason_any(stmt, readings)
|
||
if reason:
|
||
where = f"「{source_name}」" if source_name else "这个数据源"
|
||
raise SqlRejected(
|
||
f"{where}是只读的:{reason},不被允许。"
|
||
"只能执行不改数据的 SELECT / WITH / SHOW / DESCRIBE / EXPLAIN。"
|
||
"确实需要写入的话,请在设置里另建一个关闭了只读的数据源。"
|
||
)
|
||
|
||
return stmt
|
||
|
||
|
||
def _tokens_have(sql: str, reading: _Reading, kinds: tuple[str, ...]) -> bool:
|
||
return any(t.kind in kinds for t in _tokens(sql, reading))
|
||
|
||
|
||
def is_write(sql: str, *, dialect: str | None = None) -> bool:
|
||
"""是不是写操作。用来决定要不要走人工审批。
|
||
|
||
和只读判定用同一份 _write_reason:审批这道门要是只看首关键字,
|
||
`WITH … DELETE` 在可写源上就能不经审批直接写。几种读法切出来不一样的,
|
||
一律当写:看不清的交给人批。
|
||
"""
|
||
readings = _readings(dialect)
|
||
statements = _one_view(sql, readings)
|
||
if not statements:
|
||
return True
|
||
return any(_write_reason(s, r) is not None for s in statements for r in readings)
|