上一篇讲 sqlglot 方言踩坑。评论区有人问:你闸门那么多层,最基本的「只允许 SELECT」到底怎么判,一层不够吗?
不够。 一层判断能挡住 90% 的脏 SQL,剩下 10% 恰恰是出事的那些。这篇讲清楚:为什么这是个真问题、为什么做三遍、三层各自抓什么。
一、先看为什么「只允许 SELECT」是个真问题
模型确实大部分时候会吐 SELECT。但「大部分时候」不是安全边界。
用户说「把上个月的坏数据清掉」,模型可能真的写出 DELETE FROM;用户说「帮我改成已完成」,模型可能真的写出 UPDATE ... SET status = 'done'。它不是恶意的——它在「帮忙」。NL2SQL 接到业务库,「帮忙」写库就是事故。
更要命的是:你没法用 Prompt 拦住它。Prompt 说「不要写库」,模型这次听了,下次换个说法、换温度、换版本就不一定了。意图靠 Prompt 引导,但安全不能靠意图。
所以结论是:「只允许 SELECT」必须做成运行时强制,而不是模型自觉。 这就是 fail-closed 的第一层含义:不确定,就拒绝。
二、为什么做三遍,而不是一遍
脏 SQL 不总是明显的:
WITH x AS (INSERT INTO t VALUES (1)) SELECT 1——外层像查询,里面在写库;- 某条 SQL 不含任何写库关键字、但结构根本不是查询(方言变体、存储过程调用);
- 校验通过了,但执行时走了一条绕过校验的「快捷路径」。
每一层挡的是不同类型的漏。 单层再强,也只挡住一种失败模式。三层是不同层级的纵深,不是重复劳动。
| 遍数 | 在哪 | 怎么判 | 抓什么 |
|---|---|---|---|
| 第一遍 | SQL 策略层 | 正则 + 关键字 | 明显含 DML / DDL / 权限语句 / 多语句 |
| 第二遍 | SQL 闸门 | sqlglot AST | 最外层不是 SELECT(含 WITH...SELECT) |
| 第三遍 | 执行器入口 | 再查一遍 | 校验和执行之间被人加了「捷径」 |
三、三层各自怎么判
第一遍:正则——挡「明显含写库关键字」。
它是业务库的门卫,不懂语法树,只认关键字,五道关:空 SQL 拒绝;多语句(含分号)拒绝——SELECT 1; DROP TABLE t 是注入经典姿势;DML 关键字拒绝(INSERT / UPDATE / DELETE / REPLACE / MERGE / CALL);DDL 关键字拒绝(CREATE / ALTER / DROP / RENAME / TRUNCATE);权限语句拒绝(GRANT / REVOKE / SET GLOBAL)。过了这五道,再看文本是不是以 SELECT 或 WITH 开头。
正则用单词边界卡关键字,不会误杀表名里的 update(如 update_log)。但它会拦住 CTE 夹带:WITH x AS (INSERT INTO t VALUES (1)) SELECT 1 里的 INSERT 被正则命中——这条 SQL 连 AST 那层都走不到。正则先手,AST 补刀。
第二遍:AST——挡「不含关键字但结构不是查询」。
正则过了,不代表安全。假设有人构造了一条不含任何 DML/DDL 关键字、但结构不是查询的语句——理论上存在这种可能。
sqlglot 解析之后,检查最外层节点:只认 SELECT(或 WITH 包着 SELECT),其他任何 AST 类型——INSERT、UPDATE、Command——都不过。
两层配合的逻辑:正则挡「明显含写库关键字的」,AST 挡「不含关键字但结构不是查询的」。正则快,AST 准。
第三遍:执行器——不信任任何中间层。
执行器入口第一行就是再查一遍只读。这条 SQL 理论上已经过了前面两层,为什么还要查?
一是纵深:校验节点和执行节点之间有一条图边,今天边上只有权限注入,明天有人加了缓存、加了日志、加了什么「优化路径」——谁保证中间不会把 SQL 换了?执行器不管你从哪来,进门就查。
二是行数兜底:执行器取数据时会多取一行检测溢出——如果数据库返回超过上限的行数,说明 LIMIT 没生效(B4 讲过方言坑的可能),报错而不是默默截断。
四、双库策略:业务库和治理库的规矩不一样
问数系统自己也有一套库(治理库),存用户、元数据、审计日志,这套库需要 INSERT / UPDATE。但它也有规矩:
| 库 | 允许 | 禁止 |
|---|---|---|
| 业务库 | SELECT | INSERT / UPDATE / DELETE / DDL / GRANT / 多语句 |
| 治理库 | SELECT / INSERT / UPDATE | 物理 DELETE / DDL / 多语句 |
治理库禁止物理 DELETE——删除用逻辑删除(deleted=1);禁止 DDL——建表改表只能通过版本化 SQL 文件人工执行,不允许代码在运行时改结构。
这不是防模型——治理库不接受模型生成的 SQL。这是防应用代码: 哪天有人写了个 DELETE FROM copilot_sys_user WHERE id = ?,策略层直接拒。比 Code Review 靠谱,因为策略层是运行时强制。
五、「它只是个助手」的错觉
我见过不少 Chat-to-SQL 项目的 README 写着「AI 助手只生成查询,不会修改数据」。这句话描述的是意图,不是机制。Prompt 不是合同。
NL2SQL 接到业务库上,安全论据不是「模型很乖」,而是:即使模型写了 DELETE,正则拒绝;即使正则漏了,AST 判断最外层不是 SELECT,拒绝;即使 AST 也漏了,执行器再查一遍。三道门都不看模型的意图,只看 SQL 字符串的结构。结构不是 SELECT,就不执行。
这就是 fail-closed:不确定就拒绝。 默认行为是拒绝,需要主动满足条件才放行——和 fail-open(默认放行,出了事再补丁)是两种世界观。
六、一张图收尾
正则在前,AST 在后,执行器兜底。三层都只认 SELECT。
带走三句
- 「只允许 SELECT」做三遍不是强迫症——三遍拦的位置不同,抓的失败不同;
- 业务库和治理库规矩不同,但都有运行时强制策略,不靠 Code Review 兜底;
- 「它只是个助手」描述的是意图,不是机制。NL2SQL 上生产,机制要 fail-closed。
开源仓库在下面,本地 Compose + Fixture 可以直接跑通,不需要先配云 API Key。说明看 docs/DEMO.md / AGENTS.md。
GitHub: https://github.com/yanqiuping110-cloud/xb-data-copilot-bot

382

被折叠的 条评论
为什么被折叠?



