系列: 与 AI 一起学编程

那次弄坏了 300 个文件的查找替换

一次深度审计发现了什么,以及它被修复的速度有多快。

两个发现,一次审计 当一次扫描匹配到了不该匹配的东西 300 个文件被弄坏 process_event→ 被弄乱 on_success()→ 被弄乱 has_access→ 被弄乱 没有按单词边界限定范围 编译通过,解析正常 修复方法:从 git 历史中恢复 硬编码密钥 secret = "●●●●●●●●●●●" 看起来像个占位符 变成了一个真实的值 修复方法:轮换 + 环境变量 修复方法:在 CI 中做密钥扫描
损害发生在代码的人类可读层,那是编译器从来不会去看的地方。

智能体驱动的代码库跑得很快——也会以一种人类审查者一眼就能看穿的方式失败。这里公开讲两个失败案例,以及现在能把它们彻底拦下的护栏。

你去找麻烦的那天,通常就是你找到麻烦的那天。这个周六我安排了一次针对代码库的深度审计。计划是清理一下技术债:裸露的 except 块,硬编码常量,约定上的漂移,任何一个高速运转的项目都会积累的那种堆积物。

我找到了两件我并不乐意看到的事。两者现在都已经修复。两者都是我想公开谈论的那种发现,因为它们真实地解释了一个智能体驱动的代码库是如何失败的,以及做审计这件事的纪律是如何抓住这些失败的。

发现一:那次吞噬了代码库的查找替换

在项目早期,有一个智能体被要求在文档字符串和注释中做一次营销风格的扫描。意图是合理的:重命名一个出现在少数几个面向公众的字符串中的特定短语。但执行时对单词边界不够小心。

智能体本应替换的短语,是一个特定的营销标语,它作为更长短语的一部分,包含了”process”、”success”和”access”这几个单词。这次查找替换操作,在这些子串不该被匹配到的地方也匹配到了它们。变量名。函数名。测试描述。行内注释。凡是这三个子串出现的地方,智能体的替换字符串都被替换进去了。

结果是三百个文件里出现了微妙地被弄乱的标识符和文字。名为 process_event 的变量变成了在词元中间嵌入了”Raku Game Engine Milestone”的某种东西。函数描述读起来像胡言乱语。测试描述声称在测试一些根本不存在的东西。代码库能编译通过,因为这些被弄坏的标识符在各自的文件内是一致的,但代码库的人类可读层在各处都遭到了微妙的破坏。

我想具体讲讲这种失败是怎么发生的,因为这是一类其他团队也会遇到的智能体驱动的失败。

查找的范围限定得太宽了。 智能体被指示去查找一个短语并替换它。这个短语恰好是常见英语单词的一个子串。给这次查找限定范围的正确方式,是按单词边界(正则表达式里的 \bword\b),明确大小写敏感性,明确文件扩展名的允许列表,明确标识符上下文的禁止列表。智能体收到的指令里,这些约束一个都没有。

智能体没有标记出这个范围之广。 三百个文件是很多文件。一个为了一次小小的营销文案调整就落地了一个触及三百个文件的 PR 的智能体,本应在开 PR 的时候就标记出这个范围之广。这个智能体没有这样做。PR 标题写的大概是”更新文档字符串中的营销文案”之类。PR 正文只是把文件数量列成了一个数字,而不是一个值得关注的问题。

我的审查流程没有抓住它。 这个 PR 的 diff 是三百个文件里各自两行的小改动,看起来都像是同一种编辑。粗略浏览这个 diff,读起来就像是一次干净的扫描。只有当你去读某个文件在智能体的替换产生了胡言乱语的那一刻的实际改动内容时,腐坏才会显现出来。我没有这么做。我合并了。

CI 没有抓住它,因为这些名字仍然能被解析。 被弄坏的标识符在语法上是有效的。编译器不在乎你的变量名字听起来像不像一句营销标语。构建是绿色的。测试照常运行。损害发生在代码的人类层,而不是机器层。

我这个周六是怎么修复它的

一个脚本。这个脚本做三件事。

第一:重新推导出规范的标识符名称。 从这次糟糕的查找替换落地之前的 git 历史中,这个脚本重建出每个标识符本应被叫做什么。这个重建过程是机械化的:对每一个被这个糟糕的 PR 触及的文件,把 PR 之前的版本和 PR 之后的版本做 diff,对每一个被替换的词元,提出恢复到 PR 之前名称的建议。大多数文件都能干净地恢复。少数需要人工审查,因为它们在腐坏之上还叠加了合法的改动。

第二:一次由 grep 驱动的健全性检查。 即使在恢复之后,一些被弄坏的标识符也已经被那次糟糕的 PR 落地之后写的新代码引用了。那些引用是针对被弄坏的名字写的。这次 grep 检查会找出所有在这次糟糕的 PR 落地之后写的代码中对某个被弄坏风格标识符的引用,并把每一处标记出来供人工判断:这段新代码是有意使用被弄坏的名字(罕见),还是只是使用了当时恰好存在的任何名字(大多数情况)?

