一家公司 3845 个测试、94.22% 行覆盖率,经突变测试发现并发守卫、删除路径等关键路径未被真正保护。
Engrava 的测试套件有 3,845 个测试用例,94.22% 的行覆盖率。仓库里测试代码比生产代码还多。按我们手里的每一个数字来看,这个东西测得很好。
然后我们对其做了一次突变审计,发现这个套件并没有保护住:一个断裂的并发守卫、若干删除路径、五个配置段落、两个自己的安全测试,以及一个会在用户运行的版本中静默丢弃合法值的字段。这九项发现分为三组,组与组之间并不相同。算账如下。
这篇文章要讲的是这一切是怎么发生的,因为机制比单个 bug 更有意思,也因为我们本可以在前一天跟你说这套测试很扎实。
行覆盖率告诉你某一行在测试运行期间被执行了。它不告诉你如果这一行是错的,会不会有人注意到。
听起来差不多。其实不是。测试可以执行一个守卫,对结果做出某种断言,然后在守卫被整个删掉之后依然保持绿色,因为断言的内容是由路径中其他东西产生的。那一行全程都被覆盖了。它从来没被检验过。
发现这个问题的工具是突变测试:故意改变生产代码,运行套件,看有没有任何东西变红。存活下来的突变就是发现。不是你破坏的那段代码,而是让你的破坏溜过去的测试。
我们在 0.6.0 发布前对 Engrava 跑了这个。以下是它返回的结果,按真实情况分组,因为三组不是同一种东西,混在一起会在一个方向上美化我们、在另一个方向上吓到你。
六项发现是守卫,它们校验了一个值,然后却使用了调用方原始的对象而不是校验后的对象。校验了,然后丢弃校验的结果。
如果你写过校验器,立刻就能认出这种形状。你检查一个参数,检查通过了,然后下面的代码又去拿参数本身而不是检查产生的结果。如果值在这两次读取之间可能给出不同答案,检查和使用就说的不是同一个东西了。
这六个(这里有比发现本身更重要的部分)都只能由已经在同一进程内执行代码的调用方触发。不是通过配置文件,不是通过查询字符串,不是通过来自任何地方的数据。每一个都需要代码在我们的代码旁边运行,故意向库传递一个行为不一致的东西。
我们对照已发布的版本本身做了核实,而不是对照我们自己对我们修复内容的描述——那是不同的声明。六个的结果都一样:它们在 PyPI 上任何版本都没有跨越信任边界。不需要公告,不需要 CVE,不需要给 0.5 分支打补丁。
我们最终采用的表述是这样的(这是诚实的那个):这是边界的缺失,不是在边界上戳了个洞。在你的进程里执行任意代码的人可以替换这些守卫保护的那个函数,或者它下面的标准库调用。为这种特定的调用方辩护不是库能做到的事,写一个断言说我们做了这件事也只是表演。
那为什么要加固它们?因为纵深防御在成本低的时候值得有,因为这个机制可以泛化到调用方信任度更低的地方,而且因为一个能被说服推翻自己结论的守卫在自身逻辑上就是错的,即使当前没有什么能利用它。六个我们都修了。我们不会用暗示你曾暴露过的语言来描述它们,因为你没有。
这是最让我们不安的一组。
卫生套件(决定哪些内存被归档并最终删除的代码路径)中的两个测试,越过了正确运行的守卫。它们没有在检测守卫。它们检测的是路径中恰好产生相同结果的其他东西。
其中一个有一个文档字符串,用大白话说明某个 pin 是导致该行被跳过原因。那不是真的。一个完全不同的条件在做跳过这件事,而且你可以在全部三层上移除 pin 保护,看着测试保持绿色。
没有人草率地写这些测试。写它们的人理解这个功能,读它们的人也理解。读测试告诉你的是作者相信什么。它不告诉你测试能检测什么。只有对声称要保护的代码做突变才能告诉你,而在此之前我们俩都没想到要这么做,直到一次自动扫描替我们机械地做了这件事。
有一个相关发现把这个问题说得更尖锐。在代码库的别处,直接删除一个保证会让 4,316 个测试保持绿色:整个套件,不是某个狭窄的切片。绿色套件不是保证成立的证据。它是什么都没注意到保证消失了。
以上内容没有改变普通用户的数据:第一组需要代码已经运行在你的进程内部,第二组是关于我们的测试而不是关于已发布的行为。这个不一样,这是这篇文章以现在这种形式存在的原因。
Engrava 的边携带一个 decay_multiplier,一个文档说明从 0.0 往上都合法的浮点数。设为 0.0 是合法的:意味着这条边不通过这个机制衰减。
在已发布的 0.5.x 中,从数据库读回那个值的代码对真值性做了检测。0.0 是假值。所以存储的 0.0 读回来变成了 1.0,即默认值。
仅仅这样已经是一个坏但可恢复的读 bug。在写出的时候更糟:更新路径从刚读到的值重建整条边记录,然后把每一列写回去。所以下一次任何东西更新那条边(因为无关的原因:改权重、碰元数据),被误读的 1.0 会覆盖你的 0.0。永久地。没有错误,没有警告,没有日志行。
没有攻击者。没有异常配置。对公开的、有文档的 API 的一般正确使用,静默丢弃了文档说明合法的值。
0.6.0 中已修复。如果你在任何 0.5.x 版本上设置了 decay_multiplier=0.0,检查那些边,并注意升级不会恢复已经被覆盖的值。0.5 的数据库没有记录那个数字曾经是什么。
我们把这件事说得很清楚,因为一篇关于我们自身测试缺口、却悄悄漏掉了那个可能让人丢失数据的唯一缺陷的文章,还比不上不写它。
六个纵深防御加固,全部需要代码已经运行在进程中。两个对正确守卫的空测。一个真实的数据丢失缺陷。
这九个和开头列出的是同一组:并发守卫、删除路径和配置段落是那六个中的几个;卫生安全测试是那两个;可能丢失数据的字段是那一个。我们把算术写出来是因为这篇文章的简单版本会把九项都用安全语言描述,而那会比任何一个单独发现都更错误:其中六个无法被任何没有在你的进程里运行代码的人触及,其中两个是关于我们的测试而不是关于任何已发布的东西。
套件从 3,845 个测试变为 4,316 个;覆盖率从 94.22% 升到大约 95%。覆盖率这个移动是页面上最无趣的数字,这就是论点自己证明自己的地方。
重要的改变是结构性的。守卫现在使用校验产生的值。几个手维护的列表——本应列举受保护操作的——现在从代码派生而不是打出来然后指望没打错。两个空测现在能够失败了。而且我们现在要求:对于一个关于测试的声明,要有人展示信号在坏版本上变红,而不是套件在修复版本上保持绿色。
我们没做的事:替换并发机制。审计破坏的那个特定守卫已修复,但它下面的更大设计问题(两个进程写入同一文件如何协调)需要一次 schema 变更,在发布前几天把它合入版本是错误的交易。它被推迟并写下来标为推迟,而不是为了这篇帖子能有个更整洁的结尾而仓促合入。
你不需要突变测试框架就能开始。挑一个你相信被测过的守卫:授权检查、校验、删除安全条件。删掉它。跑测试。
如果它们保持绿色,你就学会了关于你套件的一件事,这是任何覆盖率报告都永远不会告诉你的。这就是整个技术。工具只是让它变得系统化。
我们用这种方式在一个代码库里发现了六个加固、两个什么都证明不了的测试和一个真正的 bug,而这个代码库我们在前一天还会说是测试得很充分的。我们认为这说明的不是 Engrava 怎么样,而是一个覆盖率数字能承诺什么。
pip install engrava
Repo: github.com/sovantica/engrava
Upgrade notes: github.com/sovantica/engrava/blob/v0.6.0/docs/upgrade.md