Files
ThearchyHelios 277982ead3 普通用户建应用(后端):
- 蓝图:bp_* 操作和逐卡白名单、蓝图检查、留痕、按蓝图判权限;五个场景模板加自由形(骨架、关节、应用骨架、定时任务),实例化与骨架比对;BlueprintApplier / TemplateApplier(只收模板声明过的关节)与自查;look 新类别口径卡;蓝图对话和填关节两个流式接口,不同组的关节并行填、出错的组串行重试;模板列表
- 试运行与发布单:四种起点(对话、模板、问数据的一轮、已发布版本);试运行是探索运行,草稿应用按这次运行解析、印章写试运行,流程类派给自己、不触发升级;发布单九步、预检只读、每步留痕、停下后接着做、核对卡片指纹和应用管理权、启动时整理中断的单;白话层覆盖全部问题编号
- 记录表与文件:平台托管只追加的记录表、按高水位固定数据、连接上的临时视图做行级可见、写入走动作(records_append)、表版本在发布时切换、草稿不影响线上、记录列表遮敏感列;文件按哈希存、两步上传、提交时绑定运行、下载按读权限、清理、file_text、按配方导入一期(table_import_period)
- 个人应用:存成个人应用(问数据的约束)、刷新(遮罩、只读、每日上限)、只有拥有者看得见、提交审核与共享副本、开关下发到认人接口;页面编辑的候选;隐藏组件不下发;三条新检查
2026-10-15 12:09:29 +08:00

510 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
#: 代码段里的一个词或一个符号(schema_reference 用)
_ATOM = re.compile(r"\w+|\S")
def _ident_name(token: _Token) -> str:
"""带引号的标识符 → 名字本身("ma""in" → ma"in,[main] → main)。"""
end = len(token.text) - token.close if token.close else len(token.text)
inner = token.text[token.open:end]
quote = token.text[:token.open]
return inner.replace(quote * 2, quote) if quote in ('"', "`") else inner
def schema_reference(sql: str, schemas: tuple[str, ...] | list[str], *, dialect: str | None = None) -> str | None:
"""SQL 里有没有直接写这些库名加点(main.records、"main"."records"、[main].records……):有就交回写的那个库名。
记录表靠连接上的同名临时视图做钉版本和行级可见(records/store.view_prelude):不带库名的表名先找 temp 库,查到的
是视图;写了 main. 就绕过视图看到了底表。所以这一类源(data/engine.run_query 的 forbid_schemas)一律拒。按扫描出的
词判断:字符串、注释里的 main. 不算;带引号的写法算。每种读法都扫一遍,任何一种读出来有就算有。"""
wanted = {s.lower() for s in schemas if s}
if not wanted:
return None
for reading in _readings(dialect):
atoms: list[str] = []
for token in _tokens(sql, reading):
if token.kind == "code":
atoms += [a.lower() for a in _ATOM.findall(token.text)]
elif token.kind == "ident":
atoms.append(_ident_name(token).lower())
elif token.kind in ("string", "sep"):
atoms.append(token.kind)
for name, nxt in zip(atoms, atoms[1:]):
if name in wanted and nxt == ".":
return name
return None