第三:为未来加一道防护。 现在智能体做的每一次查找替换操作都必须指明:(a)按单词边界限定范围,(b)大小写敏感性,(c)文件扩展名的允许列表,(d)一个最大文件数阈值,超过这个阈值智能体就必须标记出来并请求明确的审查,以及(e)在应用完整替换之前,智能体必须展示三个随机匹配的样本。这个防护现在写在了 Copilot 指南里,并且已经成为每一次查找替换任务框架的一部分。

腐坏现在已经修复了。做修复的这个审计脚本已经在仓库里,随时可以运行,diff 输出也保存下来作为证据。教训写在了 Copilot 指南里。

发现二:硬编码的 HMAC 密钥

这次深度审计还揭露出一件我本该更早发现的事。运行时的许可层使用 HMAC-SHA-256 来验证许可令牌。这个 HMAC 密钥被硬编码在一个源文件里。这个源文件在公开仓库里。这个密钥是一个真实的密钥,被一条真实的生产验证路径使用着。

这是今天最让人尴尬的发现。我想对此保持诚实,因为这正是在快速运转的智能体驱动代码库中会发生的那种事情,而公开讨论如何抓住它,比私下讨论更有价值。

它落地的路径: 许可层的一个早期版本是用一个占位密钥值原型化的,原本打算在这一层发布给任何人之前被替换掉。这个原型是以一个明显看起来是开发用占位符的形式落地的。随着时间推移,真实的验证逻辑被叠加在这个占位符之上。一旦它被包裹在看起来真实的验证代码里,这个占位符就不再看起来像一个占位符了。等到有人注意到时,这个密钥已经在生产风格的流程中被使用,而这个文件也已经在公开仓库里了。

我今天做了什么:

  • 轮换了这个密钥。被泄露的值不再是生产值。新的值放在一个环境变量里,并为开发环境提供一个 warnings.warn() 兜底,让开发工作可以在没有真实密钥的情况下继续,但会大声地提醒这件事。
  • 从源文件中移除了硬编码的值。替代方案是一个 getenv,如果在生产构建中这个环境变量未设置,会给出一条清楚的错误信息。
  • 加入了一个 CI 检查,扫描匹配常见模式的硬编码密钥(高熵字符串、base64 形状的令牌、任何看起来像密钥的东西)。这个检查是那种能在下一次尝试落地之前就抓住它的小型基础设施。
  • 提交了一个后续任务,审计代码库其余部分是否有类似的模式。这个审计是另一个周末的工作。今天的重点是把这个当下的发现处理掉。

许可层仍然能正常工作。新的路径更安全。被泄露的密钥在被发现的几小时内就被轮换了。

这件事能推广出什么

几个诚实的观点。

智能体驱动的查找替换需要明确的范围限定规则。 这是项目历史上第三次一次范围过宽的扫描咬了我一口。前两次的损害没这么大。这一次严重到值得设立一道永久性的护栏。这道护栏现在已经就位了。

源文件中的硬编码密钥是一种纪律上的失败,而不是工具上的失败。 没有任何工具能拯救一个允许真实密钥落进公开文件里的团队。”每一次提交都要审查有没有硬编码凭据”这条纪律,才是真正的修复。CI 扫描有帮助。但纪律才是关键。

审计能找到审查漏掉的东西。 定期对代码库进行一次审计,专门去找那种逐个 PR 审查往往会漏掉的失败模式,这种纪律是值得花时间的。今天的审计抓住了两件被 PR 审查放过去的事情。未来的审计会抓住其他的事情。定期进行才是关键。

合作伙伴和开发者应该从中获得什么

如果你正在为合作评估一个引擎,去问问那个团队他们是如何处理”智能体驱动的范围过宽扫描”这种失败模式的。正确的答案会涉及明确的范围限定规则、对大规模改动的强制标记,以及审计流程。错误的答案是”我们还没见过那个问题”。

如果你自己在运行一个智能体驱动的工作流,而你最近没有做过硬编码密钥审计,做一次吧。有东西悄悄溜进去的概率不是零。现在发现它的成本很小。

如果你是一位读到这篇文章的安全专业人士,并且有建议,我是真心感兴趣。我正在努力防御的这类失败是”智能体做了一件人类审查者一眼就能看穿、但在智能体驱动工作流所鼓励的批量审查模式下却没能抓住的事情”。欢迎提出建议。

周六下午。代码库经历了一次严格的审视。两个发现,都已修复。下一次审计已经排上了日程。

回去继续构建。

一个为了被审计而构建的运行时

RakuAI 是 LLM 制造商和智能眼镜制造商用来构建产品的空间运行时——由审计来约束,由公开的教训来加固。看看我们是如何为合作伙伴级别的信任而进行工程设计的。

← 所有文章