☰
拒绝eval陷阱:用AST白名单构建Python安全表达式求值器
2026/10/18 1:29:06 网站建设 项目流程

写这篇文章的念头,来源于我自己做过的一个小项目。当时要做个内部用的数据筛选工具,产品同学提了个需求:让运营能自己填条件表达式,比如price * quantity > 1000 and status == 'paid',系统拿到之后按条件筛数据。第一版我很自然写了eval(),功能倒是秒切,但某天做安全自查,盯着那行代码,越想越不对劲——一个能执行任意Python代码的函数,暴露给非技术用户,简直是在生产环境埋雷。于是才有了后面用抽象语法树AST重写求值器的过程,也就是这篇文章想聊透的东西。

无论你是做低代码平台、规则引擎、报表系统,还是只是想在脚本里少冒险,只要写过或想过用eval处理用户输入的表达式,这篇文章应该能帮你省不少排查时间。我会把为什么要避开eval、AST 是怎么工作的、白名单校验怎么设计、两条求值路线的取舍,以及实际落地时的边界问题都过一遍。

1. 为什么"运行字符串代码"是危险动作:eval的安全红线

1.1 eval看起来很方便:需求起点

先回到那个让我后怕的场景。运营同学要填的表达式,本质上是一个"字符串",比如:

expression = "price * quantity > 1000 and status == 'paid'"

如果用eval来求值,代码特别短:

result = eval(expression)

前提是price、quantity、status这些变量必须在当前作用域里可访问。通常的做法是构造一个字典传进去:

scope = {"price": 12.5, "quantity": 80, "status": "paid"} result = eval(expression, {"__builtins__": {}}, scope)

这样看似把__builtins__禁掉了,还能用吗?可以跑,但问题是——你以为的"沙箱",在Python里根本经不起推敲。

1.2 从eval口中逃出去的"数据":代码执行的三条路径

先说最简单也最直白的一条:Python在求值eval的时候,是把整个字符串作为Python源代码片段来编译执行。只要表达式里能访问到任何对象,就可以沿着属性链一路摸到系统层。

举个例子,正常用户会输入price * quantity,但恶意用户可能输入形如__import__('os').system(...)的表达式。如果__builtins__没关干净,这一句可以直接调用系统命令;就算把__builtins__关成空字典,攻击者还可以借助字符串对象的类,通过""的__class__拿到类型对象,再沿__bases__回溯到object,接着用__subclasses__()找到某个能读写文件或执行命令的类。整个过程不需要任何导入语句,纯粹靠"对象→类→基类→所有子类"这条属性链就够了。这种技术在安全圈有个很形象的说法:Python对象属性穿越。

第二条路径是资源耗尽型攻击。不需要真的执行系统命令,输入一个10 ** 10 ** 10这样的表达式,Python在求值时会尝试计算一个巨大无比的大整数,CPU和内存瞬间被打满。这类攻击在规则引擎场景里特别容易被忽略,因为大家的目光都盯着"能不能执行命令",没人想到一个数学表达式也能把服务拖垮。

第三条路径是信息探测与越权。open('/etc/passwd').read()、__import__('os').listdir('.')等表达式,可以让没有权限的人读到不该读的东西。哪怕只是读出文件列表,对某些敏感系统来说已经是事故了。

问题的根源在于:eval把"表达式求值"和"代码执行"这两件事完全合并了。它没有一个中间层可以让你检查"这句代码到底在干什么",你只能看到一个字符串。而字符串这种形态,是无法可靠防御的——同一个意图可以被编码成无数种字符串变体,你每堵一个洞,攻击者换一种写法又进来了。这就是为什么后来我下定决定不再用黑名单式过滤,而是借助AST从结构上做限制。

2. 抽象语法树AST:把表达式变成一棵可以审阅的树

2.1 ast.parse与mode='eval':从字符串到结构数据

Python标准库里有个模块叫ast,它的作用是把源代码字符串解析成一棵结构化的树。树上的每个节点代表源代码里的一个语法要素,比如数字、变量名、运算符、函数调用等等。

