上周四晚上,我让 Claude Code 把一段我标记了三个月的 SQL 注入清掉。它修完,跑通,测试全过。我本来想直接合进主干,临了多看了一眼 diff——修掉的那处在 users 表的查询上,改成了参数化,标准得无可挑剔。但就在同一块逻辑往下数十几行,一段跟它长得几乎一样的拼接,它一个字没动。
- query = f"SELECT * FROM users WHERE id = {user_id}"
+ query = "SELECT * FROM users WHERE id = ?"
# fetch profile for audit log
- profile = db.execute(f"SELECT * FROM profiles WHERE uid = {user_id}")
+ profile = fetch_profile(user_id)
fetch_profile 是我大半年前写的一个包装函数,里面还是字符串拼接。Claude 把调用替换上去了,但 fetch_profile 本身埋着一模一样的雷。它修掉了一个扫描器会报的地方,对旁边长着同样毛病的另一处看都没看。
先停一下,问个问题:这算修好了吗?
那晚我没合。第二天在 arXiv 上翻到一篇刚挂出来的论文,讲的就是这么一条流水线——代码生成完,扫,再让一个独立的模型验证,再修,再扫。它管这套叫"即时漏洞检测与修复"。里面有组数字我盯着看了很久:
修复过程在约 15% 到 22% 的样本里引入了新的漏洞。其中七成的新增案例只有一个新增发现。
差不多每五六次"修复"里,就有一次,修完比不修更糟。
我一开始觉得这数字有点夸张。后来想想自己那天晚上的 diff,又觉得它可能还偏乐观。
往下剥一层:修漏洞的模型,到底在干什么?
它的输入是"这里有一个 SQL 注入,修掉它"。它被训练去完成的那个目标,不是让这段代码真的变得更安全,而是输出一段"看起来修好了"的 diff。这两件事常被当成一回事,其实隔着一层。
写代码的模型擅长输出"看起来对的代码",是因为训练数据里正确代码就长那样。修漏洞的模型,训练数据里全是"修复前-修复后"的成对样本,它学的是"一个成功的修复 diff 长什么样"。而"一个 diff 看起来像修复"和"代码库里真的少了一个漏洞",中间隔着的缝,就是那 15% 到 22%。
再往下呢?论文里比了对两种流水线配置:一种只把验证模型精选过的发现喂给修复模型(他们叫 P1),另一种在此基础上把 CodeQL 和 Bandit 最早的原始扫描结果也一起喂进去(P2)。他们跑了 80 次,覆盖四个 Claude 模型和 9 个 CWE 类别。结果很一致:P2 在全部四个模型上都好于 P1。P1 的漏洞残留降幅大约 9% 到 54%,P2 拉到 29% 到 69%。
我一开始不太理解。原始扫描结果里混着不少误报,把误报也丢给修复模型,怎么反而修得更干净?后来琢磨出一个可能的方向:
CodeQL 和 Bandit 的输出是"低层级"的信号。它们不给你下结论,只告诉你"这里,就这里,可能有点不对劲"。当验证模型把这些原始结果加工成一份精挑细选的修复清单时,它在做语义压缩。压缩必然丢东西——丢掉的往往是那些看起来不重要、但能给修复模型提供"周围哪里可能跟着裂开"这个判断依据的信息。P1 把扫描器最初的警报全扔了,只剩确定名单;P2 把名单和最初的那些"嘀咕"一起给了修复模型。
这个解释我在论文里没找到直接证据。方向不一定全对。但有一件事是确定的:在修复这个环节,原始的、未经语义过滤的信号,有它自己的价值。
接下来这层,是我看完之后觉得最别扭、也最值得停下来想想的地方。
四个 Claude 模型里,Opus 4.8 是最强的代码生成模型——这一点应该没人争。但在 P2 修复之后,残留漏洞最低、通过率最高的,是 Sonnet 4.6。
一开始我以为是实验噪声。再想,这跟上一层其实是同一件事。Opus 4.8 首稿写得太干净了,干净到 CodeQL 和 Bandit 一开始就扫不出多少东西。扫得少,P2 能多喂给修复阶段的那份"初始扫描结果"就薄。修复模型拿到的原始信号越少,越只能盯着验证模型筛出来的那几条确定结论去改,旁边还有没有别的毛病,它没线索。
Sonnet 4.6 首稿没写那么完美,初始扫描结果里信号更多,修复阶段反而更有东西可看。这么说吧——一个模型的首稿安全性,和它在流水线里的修复效能,不是同一种属性。甚至在这条流水线的设计下,首稿越干净,修复阶段越吃亏。
这个结论让我挺不舒服。如果真是这样,那"先让模型把首稿写到最好"这个思路,在修复场景里,可能要重新掂量。
你可能会说,反正最终看的是修复后的残留,P2 能把 Opus 4.8 的残留拉下来,不就行了吗?
这是对的。但我想说的是另一件事:这条流水线把"安全"从人的判断变成了一串工具间的互相背书。生成模型写,静态分析扫,验证模型筛,修复模型改,再扫。每一环都信任上一环的输出,没有人中途停下来问一句——"这样真的对吗"。
而修复模型夹在最后面。它的任务定义是前面几环扔给它的:根据它们筛出来的东西做修改。这意味着前面任一环系统性漏掉的漏洞,压根就不会出现在修复模型的任务清单里。修复模型不是在"保证代码安全",它是在"按照一张可能不完整的清单把代码改一遍"。这两个目标之间的空隙,就是那 15% 到 22% 里最不显眼也最要命的部分。
所以说回来,那天晚上我盯着那段 diff 看的时候,其实不是在怪 Claude 没修旁边那一处。我是在怪自己:我给了它一张只有一条的清单——"修掉 47 行的 SQL 注入"——然后指望它主动注意到 58 行还有一个。这条流水线比我聪明的地方,是它至少还把扫描、验证、修复串了起来。但它的代价也在这儿:每一环都在向清单看齐,没人向代码看齐。
修漏洞的流水线能做的:把清单上的东西修得比原来干净,大幅降残留。它做不到的:替你想清楚"修好"到底意味着什么。再往下就是怎么把"整段代码为什么长这样"的历史喂给修复模型了。这个我还没想明白,先放这儿。