String sql = "select * from users where id = " + req.getParameter("id");
doSomething(sql);
模型给了 SAFE。
它看得见 req.getParameter 是不可信输入,也看得见这个字符串被拼进了 SQL 语句。它唯一看不见的,是 doSomething() 里干了什么——实现在别的文件,或者干脆在它这次读到的范围之外。
于是它替这个函数签了一张欠条:这名字听着像会做净化的。
EdgeGuard 那个项目就是在这儿栽的跟头。项目本身是什么不重要,一个 VS Code 扩展,想拿对抗性的视角去审代码。它撞上的这个东西——LLM 做代码审查最典型的失败模式——在这个例子里一次性露了个干净。模型不是不懂 SQL 注入,它连该查什么都清楚。它是在缺一块拼图的地方自己把拼图补上了,补得还特别自然:"这函数大概会做净化吧",然后顺着这个假设往下推,最后给出 SAFE。
误报你花几分钟核一下,过去了。假阴性反过来:你不知道它存在,直到它上线。更要命的是,AI 给出的假阴性不是"我漏了",是"我判断这里安全"。它把一次"没看见"翻译成了一个结论。这个翻译是不自觉的,模型没撒谎,它只是在用乐观假设填坑。
换成攻击者的思路。他不去破解你的静态分析器,那太累。他要做的比这简单:让该被看见的那一层,在你的审查器视角里看不见。
框架里的 filter、interceptor,请求进来时做净化,但 AST 上这层和 dao 层串不起来。反射、动态调用——call graph 到那儿就断头,分析器不知道它调到了哪。再不然,把逻辑挪到扫描边界之外:generated code、vendor 目录、构建产物,你的审查器默认跳过。
然后在模型视角里,代码呈现出来的形态是"不完整,但看起来合理"。它看不到净化实现,它默认为这里有净化。
这条链短得有点冷酷:
让关键的净化层落在模型视野之外 → 让代码保持"看起来合理"的形态 → 模型用乐观假设抹平缺失 → SAFE 结论进报告 → 你合并 PR → 漏洞上线,而且是带着"AI 已经审过"的通行证上线的。
最后一步最毒。漏报你不知道。漏报再加上一份装订好的"已审查"结论,你会主动不去看。
我一开始想说这类假阴性就是高危。后来想了一下,得把两件事拆开:这个漏洞本身可能不高危,高危的是它穿着结论的外衣落地。工具告诉你"这块我没看清",你还会自己看;工具告诉你"这块安全",你就把这一页翻过去了。工具越自信,你翻页越快。
EdgeGuard 后来做的调整里,我认为最值钱的一条不是那个"污点保持"规则——虽然那条规则很好看:污点数据进入未知函数时,在缺乏净化证据的情况下仍然视为污点。这是把 default deny 用在数据流上,语义完全正确。可规则写在哪儿,决定它有多硬。写在 prompt 里,它就是一段话,后面的输入可以盖过去;写在 AST 和 call graph 那一层,它才是一条边界。
我手上还压着几个没到 disclosure 窗口的洞,其中一个就是这么发现的——最后真正修的地方不在提示词里,在它上面那层权限判断。——离题了,收,回来说作者做对的下一步。
他在发起 API 调用之前,先在本地做静态风险筛查:找数据库操作、文件系统访问、进程执行、网络边界、用户可控输入、危险 API、缺失校验,把高/中风险的函数筛出来,再交给 LLM。
表面上这是为了省钱和省 API 额度。7,536 个函数挨个调一次 API,谁也扛不住。但更值得说的是它改了 LLM 的角色:从一个"全量审查者"变成"被点名之后的调查者"。这个变化改的是失败模式。全量审查的时候,模型在一大堆函数里自己决定该看哪个,找不到干净落点就会迁就,给一个大概的判断;被点名的时候,它面前站着一个具体、指向明确的问题,它知道有人在等一个具体的证据,而不是在等一句"总体还行"。
作者自己那句话是对的:把 LLM 变成推理引擎,而不是第一道分析环节。
那个 2,145 条发现的数字,我还是想说一句——不好用。2,145 里有多少能复现,有多少只是"可能存在",一个开发者没法对着这份清单干活。2,145 是标题要的数字,干活的人要的是"这里有洞,这是复现输入,我打过一次"。作者说关键不在发现数量而在架构,这话对,但他还是把 7,536 和 2,145 摆出来了——不冤枉他,换我我也摆,那个数字确实好看。
真正让我觉得这项目有意思的是验证那一环。假设 → 反例 → 生成测试 → 执行 → 证据。C# 生成 xUnit,Java 生成 JUnit,TS 生成 Mocha。这不是"AI 生成单元测试"那套流程,它是把 SAFE 这个结论换成了另一种东西:提了一个攻击假设,造了一个输入,跑了一次,没打进去。打不进去和"我认为打不进去"是两个世界。前者是证据,后者是观点。
作者在评论里那句话,我拿它当所有 AI 审查工具的验收标准——无法建立可信结论时,不确定性应当保留可见,而不是被静默转成 SAFE。
说完优点,说两条不顺眼的。
一条是语料。三种语言三份料:Java 用 OWASP Benchmark,TS 用 Juice Shop,C# 用 eShopOnWeb。这三样东西不是一个种类。OWASP Benchmark 是带标注的合成靶场,每条测例都有 ground truth;Juice Shop 是一个完整的、有意写满漏洞的应用,能跑端到端,但没有 Benchmark 那种均匀分布和标注;eShopOnWeb 是微软的示例工程,本身就不是漏洞靶场。拿这三种语料跑出来的数不能横向比。说"跨语言能跑通"可以,说"检测率"就不行——那三个数字放一起没有意义。要真比,得拿同一个漏洞集在三种语言里各写一遍,或者拿同一种语料翻成三种语言。这很贵,但省不掉。这就是选型的时候会真踩到的坑。
另一条是模型版本。作者自己在评论区说了,数据来自 Gemini 的初步实测,跨模型系统对比没做。这个诚实我认可,但还得补一句:不带模型名、版本、日期的检测率,没有复现价值。我不知道这是哪一代跑出来的,换个版本可能差一截,prompt 一改又差一截。这类工具的效果报告,得把模型版本、prompt 版本和代码版本钉在一起——不然你拿到的是一张打完靶的成绩单,不是基线。
顺手给个威胁分级,方便你判断哪些要连夜处理:
- 模型用乐观假设填补缺失信息、把不确定转成 SAFE——真高危,尤其是它接进了自动合并、自动部署这类流水线的时候。这不是"AI 不擅长安全",是"AI 在替你消灭破绽的证据"。
- 一次全工作区扫出 2,145 条发现——偏标题化。别为这个数字选工具,也别为它熬夜。
- 跨语言跑通、调查引擎和语言上下文分离——架构成果,不是安全上多出来的一道防线。该夸,但别混算。
拆弹清单:
- 从工具输出里把 SAFE 这个词删掉。换成三态:confirmed / unverified / not-scanned。任何工具敢替人下"安全"这个结论,你就是在骗自己。
- 污点保持别只写在 prompt 里。AST 和 call graph 层面能判的"看不见净化实现",就别甩给模型猜——看不到证据就保留污点,报 unverified,报不出 SAFE。
- 让工具报扫描边界。哪些目录跳过了、哪些动态调用没解析、哪些框架中间件是黑盒。攻击者就藏在这份清单里,它比发现清单有用。
- 高权限路径永远留人过一遍:鉴权、加密、查库、命令执行、文件写。工具说没问题,意思是它没找到证据。
- 全量扫描的结果不要接进 PR 门禁。全量扫描用来看趋势和覆盖率,不用来卡合并。要卡,卡在那些有复现证据的条目上。
排雷记录:一个敢说 SAFE 的审查器,比一个扭扭捏捏说 UNKNOWN 的审查器危险得多。它值钱的地方不在于更聪明,在于它肯不肯告诉你哪一块自己没看清。