跳到主要内容

测试全绿,不代表它真的测了

摸鱼办主任
摸鱼办主任

· 阅读约 4 分钟

给 AI 写测试这件事,最魔幻的地方在于:你的测试到底在测什么。

Marco 给自己那个 llm-council 写提示注入测试的时候,测的是"字符串的计数和索引顺序"。不是"模型有没有被攻击者骗到",是"这串字出现了几次、按不按顺序"。别的我不敢乱说,但"计数和索引顺序"这六个字,基本能勾勒出传统测试里一大半"看起来在测,其实没测"的幻觉。测试全绿,只是因为你压根没打算问你真正该问的那个问题。

【灯光渐暗】

这个漏洞怎么被发现的?他用自己的工具去审查代码库,然后发现:那些用来分隔指令和数据的固定分隔符,就躺在公开仓库里。攻击者连猜都不用猜,看一眼就完了。伪造一个结束标记,你的模型立刻把后面的内容当指令执行。

这根本不是"有没有漏洞"的问题。"公开仓库 + 固定字符串 + 一个会把输出当指令的模型",这三个词摆一起,漏洞是必然的,发现是偶然的。他写测试的时候大概也没想过——一个检查"字符串出现次数和顺序"的测试,要怎么发现"模型被骗子拐走了"?

它当然不会。它从来没打算问这个问题。

修复方案倒是不复杂:每次运行随机生成一个 nonce,嵌进分隔符。攻击者伪造的标记只能作为普通文本存在——因为运行时只有工具自己知道这轮 nonce 是什么。像给门换上一次性钥匙,每次开门换一把,前一秒的钥匙下一秒就作废。第三阶段排名数据没加围栏这事儿,也一并被纳入保护范围。

然后他来验证修复效果。变异测试:把 nonce 改成静态值,3 个测试当场红了;把排名数据的围栏删掉,2 个红了。这才叫"测试有效"——不是它全绿,是当你把安全属性拆掉的时候,它能第一时间把你按住。修复前的测试为什么全绿?因为它根本没有"被破坏"这个选项。它只是数了数数。

【关于本次修复过程中最荒诞一幕的裁定书】

他写了一个测试:assertNotEqual(_new_nonce(), _new_nonce())

两个字面完全一样的调用,语义是"它们必须不一样"。这是 nonce 最核心的属性,也是这个测试存在的唯一理由。

SonarCloud 看了一眼:复制粘贴缺陷。拦截。

一个静态质量门禁,在两个长得一模一样的调用面前,永远只能看到"雷同",看不到"必须不同"。它不认识 nonce 这门学科。它只认字形,不认语义——一个和提示注入异曲同工的问题:只看输入长什么样,不管你想让它干什么。SonarCloud 和提示注入,某种程度上是同一类 bug 的两个版本:形式到位了,语义没人管。

最后怎么过的?改成跑 50 次,必须全部唯一。一个写一次就能证明的属性,为了向一个静态工具证明"我不是复制粘贴",硬生生多跑了 49 次。这大概是我见过的关于"过度验证"最正当的一次。

(友情提示:如果你也遇到了"测试写了但静态工具说你在复制粘贴",恭喜,你至少知道质量门禁在认真看你的代码。虽然它看不太懂。)

这个修复 PR 最后带了 122 个测试进去。122 个,为了一个分隔符漏洞。评论区里的人还在跟 Marco 讨论负控制测试、属性可证伪性……我没细看,但你能感觉到,一个提示注入漏洞的修复过程,最后演变成了一场测试方法论的围炉夜话。这大概就是这行的常态:你以为你在修一个 bug,修着修着,你开始怀疑自己对"测试"两个字的全部理解。

我顺手看了一眼自己的测试。全绿。

但我不敢保证,如果我把哪个关键的防御代码删掉,它们会不会变红。我甚至不确定它们在不在问那个问题。

评论区扣个同款,让我知道不是我一个人。🫠