[控场AI]
· 8 分钟阅读· 4,046 字

用sqlglot为LLM生成SQL加只读护栏

用sqlglot为LLM生成SQL加只读护栏

用sqlglot解析SQL并遍历语法树,构建只读查询护栏,防御注入与副作用。

文章指出,给LLM连接数据库时,简单的字符串前缀检查无法防御分号链、CTE藏匿写操作、DuckDB文件读取等攻击。更稳的做法是用sqlglot将SQL解析为方言感知的AST,再按六步审查:要求单条语句且根节点为查询、遍历整棵树拦截禁用节点、按函数名屏蔽文件与网络读取、带CTE感知的表允许列表、用包裹而非追加方式施加行数上限、以行数求和或相乘的成本代理检测笛卡尔积。护栏只防损害不判对错,正确性需通过比较结果集而非SQL文本来度量,且必须配合只读数据库角色使用。

给LLM连接数据库,迟早会收到DROP TABLE或没有WHERE的DELETE——有时是用户要求,有时是列值里藏了提示注入,有时只是模型"灵机一动"。简单的sql.strip().lower().startswith("select")防御至少会在三种情况下失效:SELECT 1; DROP TABLE orders、用CTE藏INSERT、以及DuckDB中SELECT * FROM read_csv('/etc/passwd')。

更稳的做法是先解析SQL,再审查语法树。开源库sqlglot能生成方言感知的AST,下面是一套基于它实现的只读查询护栏,以DuckDB为例。

sqlglot SQL护栏示意

核心设计:解析后审查语法树

第一步:单条语句,根节点为SELECT

用sqlglot.parse(sql, read=DIALECT)得到语句列表,过滤空语句,要求恰好一条。根节点必须是exp.Select、exp.SetOperation或exp.Subquery。解析失败的SQL直接拒绝——如果解析器看不懂,就不该让它执行。

stmts = [s for s in sqlglot.parse(sql, read=DIALECT) if s is not None]
if len(stmts) != 1:
    raise GuardError("multiple_statements", ...)
tree = stmts[0]
if not isinstance(tree, (exp.Select, exp.SetOperation, exp.Subquery)):
    raise GuardError("not_select", ...)

注意错误码code的设计——它不仅是拒绝原因,还是后续做评测时的标注数据。

第二步:遍历整棵树,而非只看根节点

根节点是SELECT还不够,数据修改型CTE和SELECT INTO都可能藏在深处。用tree.walk()遍历每个节点,命中禁用列表就拒绝。列表包括Insert、Update、Delete、Merge、Create、Drop、Alter、TruncateTable、Command、Copy、Set、Use、Pragma、Attach、Detach、事务类、权限类等。

sqlglot跨版本会重命名节点类,因此用hasattr(exp, name)过滤构建元组,并锁定测试过的sqlglot版本,升级时重跑护栏测试。

CTE(Common Table Expression,公共表表达式)即 WITH 子句定义的命名子查询。它之所以成为注入写操作的温床,是因为 SQL 标准允许在 CTE 体内嵌入数据修改语句,例如 WITH x AS (DELETE FROM orders RETURNING *) SELECT * FROM x。这类语句的语法树根节点仍然是 Select,仅检查根节点类型会直接放行。同理,SELECT ... INTO new_table 在部分方言中会创建新表,其根节点也表现为查询。只有用 tree.walk() 深度优先遍历每个节点,才能捕获藏在中层的 Delete、Insert 等节点。这也解释了为何护栏必须做整树审查,而不能止步于验证最外层语句的类型。

第三步:屏蔽文件与网络读取函数

DuckDB的read_csv、read_parquet、glob最隐蔽:表面是SELECT,实际是文件系统侧信道。按函数名屏蔽,同时拦截FROM 'data.csv'这种字面量表引用。

_BLOCKED_FUNCS = {"read_csv", "read_csv_auto", "read_parquet", "read_json",
                  "read_text", "read_blob", "glob", "parquet_scan", "query"}

如果目标是Postgres或Snowflake,黑名单会不同(pg_read_file、COPY、stage等),原则一致:枚举你所使用引擎的副作用入口,再叠加表允许列表。

