团队对约 70 个回归守卫脚本进行故障注入,发现其中 44 个在受保护行为被破坏后仍然通过。统一运行器还发现长期未执行、未纳入 Git 和已经失败的脚本,说明检查齐全且全绿并不能证明回归保护有效。
我们在开发一款 AI 虚拟软装工具:用户上传一张房间照片,图像模型为房间重新设计风格,再由一组检查将结果与原图对比,检查通过后才会展示给用户。我们在上一篇文章里介绍过这些检查。这篇要讲的是它们下面的一层:用来确保检查、计费规则和用户授权逻辑始终保持修复后行为的脚本。
我们把这些脚本叫作 guard。每个 guard 都是一个独立的 Node 脚本,扩展名为 .mjs 或 .mts,用来断言某个行为;如果这个行为不再成立,就以非零退出码退出。每次修复都会附带一个 guard。到 8 月下旬,我们已经有了大约 70 个,通过 npm run qa 一起运行。
然后,我们向每个 guard 提了一个简单的问题:如果我们破坏了你负责保护的行为,你会变红吗?70 个 guard 中,有 44 个的答案是不会。
第一个问题甚至还不是质量,而是这些脚本到底有没有运行。直到 8 月 22 日,我们都没有 runner;脚本清单放在一个 Markdown 文件里,磁盘上有 78 个脚本,清单却只列了其中 33 个。后来,runner 开始将清单与实际存在的文件进行比对,结果发现:有一个 guard 已经变红,却一直没人注意到,18 项检查中只有 15 项通过;27 个仍在使用的 guard 已经几个月没运行过;还有 9 个脚本虽然在磁盘上,却没有纳入 git。
现在,所有脚本都运行了,而且全部是绿的。这让人觉得有了保障。其实并没有。
两天后,一次独立审查做了一个很小的改动:在错误追踪工具的配置中,把承载 DSN 的环境变量改了个名字。这就是重命名时很容易出现的那种拼写错误。负责检查这份配置的 guard 有 55 项检查,结果全部保持绿色。在生产环境中,这个拼写错误会让错误追踪工具悄无声息地什么都不做,而这恰恰是这个 guard 原本要防止的故障。
这已经足以让我们对每个 guard 都做同样的实验。
方法是手动操作,一次测试一个 guard:修改生产代码文件,真正破坏它所保护的行为,运行 guard,恢复文件,然后记录数字。只有在真实破坏发生后,guard 仍然保持绿色,才算一个漏洞。
结果:70 个 guard 中发现了 44 个漏洞。
其中一些具体发现如下:
有一个 guard 根本没有任何检查。无论代码变成什么样,它都会打印一个绿色对勾,然后以退出码 0 退出。
有一项检查会比较路由文件中两个字符串的位置,以此断言先删除存储中的 blob,再删除数据库记录。如果先删记录,客户的文件就会永久变成无法关联的孤立文件。但第一个字符串早在三周多前的一次重构中就被删掉了。indexOf 返回 -1,-1 比任何正常位置都小,于是从那以后,这项检查每次运行都通过。
还有一个 guard 测试的是一份手写的 SQL 副本,而不是生产代码中的 webhook。
这 44 个漏洞背后的共同模式是:guard 针对的是文本,而不是行为。我们把路由文件当作字符串读取,然后查找一些名称。这种 guard 两头都不可靠:正常重构会让它无缘无故变红;真正的破坏却能蒙混过关,因为那些名称还在。
用户授权路由依次做三件事:将用户的选择写入 auth provider,追加审计日志,再将用户的渲染图从公开展示区下架。guard 用源码中的 indexOf 检查这个顺序。9 项检查,全部是绿的。下面是让它失效的 mutation,以及改写后的检查:
// production: awaited; a failure returns 500 to the user
await client.users.updateUserMetadata(userId, { ... });
// mutation: same call, same position in the file - all nine checks still green
void client.users.updateUserMetadata(userId, { ... }).catch(() => {});
// rewritten guard: cut out the statement itself, not a name near it
const i = ROUTE.indexOf("client.users.updateUserMetadata");
const stmt = ROUTE.slice(ROUTE.lastIndexOf("\n", i) + 1, ROUTE.indexOf(";", i) + 1).trim();
ok("write is awaited, not fired in the background", /^await client\.users\.updateUserMetadata\(/.test(stmt));
ok("a failed write is not swallowed", !/\.catch\(/.test(stmt));
ok("and not silenced with void", !/^void\b/.test(stmt));
在这个 mutation 下,三个调用的顺序没有变化,所以旧检查全部通过。但原来的语义已经消失了:写入变成了发起后不再等待,失败被吞掉,审计日志记录了“已撤销”,用户以为自己已经退出授权,而公开展示区仍在发布他们的图片,因为 provider 中的状态依然是“允许”。
改写后的 guard 仍然检查文本,但检查的是语句本身的文本;而且,击败旧版本的那个 mutation 现在成了一项永久保留的检查。如果代码可以导入,我们会再往前走一步:guard 通过 loader 加载真实函数,并用一张输入表逐项运行它。
此前,每次实验都是一个用完即弃的脚本,每个大约 50 行,而且都会踩到同样的坑:Windows 工作副本使用 CRLF,git 中却是 LF;一个代码片段匹配了两处;还有一种“mutation”删除了一行,却无法通过空模式匹配将其恢复。有一次,改动就这样留在了代码里,直到第二轮检查才被发现。
8 月 28 日,这些脚本被整合成了一个工具。每项任务用 JSON 描述:guard 命令,加上一组有名字的替换操作。
{
"guard": "node tmp/qa/geometry-escalation-proof.mjs",
"mutations": [
{
"file": "src/lib/draft-rank.ts",
"name": "a judge that errored no longer ranks last - an empty verdict beats a checked image",
"from": " if (input.errored) return Number.MAX_SAFE_INTEGER - 1;\n",
"to": " void input.errored;\n"
},
{
"file": "src/app/api/generate/route.ts",
"name": "escalation became unconditional - furniture-placement failures go to the expensive engine too",
"from": "if (!economyMode && lastGeometryBroken && attempt < MAX_ATTEMPTS) {",
"to": "if (!economyMode && attempt < MAX_ATTEMPTS) {"
}
]
}
循环本身很短;真正让我们付出代价、积累经验的,是围绕它建立的那些规则:
if (!runGuard()) fail("guard is already red; it proves nothing"); // 1. green before
for (const m of mutations) {
if (!m.to.trim()) { bad(m, "empty replacement is a deletion, not a mutation"); continue; } // 2
const src = original(m.file);
const hits = src.match(rx(m.from, "g")); // rx() tolerates \r?\n // 3
if (!hits || hits.length !== 1) { bad(m, "snippet must occur exactly once"); continue; } // 4
fs.writeFileSync(m.file, src.replace(rx(m.from), m.to));
const stillGreen = runGuard();
fs.writeFileSync(m.file, src);
stillGreen ? bad(m, "GUARD STAYED GREEN") : good(m);
}
assertEveryFileRestoredByteForByte(); // 5
我们有意为 mutation 命名,并赋予它明确的语义。传统的 mutation testing 会自动翻转运算符;我们则把每个 mutation 写成一句描述客户会如何受损的话,因为这句话也说明了这个 guard 为什么存在。代价是覆盖范围:我们只能测试自己想到的那些破坏方式。
runner 诞生的那天,我们在一个新 guard 上运行测试,发现了一件意料之外的事。负责判断支付返回 URL 是否过期的函数里,有一个显式处理“支付时间在未来”的分支。6 个 mutation 都让 guard 变红了。第 7 个 mutation 删除了这个分支,却没有改变任何结果:未来的时间戳会得到负的时间差,而负数永远不会超过阈值。这个分支只是一个多余的补丁,guard 无法区分有它和没有它的正常代码。我们删除了它,并在函数上方写明了原因。
在同一轮测试中,针对 7 项修复的 35 个 mutation,发现了 3 个检查目标有误的 guard:一个解析器遇到第一个右花括号就停止,碰到嵌套对象便失效,在代码明显被破坏的情况下,6 个 mutation 中有 4 个仍然通过;一个检查“catch 块会记录日志”的 guard,在某个分支的日志被移除后仍然保持绿色,因为旁边的另一个分支还有日志;还有一个扫描原型键防护代码的工具,只要防护代码在文件中任意位置出现,就判定整个文件通过。
现在,仓库里有 93 项 mutation 任务、867 个 mutation,就放在 guard 旁边。
runner 的清单里有 171 个 guard,还有 12 个明确列出的跳过项:它们会产生费用、按日程运行,或者需要重新构建。每次 commit 都会将清单与磁盘上的文件进行比对。
我们写下了一条规则:新 guard 如果没有运行过 mutation,就算没写。
pre-commit hook 只检查注册清单,耗时 0.08 秒。完整运行需要几分钟,还依赖网络和数据库,也可能因为别人的服务故障而变红。这样的 hook,遇到第一次紧急修复时就会被加上 --no-verify 跳过;一个被绕过的 hook,比没有 hook 更糟。完整检查仍然由人工运行,而且合并前必须执行。
手写 mutation 的人和写 guard 的人是同一个,也带着同样的盲区。10 月 5 日,一个名为“链接区块只剩 7 篇文章”的 mutation,从 17 个链接中移除了 5 个,guard 仍然保持绿色。问题是 mutation 太弱,而不是 guard;现在,它会移除所有链接。除了有人注意到,没有什么机制能发现过弱的 mutation。
很多 guard 仍然读取源码文本。实际执行代码的 guard 更好,但我们的大量逻辑都在与 auth 和数据库绑定的路由处理函数里。仅仅为了测试就把这些逻辑抽出来,需要一次重构,而我们目前还没有足够的理由承担它。文本 guard 是否可信,取决于它们的 mutation 集是否足够真实有效。
我们没有 CI。guard 在合并前,在一台笔记本电脑上运行。
最后,回到最初的发现:70 个 guard 中有 44 个失效,这个数字并不能说明我们粗心。每个 guard 都是在遇到真实 bug 后,认真写下的。一个绿色检查结果就是一项声明;在有东西试着让它变红之前,这项声明始终未经验证。
我们在 AI Flip Room 开发这款工具。
如果还想采取进一步行动,你可以考虑屏蔽此人,或举报滥用行为。