作者用 OWASP Java Benchmark 测量漏洞扫描器,解释精确率、召回率和 F1,并将低召回的最小原型视为验证污点追踪链路的实验。案例展示了如何先建立可复现基线,再逐步扩展 source、sink 和数据流规则。
第一次用行业标准基准测试运行我的漏洞扫描器时,评分报告最下面显示的是:
$ python scripts/score_benchmark.py --findings out/java.findings.json \
--truth benchmark-java/expectedresults-1.2.csv
OVERALL precision 0.60 recall 0.07 F1 0.13 # abridged
三个数字,下面逐一解释它们的含义。
Precision 0.60——扫描器发出的所有警报中,60% 指向了真实漏洞:也就是说,它一旦开口,多数时候都是对的。
Recall 0.07——基准测试中的所有真实漏洞,它只找到了 7%。在我的扫描器所覆盖的四类漏洞中,基准测试包含 777 个经过标注的真实漏洞;而它漏掉了自己本应发现的漏洞中的 93%。
F1 0.13——将 precision 和 recall 合并为一个分数,即两者的调和平均值。由于 recall 太低,这个分数也被拖到了接近于零。
我的第一反应,是趁别人看到之前把它修好。但我没有这么做。我保存了输出,把这个数字写进基准测试日志,并且保留了下来——因为这个数字迟早都要公开,而这篇文章就是公开它的地方。
过去几个月,我一直深入研究 AI——阅读、构建、测量。其中一个成果,就是一款 AI 漏洞扫描器。用一句话概括它的设计:确定性的静态分析规则负责完成所有搜索,LLM 则判断每一条发现——它究竟是真实漏洞,还是误报?
完整架构会单独写成一篇文章。这篇文章只讨论第一个经过测量的数字。
测试集是 OWASP Benchmark——包含 2,740 个带标签的 Java 测试用例,是 Java 安全扫描器的标准考场。在我的扫描器覆盖的四类漏洞中——SQL injection、command injection、path traversal 和 XSS——共有 1,478 个用例:其中 777 个是真实漏洞,另有 701 个是专门用来诱导扫描器误报的陷阱用例。
我拿来比较的每一个工具——Semgrep、CodeQL——参加的都是同一场考试,并由同一套评分代码打分。所有人遵守相同的规则。
刚接触这些概念?理解这篇文章只需要三个术语。
source 是不可信输入进入程序的地方,例如 request.getParameter("id")——任何攻击者能够输入的内容。
sink 是这些输入变得危险的地方,例如 executeUpdate(sql)——把输入当作数据库命令执行。
如果数据从 source 流向 sink,途中却没有经过清理,就会形成漏洞;这类流动中的数据称为 tainted data,而追踪它的过程称为 taint analysis——扫描器的全部工作,就是找出这些数据流。
这些你都已经知道?那就跳过这一段。
我当时实际运行的,只是一个 spike。它是一个刻意保持极简的第一版:只使用一种 source 模式 getParameter,连接到少数几个 sink,然后让它端到端跑完整条 pipeline——把 2,740 个测试用例解析成 code graph,运行 taint query,输出 findings,再对照标准答案评分。
这个 spike 的任务从来都不是取得高分。它只需要回答一个简单的问题:整套机器究竟能不能运转?
而这个难看的数字,只要仔细解读,就已经给出了答案:
Precision 0.60——扫描器一旦发出警报,通常就是对的。taint engine 正确追踪到了真实的数据流。
Recall 0.07——它看不到 93% 的漏洞。engine 并没有坏,只是它掌握的词汇太少。我就像站在一栋有许多扇门的大楼外,却只监听了其中一扇门。
这并不意味着整个思路是错的。它只是正确而简单地诊断出了 source 列表过窄的问题,而且是在我还没有把数周时间投入错误层次之前,就给出了诊断。
如果第一个数字是 precision 0.10,那我面对的就是 engine 问题,意味着需要重建。可 recall 问题只是列表问题。列表是可以修的。
显而易见的做法一:不要告诉任何人。悄悄修好它,只发布最终数字,看起来更专业。
几乎所有公开构建产品的人,都做过某种版本的这种事——那条代表“一夜成功”的曲线,总是从第一个漂亮的结果开始画。
问题在于:这个系列中的每一个结果,都是我自己运行基准测试得到的数字。这里没有裁判——成果发布之前,没有第三方替我检查;只有我、一个评分脚本,以及你。
面对这样的数字,读者只有一种方法判断它们是否可信:看作者过去如何对待那些对自己不利的结果。如果我展示给你的每一个数字都是胜利,你就没有理由相信其中任何一个。
所以,糟糕的数字也要发布——而且要先发布。之后,我会公布扫描器与 Semgrep、CodeQL 的正面对比。当我在那篇文章里宣称某项结果时,我希望读者想到的是:“这就是那个公开了自己 0.07 分数的人。”
诚实在这里不是一种美德,而是基础设施。
显而易见的做法二:用规则地毯式轰炸。
Recall 太低?那就添加更多模式!匹配更多名称,放宽 regex,把所有东西都标记为 tainted——recall 肯定会提高。
但 precision 也会被摧毁。更糟的是,同时进行二十项修改之后,你根本说不清究竟是哪一项产生了什么效果。你把一个经过测量的系统,换成了一个靠感觉运行的系统。
我的做法更慢,也更枯燥:阅读基准测试中的真实代码,找出它实际调用了哪些方法,按照代码使用频率依次添加 source,并在每次修改后重新测量。
一次只改一个变量,每次修改只产生一个数字。
我调查了基准测试代码实际使用了哪些输入方法,并按文件数量统计:getRequestURI 出现在 724 个文件中,getCookies 出现在 664 个文件中,getParameter 出现在 538 个文件中,getParameterValues 出现在 510 个文件中,getHeaders 出现在 400 个文件中,之后还有 HTTP request 接口上的其他方法。
而我的 spike 只覆盖了这张列表中的一个条目。
于是,所有 source 被合并成了一份共享定义——一个匹配 fully-qualified method name 的 regex,让每个 getter 都与使其受攻击者控制的类型绑定。下面是精简版本;完整的规则表会放在另一篇文章中:
// Queries run on Joern, an open-source code-analysis engine (Scala DSL).
".*(HttpServletRequest|ServletRequest)\\.(getParameter|getParameterNames|getHeader|" +
"getHeaders|getCookies|getQueryString|getRequestURI|getInputStream|…)\\b.*" +
"|.*Cookie\\.(getValue|getName)\\b.*" +
"|.*SeparateClassRequest\\.(getTheParameter|getTheValue)\\b.*" // the benchmark's request wrapper
.*Cookie\.getValue.* 只会匹配 cookie 的 getter——如果只匹配一个裸露的 getValue,就会命中世界上所有名为 getValue 的方法。
这一步里还隐藏着另外两个问题,而且它们是两类完全不同的问题:
基准测试中有 117 个真实的 SQL injection 用例完全不会接触 java.sql——它们通过 Spring 的 JdbcTemplate 执行。添加一条新的 sink 规则,就让 SQLi recall 从 0.57 提升到了 0.86。
还有 6,060 个 XSS sink 调用不可见,但原因不在规则本身。如果分析路径上没有 servlet library,engine 就无法推断 response.getWriter() 的返回类型,因此这些调用永远无法匹配基于类型的 .*Writer.* 模式。
修复方法是:当 receiver text——也就是代码中原样写出的 response.getWriter() 部分——匹配 getWriter|getOutputStream 时,也将其认定为 sink。仅仅这一项修改,就让 XSS recall 从 0.03 提升到了 0.73。
新分数:precision 0.53,recall 0.83。
此时仍有 130 个真实漏洞没有找到。我将这些漏报与基准测试代码进行 diff,发现其中 97 个——占剩余漏报的四分之三——都从同一个没有被列入规则的方法获取 tainted data:getParameterNames()。
不难理解为什么人们会忽略它。getParameter("id") 返回的是用户输入的值——显然很危险。getParameterNames() 返回的是参数名称——而名称给人的感觉更像结构,而不是数据。
但客户端同样可以选择参数名称。?<script>alert(1)</script>=x 是任何客户端都可以发送的 query string,此时参数名称和参数值一样,完全受攻击者控制。
只需在 regex 中添加一个名称。这项修改当场找回了 93 个真实漏洞:recall 从 0.83 提升到 0.95。
另外 4 个漏洞虽然也属于这 97 个,但还被另一个独立问题阻挡——它们会在修复 3 中重新出现。
而真正让我不安的是:整个过程中,没有任何东西崩溃,没有任何警告,也没有任何迹象看起来不正常。source 缺失时,系统会静默失败。如果没有带标签的基准测试,我永远不会发现这个问题。
还剩下 37 个漏报——其中 33 个从未使用 getParameterNames,另外 4 个来自修复 2,但还存在第二个问题——而且每一个用例都包含 .split(...)。
我没有继续猜测,而是在一个失败用例中逐段测量 taint chain。
被执行 split 的变量,可以从 source 到达。
split 调用本身,也可以到达。
但对其结果执行的数组索引访问,例如 param.split(" ")[0],无法到达。
taint 正确流过了 split,却死在了索引操作上。engine 自带一条处理该操作符的默认规则,但覆盖这条规则并没有带来任何变化——缺口出在 engine 如何把规则应用到该操作符上,不是我能通过配置消除的问题。
于是我构建了一座 bridge,并用一个条件保证它足够严谨:只有当被索引的数组本身能够从真实 source 到达时,针对 split 风格调用结果的索引访问才会被提升为额外的 source。
访问一个未受污染数组的索引,仍然不会产生 taint——这个条件正是针对性修复与无差别过度污染之间的区别。
37 个漏报全部找回,代价是恰好增加了 3 个误报,以及每次扫描大约 84 秒的额外工作。这个修复同时提高了 recall 和 precision。
完整的提升过程,每次只进行一项经过测量的修改:
零个 false negative。全部四类漏洞的 recall 都达到了 1.00——SQL injection 找到 272 个真实漏洞中的 272 个,command injection 找到 126 个中的 126 个,path traversal 找到 133 个中的 133 个,XSS 找到 246 个中的 246 个。
在相同的 1,478 个用例上,使用相同代码评分,这与 CodeQL 达到的 recall 相同。而我的实现只用了一张七行规则表,也不需要 build step。
现在来说不那么舒服的部分,因为这是一篇工程日志,不是产品发布稿。
Precision 0.56 意味着产生了 614 个误报,而且这一层掉进了基准测试设计的 88% 的陷阱——比两个现有主流工具都多。正面对比文章会公布完整结果。
完美的 recall 配上很弱的区分能力,已经危险地接近一种对所有事情都回答“也许”的工具。我宁愿亲自写下这句话,也不愿让读者替我写出来。
“你通过阅读基准测试自己的代码,并把它调用的内容添加进去,修复了 recall。这是在 test set 上调优——recall 1.00 没有任何意义。”
这话部分正确,因此值得说得更精确。
这个基准测试是公开的,而且所有接受测量的工具都可以开卷考试——现有主流工具也会针对它进行调优。但请看看这些修复实际上是什么:标准的 HttpServletRequest 输入接口——getHeader、getCookies、getRequestURI——而不是针对基准测试的特殊 hack;唯一的特殊情况,是基准测试自己的 wrapper class,而且我已经在上面的 regex 中明确披露。
那些陷阱用例也能证明我没有针对答案投机:我掉进了 88% 的设计误报用例,比 CodeQL 更糟——如果我真是在拟合标准答案,这会是我最先修复的数字。
recall 1.00 真正能够诚实宣称的范围要窄得多:给定一套词汇,只要漏洞使用了这些词汇,engine 就不会漏掉任何一个。而构建这套词汇的循环——调查代码实际调用了什么,按照出现次数添加 source,每次修改后重新测量——同样可以用于接入任何真实代码库。
它在真实 repository 上的泛化能力还没有测量;等我测量之后,无论结果好坏,都会公布。
“为什么不直接使用 CodeQL?Recall 相同,precision 更好。”
没错,正面对比文章会用纯文本明确打印这一行结果。
尽管如此,这个项目仍然存在,原因有两个。
第一,这个发现层只是一张七行规则表,不需要 build step;CodeQL 拥有多年积累的 library model,但它需要你的项目能够完成 build。
第二,precision 问题是有意保留的冗余:发现层负责过度报告,为 judge——也就是 LLM 层——提供可以剔除的材料。在受控测试中,我尝试过的最佳 judge model 消除了一半误报——最近一次经过验证的运行中消除了 52%——代价是损失 2% 的真实漏洞。
真正决定这种设计是否成立的比较,是经过 judge 处理后的输出与现有工具之间的比较。无论这个数字结果如何,我都会发布。
“pipeline 中存在 LLM,就意味着结果无法复现。”
从设计上说,非确定性已经被隔离起来。
本文介绍的全部内容都属于确定性层:输入相同的代码,就会输出相同的数据流——从 0.07 到 1.00 的整个提升过程完全没有使用 LLM。
model 从不负责搜索;它只对规则已经生成的 findings 作出判断。而且它的 verdict 不会直接被信任——我们会在固定 seed 的样本上,对照带标签的 ground truth 进行测量,并给出 confidence interval。
这项测量的结果如何,以及 judge model 的选择为何比我预想的更加重要,会在后续文章中介绍。
Recall 1.00 配合 0.88 的 false-positive rate,意味着这一层的 Youden's J 只有 0.12。Youden's J 等于 recall 减去 false-positive rate,是一种会惩罚“对所有事情都回答也许”的单值评分指标。
直白地说,这意味着区分能力很弱;而提高这个数字,正是 judge 的任务。
另外,上面引用的 judge 数据,是从包含 200 个 candidate 的分层样本中估算得出的,因此它们需要附带 confidence interval。我的日志绝不会把 sample-level 数字和 full-census 数字放在同一张表中,因为混用两者,会在不知不觉中让 precision 偏移一个百分点。
发布那个令人难堪的数字。公布 0.07 并没有让我付出任何代价——无论是否公开,扫描器都会以相同速度得到改进。它为我换来的,是日后被相信的资格:当与 Semgrep 和 CodeQL 的正面对比发布时,其中的每一个数字,都有这样一个事实作背书——我也会公布那些让我难堪的数字。
spike 的任务,就是以低成本犯错。这个极简版本只运行了一次,就告诉我薄弱的是哪一层——是词汇,不是 engine——而那时我还没有在上面构建任何昂贵的东西。如果你对新系统的第一次测量一点都不令人尴尬,那很可能说明你测得太晚了。
覆盖缺失是静默的——只有测量才能发现。在我的 130 个漏报中,有 97 个可以追溯到同一个缺失的 source,却没有产生任何崩溃、警告或异常日志。这远不只适用于安全领域:没有人测试的错误路径、没有人调查的市场细分、没有人生成的输入情况——“缺失”从不会主动宣布自己的存在。如果你没有对照 ground truth 进行测量,你的盲区根本不会让你感觉它们像盲区。
上面的经验属于我;这一节属于你。根据你的身份,各取所需。
你从事软件开发——任何类型的软件都可以。本周就能应用两个习惯:
检查你的输入验证是否不仅覆盖输入值,也覆盖输入名称。客户端可以同时选择两者——正是这个缺口,让我漏掉了 97 个漏洞。
对于你负责的任何系统,都问一句:“我的 labeled answer key 在哪里?”能力缺失不会产生错误,也不会留下日志。只有对照已知真相进行测量,才能让盲区显现出来。
你使用 LLM 或构建任何 detection system。可以把这套循环完整拿走:发布一个端到端的极简 spike,用 ground truth 测量它,一次只修改一个变量,重新测量,并保留每一个数字。
它只运行了一次,就定位到了我最薄弱的层;它同样能够定位你的薄弱之处。
你正在学习安全。现在,你已经掌握了组织静态分析知识的四个核心概念——source、sink、tainted flow,以及 precision/recall trade-off。
这里的一切都可以复现:确定性层完全免费运行,不需要任何 API key。
这就是第一个数字。
这个系列接下来会介绍:整套扫描器赖以成立的架构——为什么所有搜索都由规则完成,而 AI 只负责判断——judge 实验,以及与 Semgrep、CodeQL 的完整正面对比。
每一个数字都会公布,尤其是那些难看的数字。(代码和基准测试产物会与正面对比文章一同公开。)
我是 Ali Afana——一名身处 Gaza 的 AI builder 和安全研究者。我公开构建系统,用 ground truth 测量它们,并保留所有证据。这款扫描器只是更长道路上的一个项目——关注我,看看接下来会发生什么。
GitHub · X · LinkedIn
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。