每天早上一打开内网的安全平台,满屏都是红色、黄色的告警。点开一条看一下,“可能是SQL注入”,再点开一条,“疑似使用了危险函数”,但大部分都是误报。真正严重的问题,反而被淹没在几百条告警里,没人看到。这种体验,相信很多做开发或安全的人都有过。
我们为什么会掉进告警海?一个很重要的原因,就是扫描规则太简单。很多规则本质上就是一堆正则表达式,看到某个字符串就报警。正则虽然方便,但它并不理解代码。它把代码当成一段普通的文本,而不是有结构、有语义的程序。于是,注释里的“eval”、字符串里的“password”,甚至文档里的示例,都能被它当成漏洞。
可解释的审计模式,意思是我们得让规则理解代码的“形状”和“关系”。比如,知道什么是函数调用,知道参数是怎么传的,知道变量从哪里来。这样规则才能说出“这里真的有风险,因为我看到你在调用一个叫eval的内置函数”,而不是“这里出现了eval四个字母”。
一、告警海是怎么来的
现在的SAST工具,默认规则库往往特别庞大。供应商为了不漏报,宁可多报,也不能放过。于是规则越堆越多,正则写起来也越来越随意。你可能见过这样的规则:.*(exec|system|popen).*,只要代码里出现这几个单词中的一个,直接亮红灯。结果呢?项目里有一段注释写着“不要用exec,用subprocess”,也被扫出来了。开发人员每天处理这种告警,慢慢就麻木了,看到一个告警先标“误报”再说。
1.1 正则表达式的“功劳”
正则表达式擅长匹配文本模式,但代码不能简单当文本看。举一个最简单的例子:我们想找IP地址,用\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}。这个正则可以匹配出“10.20.30.40”,也能把版本号“1.2.3.4”匹配出来。如果项目里有好多类似“v1.2.3.4”的标签,扫描结果就会充满噪音。
更麻烦的是,正则很难处理“上下文”。比如你想找“用户拼接SQL”的场景,用正则就得匹配select、from、where这些关键字,同时还要看有没有字符串拼接符号+。可代码里变量名、函数名、注释都可能包含这些关键字,于是误报像潮水一样涌过来。
1.2 一个让你头疼的例子
看下面这段Python代码(本文所有示例统一使用Python技术栈):
# 用户输入直接拼进SQL,这是真正的注入漏洞
def get_user(user_id):
sql = "select * from users where id = " + user_id
return execute(sql)
# 下面这个看起来差不多,但先做了数字校验
def get_user_safe(user_id):
if not user_id.isdigit():
return None
# 此时user_id只可能是数字,拼接是安全的
sql = "select * from users where id = " + user_id
return execute(sql)
如果用正则去扫,这两段代码都会被报“SQL注入”。但第二段明显做了输入校验,风险小很多。正则看不到“isdigit”这个校验,它只知道“select + from + where + 拼接”出现了,所以报警。这样的告警规则,除了让人觉得烦,没有任何教育意义。
二、从“匹配文本”到“理解代码”
要减少误报,我们就得往更深一层走。代码在编译的时候,会被解析成一棵语法树。语法树上的每一个节点,都代表了代码里的一个语法元素,比如“函数定义”“函数调用”“赋值语句”“变量名”。有了这棵树,我们就可以问一些很精确的问题,比如“这个函数的参数列表里,有没有一个叫timeout的参数?”而不是“这段文本里有没有出现timeout这个单词”。
2.1 代码是树,不是字符串
很多开发者第一次听说“抽象语法树”会觉得很高级,其实理解起来也不难。你可以把它想象成语文课的句子成分图:主语、谓语、宾语都是有层次的。代码也一样,一个if语句下面可能挂着好几个分支,一个函数定义下面挂着参数和函数体。正则只能看到一行一行码在一起的字符,而语法树能告诉你“这是一次调用”“这是一个赋值”“这是函数声明”。
有了语法树,规则的可解释性就出来了。你可以给开发者看一个完整的树状结构,告诉他:“你看,这里确实调用了一个函数,函数名是eval,而且没有屏蔽内置模块,所以它执行的是python的内置eval。这就是为什么我报这个告警。”
2.2 用Python自带武器认识语法树
Python标准库里面有个模块叫ast,专门用来把Python源码变成语法树。你可以先看看一棵简单的树长什么样:
import ast
code = """
def hello():
print("hello")
"""
# 把源码字符串解析成语法树
tree = ast.parse(code)
# 打印出整棵树的详细结构
print(ast.dump(tree, indent=2))
运行这个脚本,你会看到输出里出现了Module、FunctionDef、Expr、Call这些节点。它们就是代码的“骨架”。Call节点代表一次函数调用,它里面还会有一个Name节点,表示被调用的名字是什么。这些都是正则看不到的东西。
三、动手写一个可解释的审计规则
光说不练假把式。我们用Python的ast模块,写两个具体的小规则,让你感受一下什么是“从可解释的审计模式出发”。
3.1 规则应该长什么样
之前用正则,规则就是一段字符串。现在我们用Python代码来描述规则,思路变成:遍历所有节点,遇到“调用”节点,就去检查被调用的函数名、参数、上下文等。这样规则本身就自带解释:我是因为看到了调用,才报警。
3.2 示例:找出真正的eval调用
我们先写一个扫描器,找出代码里所有对eval的真实调用。注意,是真实的调用,不是字符串里的“eval”,也不是注释里的“eval”。
import ast
import sys
# 要扫描的Python文件路径
target_file = sys.argv[1]
class EvalCallFinder(ast.NodeVisitor):
"""遍历语法树,专门找eval调用"""
def visit_Call(self, node):
# 这里的node就是一个函数调用节点
# 检查被调用的表达式是不是一个“名字”
if isinstance(node.func, ast.Name):
# 这个名字恰好叫eval
if node.func.id == "eval":
# 输出源码行号
print(f"[危险] 发现eval调用, 行号: {node.lineno}")
# 继续遍历子节点,防止eval藏在嵌套表达式里
self.generic_visit(node)
with open(target_file, "r", encoding="utf-8") as f:
source = f.read()
tree = ast.parse(source)
finder = EvalCallFinder()
finder.visit(tree)
假如被扫描的demo.py内容如下:
# 这里有一个真实的eval调用
def calc(expr):
return eval(expr)
# 下面这个只是在字符串里写了'eval',不是真的调用
def fake():
msg = "eval is dangerous"
print(msg)
# 这个也不是调用,只是打印了eval这个名字
def print_eval_name():
print(eval)
运行上面的扫描器,它只会报告第一处eval调用,后面两个都不会误报。因为它看的是“语法树上的调用节点”,而不是文本里的“eval”字眼。这就是可解释的审计模式。
3.3 示例:找出缺少timeout的requests
再举一个更贴近实际的例子。Python的requests库,如果不设置timeout,程序可能会一直卡在那里。我们想找出所有没有设置timeout的requests.get/post调用。
import ast
source = """
import requests
def fetch(url):
# 缺少timeout,可能卡住,危险
resp = requests.get(url)
return resp.text
def fetch_ok(url):
# 设置了timeout,安全
resp = requests.post(url, timeout=5)
return resp.text
"""
class NoTimeoutChecker(ast.NodeVisitor):
"""检查requests调用是否缺少timeout参数"""
def visit_Call(self, node):
# 先看是不是“模块.函数”这种形式
if isinstance(node.func, ast.Attribute):
# 看看模块名是不是requests
if isinstance(node.func.value, ast.Name) and node.func.value.id == "requests":
method = node.func.attr
# 只关心常见的几个方法
if method in ("get", "post", "put", "delete"):
# 遍历所有关键字参数的名字
has_timeout = False
if kw.arg == "timeout":
has_timeout = True
if not has_timeout:
print(f"[警告] requests.{method} 缺少timeout参数, 行号: {node.lineno}")
# 继续往下遍历
self.generic_visit(node)
tree = ast.parse(source)
checker = NoTimeoutChecker()
checker.visit(tree)
四、把模式变成真正的审计工具
上面我们直接用Python脚本写死了规则。实际工程里,我们会希望有“可配置”的规则,比如用一个规则列表,描述哪些函数需要哪些参数。下面的示例展示了一个极简的规则引擎雏形,所有代码仍是Python。
import ast
# 定义两条可解释的规则
rules = [
{
"name": "no-eval",
"description": "禁止使用内置eval",
"type": "call",
"func_name": "eval",
},
{
"name": "timeout-required",
"description": "requests必须设置timeout参数",
"type": "call-attribute",
"func_name": "requests",
"methods": ["get", "post"],
"require_kw": "timeout",
},
]
class RuleChecker(ast.NodeVisitor):
"""根据规则列表遍历AST"""
def __init__(self, rules):
self.rules = rules
def visit_Call(self, node):
# 对每一条规则做检查
for rule in self.rules:
# 规则一:简单调用的名字匹配
if rule["type"] == "call" and isinstance(node.func, ast.Name):
if node.func.id == rule["func_name"]:
print(f"[规则] {rule['name']} 命中, 行号: {node.lineno}")
# 规则二:属性调用,比如requests.get
if rule["type"] == "call-attribute" and isinstance(node.func, ast.Attribute):
mod = node.func.value
if isinstance(mod, ast.Name) and mod.id == rule["func_name"]:
if node.func.attr in rule["methods"]:
# 检查是否缺少必需参数
if rule["require_kw"] not in args:
print(f"[规则] {rule['name']} 命中, 原因: 缺少{rule['require_kw']}, 行号: {node.lineno}")
self.generic_visit(node)
# 测试代码
test_code = """
import requests
def bad():
eval("1+1")
requests.get("http://example.com")
def good():
requests.get("http://example.com", timeout=3)
"""
tree = ast.parse(test_code)
checker = RuleChecker(rules)
checker.visit(tree)
这样,我们就把“规则”和“匹配逻辑”分开了。以后想加新规则,不用推翻整个扫描器,只需要加一条规则描述。这比在正则堆里找补丁要舒服得多。
4.2 更进一步:数据流分析
上面的规则还只是“语法模式”匹配,没有追踪变量从哪里来。真正的SAST工具还会做数据流分析,比如一个值从用户输入来,经过几次赋值,最后拼进了SQL。这种分析能告诉开发人员“污点怎么传播的”,告警会更有说服力。
不过数据流分析很复杂,不是几行代码就能做好的。正因为如此,我们才要理解一个理念:先从可解释的审计模式起步,让规则能说清楚“我看到了什么”。等积累够了,再上数据流,你的告警质量就会比纯正则高很多。
五、应用场景与优缺点
5.1 这些场景最受益
第一个场景是团队的编码规范检查。比如规定“禁止使用eval”“HTTP请求必须设置超时”,这类规则非常适合用AST模式去写,误报少,还能输出具体行号和原因。
第二个场景是CI流水线里的安全门禁。告警质量不高的话,开发人员会直接关闭门禁甚至绕过工具。用可解释的规则,误报率低,大家才愿意认真看并修复。
第三个场景是平台的规则二次开发。很多企业用商业SAST,但仍然需要定制自己的策略。如果你具备基于AST写规则的能力,而不是只知道“追加正则”,那你定制的规则会有更强的生命力。
5.2 优点和缺点都要看清楚
优点很明显:规则可读、可维护,误报减少,告警能给人讲道理。缺点也很现实:每一种语言都需要单独的AST解析器,Python的AST模型跟Java、JavaScript都不一样。你没法用一套代码通用搞定所有语言。而且很多安全问题不只是结构问题,比如“变量是否被用户控制”需要数据流分析,那需要更深的框架,比如CodeQL、Semgrep等,学习门槛并不低。
另外,AST模式无法感知代码的执行语义。例如,一个函数可能在某个条件分支里才调用eval,AST能看到“有条件”,但不容易判断条件是否恒真。这需要更复杂的路径分析,不是简单遍历就能解决的问题。
六、注意事项
6.1 别忽略语言的“怪脾气”
每种语言都有自己的坑。Python里eval可能被用户重新赋值,或者通过__builtins__访问。你的规则需要处理这些遮蔽情况,否则要么漏报,要么误报。再比如JavaScript里,eval还可以通过window['eval']调用,这种动态访问光靠AST也不好抓。
写规则时,一定要先跑一批真实代码样本,看看会不会误报、漏报。规则不是“写出来就完事”,它需要不断调整。
6.2 规则不是写了就完事
安全规则会随着业务变化而变。今天你禁止了eval,明天可能还有exec,后天可能还有subprocess。你得安排人定期维护规则库,并把告警结果反馈回规则开发中。没有维护的规则库,早晚会重新变成新的“告警海”。
另外,不要试图用一条规则解决所有问题。过多的规则堆在一个逻辑里,会让代码难以为继。建议每条规则保持独立、清晰,每一条都能给出充分的理由。
七、总结
从正则表达式到语法树,再到数据流,这是一个逐步深入的过程。正则适合做非常粗浅的搜索,但SAST规则定制不能只靠堆正则。可解释的审计模式,让我们能够从代码结构本身出发,写出既准确又友好的规则。
面对满屏的告警,我们真正需要的不是更多、更长的正则,而是更聪明、更诚实的规则。诚实的意思是:这条规则知道自己在看什么,它说你有风险,会告诉你是哪个函数调用、哪一行、为什么。这样的工具,才能帮你从告警海里爬出来。
不再被海量的误报折腾,把精力留给真正值得追踪的漏洞,这才是我们做SAST规则定制的初心。
评论
围绕“不想持续淹没在告警海里,SAST规则定制要从可解释的审计模式出发,而不是简单堆砌正则表达式”参与讨论