DuckDB 将文件当作表来查询的能力来自其「替换扫描」机制:read_csv('path')、read_parquet('path') 等表函数在查询计划中直接读取本地或远程文件,无需预先建表。除此之外,DuckDB 还支持直接用字符串字面量引用文件,如 FROM 'data.csv',解析器会自动将其展开为对应的读取函数。这意味着攻击者只要能让模型生成一个看似无害的 SELECT,就可能读取服务器上的任意文件(如 /etc/passwd、SSH 密钥)或通过 glob 探测目录结构。这类侧信道不会修改数据,因此只读数据库角色也拦不住它们,必须在 SQL 层面按函数名和字面量表引用双重屏蔽。

第四步:带CTE感知的表允许列表

遍历exp.Table节点,要求表名在允许集合中。CTE名在语法树里看起来就是表,必须先收集exp.CTE的别名并排除,否则每个WITH查询都会被误杀。

第五步:模型无法抬高的行数上限

不要往用户SQL后面追加LIMIT——用户可能已有LIMIT 10000000。正确做法是把整个查询包一层:

def _cap_rows(tree, max_rows):
    inner = tree.sql(dialect=DIALECT)
    return f"SELECT * FROM ({inner}) AS _capped LIMIT {int(max_rows)}"

内层可以调低上限,但抬不高。执行tree.sql()重新生成的SQL而不是原始字符串,保证"验证的"就是"执行的"。

这里的关键区别在于「追加」与「包裹」。若在用户 SQL 末尾拼接 LIMIT N,当原查询已含 LIMIT 10000000 时会形成语法错误或语义歧义;而把整条查询作为子查询包裹后再施加外层 LIMIT,外层限制始终生效,内层更严格的上限可以保留,内层更宽松的上限则被外层压制,因此模型无法通过在自己的 SQL 里写大 LIMIT 来突破。另一个要点是执行 tree.sql() 重新序列化后的 SQL 而非原始字符串:解析器与执行器对同一段文本可能有不同理解(注释、方言差异、隐式类型转换),重新生成能保证被审查的语法树与实际执行的语句严格对应。

第六步:检测笛卡尔积的成本代理

引擎的EXPLAIN不一定便宜、可比或可用。一个确定性的代理足够有效:统计每张被引用表的行数后求和,遇到没有ON或USING的join就改为相乘。

total = total * max(n, 1) if cartesian else total + n
if max_cost is not None and total > max_cost:
    raise GuardError("cost_cap", ...)

它很粗糙,会高估一些查询、低估另一些,但能把"全表交叉连接"变成拒绝而不是事故。

度量正确性:执行准确率

护栏只防损害,不判断对错。判断对错要比较结果集而非SQL文本——两种截然不同的SQL可以同样正确。写一个result_sets_equal函数,浮点数先四舍五入到6位再比较,避免追逐1e-12的幽灵差异。

黄金用例是问题加参考查询,需要顺序时标注ordered: true。运行agent、运行golden SQL、比较结果。护栏拒绝会以错误码形式出现为失败,从而区分"模型写了DDL"和"模型join写错了"。

护栏做不到什么

诚实面对局限:它不能替代只读数据库角色,两者应同时使用。它不理解行级安全,无法判断一个看似合法的查询是否泄露了用户不该看的数据,成本代理也不是查询规划器。把护栏当作一层防御,并维护一个小型已知坏查询测试文件(分号链、CTE包裹的insert、read_csv),每次升级sqlglot时都跑一遍。

作者已将这套方案打包为Data Analyst Agent Kit,包含完整禁用列表、DuckDB演示仓库、Vega-Lite图表输出、离线可跑的确定性mock LLM,以及执行准确率评测框架。如果只想要思路,上面的代码足够在一个下午内自建:锁定sqlglot版本,先写坏查询测试,保留只读数据库角色。

只读数据库角色是护栏之外的另一道独立防线:在数据库侧用 GRANT SELECT 创建专用账号,即使护栏被绕过、生成了 DROP TABLE,数据库也会以权限错误拒绝执行。两者互补——护栏在应用层尽早给出可读的错误码并阻止查询到达数据库,数据库角色则在护栏本身有 bug 或被升级破坏时兜底。行级安全(Row-Level Security)则是数据库原生能力,能在行粒度限制用户可见数据;护栏审查的是 SQL 语法结构,无法知道某条合法 SELECT 返回的行是否属于当前用户有权查看的范围,这类判断只能交给数据库的角色与策略配置。

#sqlglot#LLM SQL护栏#text-to-SQL安全#只读SQL校验#SQL语法树#DuckDB安全#LLM数据库安全
分享:

相关推荐