我们不需要直接去操作这棵树,但可以"看"它。先看一个最简单的例子:

import ast code = "1 + 2 * 3" tree = ast.parse(code, mode="eval") print(ast.dump(tree, indent=2))

输出大概是这样的结构:

Expression( body=BinOp( left=Constant(value=1), op=Add(), right=BinOp( left=Constant(value=2), op=Mult(), right=Constant(value=3)))

注意mode="eval"这个参数,它表示我们只解析一个表达式,而不是一段完整的语句。对于这个应用场景来说,这一点很重要:我们的用户输入应该是"一个表达式",而不是"一段代码"。如果允许完整的语句模式,那就可以写x = 1; print(...)之类的东西,攻击面又扩大了。

AST看起来抽象,其实可以用一棵树来想象。根节点是Expression,下面挂着一个BinOp(二元运算节点),它有三个子节点:左边的操作数、中间的运算符、右边的操作数。BinOp的右边又是一个BinOp,因为乘法的优先级更高。整棵树的叶子节点是数字常量或者变量名,内部节点是各种运算符和语法结构。

2.2 常见的AST节点清单:认识自己要审核的对象

在做安全求值之前,需要先知道我们可能遇到哪些节点类型。我挑几个最常见的列一下,这同时也是后面白名单设计的基础:

节点类型语法示例说明
Constant123、'abc'、True字面量常量,数字、字符串、布尔值都算
Nameprice、status变量名,对应负载数据里的字段
BinOpa + b、a * b / c二元算术运算,内部还有op子节点
UnaryOp-x、not x一元运算,负号、逻辑非等
Comparea > b、x == y比较运算,可能连着多个比较符
BoolOpa and b、a or b布尔逻辑运算
IfExpa if cond else b三元条件表达式
Callfoo(a, b)函数调用,注意它的func子节点可以是一个属性访问链
Attributeobj.attr属性访问,如os.system
Subscripta[0]、d['key']下标访问,如lst[0]
Lambdalambda x: x + 1匿名函数定义
ListComp[x for x in y]列表推导式

不看这棵树的时候,"price * quantity > 1000"就是一串字符。当你把它解析成AST,它变成了一种结构化数据,我们就可以像审阅代码一样逐个节点检查,甚至可以直接在遍历过程中完成求值。

2.3 审阅和执行是两回事:AST赋予的能力

AST最大的价值,是把"审阅"和"执行"拆开了。用eval的时候,字符串一步到位直接执行,中间没有审查机会。而拿到AST之后,我们至少有三个选择:

第一,先审查,再执行。完整遍历AST,判断每个节点的类型是否在白名单里,全部通过之后再交给compile和eval去执行。这是比较经典的做法。

第二,不执行,自己翻译。完全不用Python的执行机制,而是自己写一个解释器,逐节点遍历AST,用自己的代码把"加法""乘法""比较"这些操作算出来。这种方法更慢,但安全边界最清晰,因为你根本没让Python虚拟机去解释恶意代码,只是把AST当数据在遍历。

第三,审查和翻译混合。在遍历求值的过程中,对每个节点做白名单判断,遇到不在允许列表中的节点立即抛异常。

我在实际项目里采用的是混合方案:自己写遍历逻辑,逐节点校验,逐节点计算。后面会详细展开。先记住一个结论:只要我们能在"执行"之前看到每个原子操作,安全策略就能从"禁掉所有恶意"变成"只开放已知善意",后者才是真正可行的。

3. 安全求值的核心思路:白名单节点校验

3.1 黑名单永远漏,白名单默认拒绝:两条路线的对比

很多人第一次听到AST方案,第一反应是:那我做个黑名单,把__import__、open、os、system这些敏感词全过滤掉不就行了?

这话听上去合理,实操中却处处是坑。字符串过滤的黑名单是"枚举坏人",但坏人永远在变着花样出现。拿__import__来说,过滤了它,攻击者可以用getattr(__builtins__, '__imp' + 'ort__')这种拼接写法绕过去;过滤了__builtins__,还有globals()、vars()、dir()可以拿到运行时信息;就算你把所有内置函数都禁了,还能通过对象属性链路找到os。你每堵一个洞,都要写一条规则,规则越来越多,误杀也越来越多,而且攻击面根本堵不完。

白名单的思路完全不同:默认拒绝所有节点,只有我明确允许的类型才放行。比如我只允许数字、变量名、算术运算符、比较运算符、逻辑运算符、条件表达式,那么就算攻击者费尽心思构造了一长串字符串,只要最终的AST里出现了Attribute(属性访问)或者Call(函数调用),就直接拒绝——根本不需要关心恶意字符串具体长什么样。

黑名单是"列出所有不能做的事",白名单是"列出所有能做的事"。在安全领域,白名单是唯一可靠的方向。原因很简单:可做的事情是有限的,不可做的事情是无限的。

3.2 我允许哪些节点:求值器的"宪法"

以我那个数据筛选工具为例,业务需要的表达式能力其实很窄:

  • 四则运算和取余:+ - * / %
  • 比较运算:> < >= <= == !=
  • 布尔运算:and or not
  • 三元条件:a if cond else b
  • 取负、括号改变优先级
  • 少量纯函数:abs、min、max、round、len、sum

对应的AST白名单如下:

允许的节点:

  • ast.Expression:根节点本身
  • ast.Constant:数字、字符串、布尔值、None
  • ast.Name:变量名,注意ID必须来自用户数据字段,不能是任意的
  • ast.BinOp:二元运算
  • ast.UnaryOp:一元运算
  • ast.Compare:比较运算
  • ast.BoolOp:布尔逻辑
  • ast.IfExp:三元表达式

允许的运算符节点(作为op子节点):

  • ast.Add、ast.Sub、ast.Mult、ast.Div、ast.Mod、ast.FloorDiv
  • ast.Eq、ast.NotEq、ast.Lt、ast.LtE、ast.Gt、ast.GtE
  • ast.And、ast.Or、ast.Not

一律拒绝的节点:

  • ast.Attribute:任何"点出来"的属性访问,比如__class__、os.system
  • ast.Subscript:下标访问,比如__subclasses__()[0]
  • ast.Call:默认拒绝所有函数调用(但会单独开放白名单函数,见第5章)
  • ast.Lambda、ast.ListComp、ast.DictComp、ast.SetComp、ast.GeneratorExp
  • ast.Import、ast.ImportFrom:导入语句

有个容易忽略的点:Callback和Attribute往往是一起出现的。如果放行了Call,但把Attribute禁了,那攻击者用os.system(...)这条路就断了;但如果不小心放行了Attribute再放行Call,就等于把Python完全敞开了。所以我把它们放在不同的层级分别控制,等你真的需要开放自定义函数时,只开放"白名单函数"的Call,而Attribute依旧保持拒绝。

这里补一句自己的经验:写白名单校验时,不要试图"允许大多数,拒绝少数",而是从空集开始,一点点加。第一次跑你的回归测试,你会遇到一堆被拒的合理表达式,再逐个添加允许项。这个过程看起来麻烦,但每加一个节点,你都能想清楚"我为什么要允许它",而不是糊里糊涂把整片树林放进来。

4. 两条求值路线:受限exec与自建解释器

4.1 路线一:校验后用compile和受限命名空间

这是网上最多人推荐的方案:先用AST校验一遍,确认没有危险节点,然后把编译后的代码交给eval执行。

import ast ALLOWED_NODES = ( ast.Expression, ast.Constant, ast.Name, ast.BinOp, ast.UnaryOp, ast.Compare, ast.BoolOp, ast.IfExp, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod, ast.FloorDiv, ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.And, ast.Or, ast.Not, ) class NodeChecker(ast.NodeVisitor): def visit(self, node): if not isinstance(node, ALLOWED_NODES): raise ValueError(f"禁止的AST节点: {type(node).__name__}") self.generic_visit(node) def safe_eval(expr, env): tree = ast.parse(expr, mode="eval") NodeChecker().visit(tree) code = compile(tree, "<safe>", "eval") return eval(code, {"__builtins__": {}}, env)

这段代码确实能挡掉绝大多数不需要的场景。它的思路是:校验器先行,执行器后行。

但有个隐藏风险需要拎出来说:eval内部运行的是完整的CPython字节码解释器,AST校验只是在执行前设了一道关卡。万一校验器有漏洞,哪怕漏掉一种节点,后面就全面失守。而且当你需要开放Call节点时,eval的执行路径里依然可以调用到一些危险内置函数,除非你把__builtins__完全清空,并且显式地把白名单函数放进环境里。

清空__builtins__之后,普通表达式其实已经很难执行系统命令了。可要是你开放了Call,攻击者仍然可以利用"已知函数返回对象→再沿属性链访问"的方式来增强能力。属性穿越的链条有时不是一步的:比如你允许调用getattr,那它就能间接拿到任意属性。因此只要你用了eval,就无法真正做到"只解释我想要的语义",只能做到"在入口处挡住大部分人"。

4.2 路线二:遍历AST手写求值器:只解释白名单内的节点

我自己最终走的是第二条路:不调用eval,而是自己写一个小的解释器,遍历AST节点,用自己的代码逐节点计算结果。核心思想是:我不让Python虚拟机去解释用户的表达式,我只是把AST当作一棵普通的数据树来遍历。

简化版实现如下:

class SafeEvaluator: def __init__(self, funcs=None): self.funcs = funcs or {} self.node_count = 0 self.max_nodes = 1000 self.max_depth = 50 def evaluate(self, expr, env): tree = ast.parse(expr, mode="eval") self.node_count = 0 self._check_depth(tree, 0) return self._eval(tree.body, env) def _check_depth(self, node, depth): if depth > self.max_depth: raise ValueError("表达式嵌套过深") for child in ast.iter_child_nodes(node): self._check_depth(child, depth + 1) def _eval(self, node, env): self.node_count += 1 if self.node_count > self.max_nodes: raise ValueError("表达式节点数超限") if isinstance(node, ast.Constant): return node.value if isinstance(node, ast.Name): if node.id not in env: raise NameError(f"未知变量: {node.id}") return env[node.id] if isinstance(node, ast.BinOp): left = self._eval(node.left, env) right = self._eval(node.right, env) return self._apply_bin_op(node.op, left, right) if isinstance(node, ast.UnaryOp): operand = self._eval(node.operand, env) return self._apply_unary_op(node.op, operand) if isinstance(node, ast.Compare): left = self._eval(node.left, env) for op, comparator in zip(node.ops, node.comparators): right = self._eval(comparator, env) if not self._apply_compare(op, left, right): return False left = right return True if isinstance(node, ast.BoolOp): values = [self._eval(value, env) for value in node.values] if isinstance(node.op, ast.And): return all(values) if isinstance(node.op, ast.Or): return any(values) if isinstance(node, ast.IfExp): condition = self._eval(node.test, env) if condition: return self._eval(node.body, env) else: return self._eval(node.orelse, env) if isinstance(node, ast.Call): func = node.func if not isinstance(func, ast.Name): raise ValueError("仅支持直接调用的白名单函数") if func.id not in self.funcs: raise ValueError(f"函数未授权: {func.id}") args = [self._eval(arg, env) for arg in node.args] return self.funcs[func.id](*args) raise ValueError(f"禁止的AST节点: {type(node).__name__}") def _apply_bin_op(self, op, left, right): if isinstance(op, ast.Add): return left + right if isinstance(op, ast.Sub): return left - right if isinstance(op, ast.Mult): return left * right if isinstance(op, ast.Div): return left / right if isinstance(op, ast.Mod): return left % right if isinstance(op, ast.FloorDiv): return left // right raise ValueError(f"禁止的二元运算符: {type(op).__name__}") def _apply_unary_op(self, op, operand): if isinstance(op, ast.UAdd): return +operand if isinstance(op, ast.USub): return -operand if isinstance(op, ast.Not): return not operand raise ValueError(f"禁止的一元运算符: {type(op).__name__}") def _apply_compare(self, op, left, right): if isinstance(op, ast.Eq): return left == right if isinstance(op, ast.NotEq): return left != right if isinstance(op, ast.Lt): return left < right if isinstance(op, ast.LtE): return left <= right if isinstance(op, ast.Gt): return left > right if isinstance(op, ast.GtE): return left >= right raise ValueError(f"禁止的比较运算符: {type(op).__name__}")

这段代码核心的优点在于:它只认识_eval里写死的节点类型。出现任何不在if isinstance分支里的节点,直接走最后的raise ValueError。没有白名单概念,就是"默认拒绝一切"。用eval的时候,安全靠的是执行前的校验;用自建求值器的时候,安全靠的是"解释器根本不认识其他节点"。

这个方案不是没有缺点。性能上,纯Python逐节点解释肯定比原生字节码慢,但架不住表达式本身都很短,一个表达式也就几十个节点,实际耗时可以忽略。可维护性上,每次要支持新语法,就得往_eval里加分支,比直接写一行正则或eval复杂。但换来的是每次加分支都在做代码审查,心智负担非常清楚。

4.3 对比:什么时候选哪种

维度路线一:校验+eval路线二:自建求值器
安全边界依赖校验器正确性,执行路径是完整CPython只解释白名单内节点,天然隔离
实现成本较低,AST校验写完就可用较高,所有运算逻辑都要自己写
性能快,接近原生执行慢一些,但表达式场景足够
扩展能力开放函数后风险高,需要额外防御开放函数仍可控,只解释明确节点
调试难度出问题时需要同时查校验器和执行器出问题大概率在_eval一个函数里

简单给个选型建议:如果你只是在内部工具里处理"可信度较高但不完全可信"的输入,且表达式能力要求比较多,路线一就已经能挡住绝大多数攻击,代码量也小。如果你做的是面向外部用户的低代码平台、规则引擎,或者对安全性极其敏感,那就选路线二,虽然开发量大,但安全边界是你自己维护的,出了问题你能讲清楚"哪些表达式是绝对执行不了的"。

5. 完整实现与边界打磨:让求值器真正可用

5.1 从数学表达式到筛选条件:能力裁剪清单

一个只支持四则运算的求值器,放在真实业务里远远不够用。实际项目中至少需要这样几类能力:

变量取值:Name节点要在env字典里查找字段。需要注意大小写敏感、字段不存在时给一个明确的NameError,而不是悄悄返回None。不然用户拼错字段名,筛选结果就是一堆空数据,排查起来一头雾水。

字符串字面量:比较场景经常会写status == 'paid'这种条件,所以要允许字符串常量。要注意ast.Constant会同时覆盖数字、字符串、布尔值、None,所以Constant节点直接返回的值本身没什么风险。真正要注意的是:比较运算时如果类型不匹配,Python会抛TypeError,这在筛选场景里最好转换成"条件不成立"而不是直接报错,不然一条脏数据能让整个流程挂掉。

白名单函数:我建议单独维护一个FUNCS字典,只放纯函数,也就是"传入参数,返回结果,不碰全局状态"的函数:

FUNCS = { "abs": abs, "min": min, "max": max, "round": round, "len": len, "sum": sum, }

注意round、sum这类函数有默认参数或关键字参数,上面代码里的_eval目前只处理了位置参数args。如果业务需要用到round(3.14159, 2)这种带两个位置参数的写法,没问题;但如果想写sum(xs, start=0)这种关键字参数,还得支持keywords字段。这个细节很容易踩坑,建议一开始就决定好:要么支持完整参数语法,要么明确禁止关键字参数。我的做法是禁止,因为用户没必要写那么复杂,报错信息告诉他"关键字参数不受支持"就够了。

再补充一个容易出问题的点:函数返回值。如果业务逻辑需要函数返回值参与比较,比如len(items) > 5,那items必须作为环境里的一个列表传进去。如果业务不允许用户看到完整数据,那你在构建env时就要控制好能传什么、不能传什么。这个边界属于"业务数据授权",求值器是没有办法替你决定的。

5.2 防资源耗尽:表达式长度、AST节点数、运算强度

当求值器面向不可信输入时,除了"执行不该执行的代码",还要防"让服务跑死"。这里有几个非常实际的防护维度:

表达式长度限制。最简单的一层:不允许超过200个字符。这个长度对正常业务表达式来说已经很宽裕了,而对绝大多数攻击型输入来说,一上来就被拦掉。

AST节点数限制。上面代码里的self.max_nodes = 1000就是在做这件事。攻击者可能用1+1+1+1+...造一个几千层的链,解析成AST之后不断让求值器递归。设一个节点数上限,超过就抛异常,成本极低,效果立竿见影。

递归深度限制。节点数和深度是两个维度。一个右递归的表达式1+(1+(1+...))层数极深,但节点数可能没到上限,这时候递归调用栈可能已经爆了。所以我在_check_depth里限制嵌套深度不超过50层,正常表达式到20层都算很深了。

运算强度限制。有些恶意的操作不需要执行系统命令,但计算量巨大。比如10 ** 999999999,虽然最终结果是个巨大的整数,Python也能表示,但CPU时间不可接受。最直接的办法是限制乘方运算的指数大小,或者在_apply_bin_op里给Pow加一个"指数过大就拒"的规则;又或者像上面代码一样干脆不开放Pow节点。我的建议是初期直接不开放乘方,等真的需要时再加限制。

执行总时长限制。更稳妥的兜底手段是把整个求值包在一个"耗时上限"内执行。可以用信号或者多进程做硬超时,但表达式的耗时普遍在微秒级,单线程函数内部塞一个线程定时器就够用。我的做法是:解析+校验+求值整个链路用time.monotonic()记录开始时间,超过200毫秒直接抛超时异常。这个兜底可以防止将来加新特性时不小心引入某个计算量爆炸的节点。

5.3 回归测试:把合法与恶意表达式都写进用例

写完求值器之后最重要的一步,是建一套回归用例。你会发现安全类需求特别依赖回归测试——今天把某个节点加进允许列表,明天可能就会破坏一条安全承诺,没有测试挡着根本发现不了。

我习惯把用例分成三组。第一组是合法业务表达式:

# 合法用例 assert ev("price * quantity > 1000", {"price": 12.5, "quantity": 80, "status": "paid"}) is True assert ev("(price + 1) * 2 >= 30", {"price": 12.5}) is True assert ev("status == 'paid' and amount >= 500", {"status": "paid", "amount": 880}) is True assert ev("len(items) > 2 and sum(counts) >= 100", {"items": ["a", "b"], "counts": [50, 60]}) is True assert ev("'VIP' if amount > 1000 else 'Normal'", {"amount": 1500}) == "VIP"

第二组是应该抛异常的恶意表达式:

# 非法用例 with pytest.raises(Exception): ev("__import__('os').system('echo pwned')", {}) with pytest.raises(Exception): ev("open('secret.txt').read()", {}) with pytest.raises(Exception): ev("().__class__.__bases__[0].__subclasses__()", {}) with pytest.raises(Exception): ev("[x for x in range(100)]", {}) with pytest.raises(Exception): ev("lambda x: x + 1", {}) with pytest.raises(Exception): ev("globals()", {})

第三组是资源耗尽型用例,建议用随机化的方式多构造几个变体,不要只写死一个字符串。安全测试最忌讳"只测我知道的恶意输入",攻击者并不会按你的用例来。

这里补充一个实操体会:恶意用例不要只测试"报不报错",还要测试到底在哪一层被拦下来。比如__import__('os').system('echo x')被拦,是因为Attribute节点被禁止了;而open('x')被拦,是因为Call节点里函数名不在白名单。把测试断言细到这个程度,以后代码重构换了一种拦截方式,你也能立刻知道安全边界有没有发生变化。

6. 比AST更远一步:从表达式求值到规则引擎的扩展

6.1 扩张场景:把求值器当条件引擎用

一个能安全求值的工具,价值绝不仅限于"算数"。我在实际项目里把它扩展成了一个小的条件引擎:给每一条记录绑定一个环境字典,求值器只负责返回布尔值,外层代码根据布尔值决定保留还是过滤这条数据。

假设有一批订单数据:

orders = [ {"id": "A001", "amount": 2300, "status": "paid", "count": 3}, {"id": "A002", "amount": 800, "status": "pending", "count": 1}, ] def filter_orders(expr): ev = SafeEvaluator(FUNCS) return [ order for order in orders if ev.evaluate(expr, order) ]

运营同学就可以写amount > 1000 and status == 'paid'来筛出订单。这个模式能直接套到工单筛选、告警规则、动态报表等场景。关键点是:外层代码是可信的,求值器只处理不可信的表达式串,两者之间的边界就是env里放了哪些字段、FUNCS里注册了哪些函数。你控制好这两个入口,往业务上扩展就非常安全。

再往外扩一层,可以把函数白名单做得更业务化。比如在FUNCS里加days_between(date1, date2)、in_week(day),让非技术用户也能写"近7天内的数据"这种规则。每一步扩展都要做三件事:实现函数、加到白名单、补测试用例。这样做虽然慢,但安全边界始终清晰。

6.2 替换方案对比:自写AST vs 第三方语法解析器

很多读者会问:既然自己写AST求值器要维护这么多分支,不如直接上第三方语法解析库,比如pyparsing、lark?这是个好问题,我给自己的参考判断是:

第三方词法/语法解析库,适合表达式语法越来越复杂的情况。比如需要支持中缀、前缀、后缀混合语法,或者需要自定义语法规则,这时候用lark定义一套你自己的表达式语法,让语义尽量简单,是长期维护收益更高的选择。而且语法解析库天然会生成自己的AST,你可以严格按自己定义的语法来解释,安全边界比"解析Python语法再过滤"更清晰。

但它的缺点也很明显:学习成本高,语法文件本身也是一个需要仔细审查的"代码",而且很多解析库生成的树节点比Python原生AST抽象得多,你需要重新映射回业务语义。对于"用户输入Python风格表达式"这个场景,Python标准库的ast反而是最自然的中间表示,不需要引入额外依赖。

我的取舍标准是:如果表达式能力保持在"运算+比较+逻辑+少量函数"这个级别,标准库ast完全够用,维护成本低,社区资料多。如果你开始需要"字段名用中文别名"“语法做前缀式简写”“支持中括号数组字面量”这类自定义语法,那才值得换lark或pyparsing。过早引入解析生成器,只会让你同时维护两套复杂系统。

最后分享一个自己踩过的经验教训。有一个线上小工具,当初图省事,直接eval(expression, {"__builtins__": {}}, data)上线了,功能正常了几个月,没人发现问题。直到有一天某位内部用户为了调试方便,提交了一个带__builtins__恢复代码的表达式,虽然没造成实质破坏,但那次之后我彻底决定重写。印象最深的是:安全改造最难的不是写代码,而是说服自己"当前看起来没问题,不代表边界就是安全的"。eval是这么一种东西,它能正常运行的时候,恰恰是最危险的信号——因为你永远不知道下一次输入会是什么。用AST重写之后,我才真正睡得踏实:不是因为我堵住了所有已知漏洞,而是因为从结构上,我们不认识的代码根本不可能被执行。

如果你手头也有类似的字符串求值场景,我的建议很简单:先别急着上AST,先用十分钟想想你允许用户输入什么、环境里放了什么数据、开放了哪些能力。把这三个问题想清楚,AST方案写起来其实很快。反之,这三个问题含糊,任何技术方案都救不了你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询