TT Lab
开始
学习 学习路径 课程

构建是绿灯,可那个库是谁加进来的

我们只选了七个,装上的却有二十五个

在 TT Lab 中继续学习

一句话总结

依赖清单不在我们自己写的文件(package.json)里,而在记录了实际安装内容的 文件(package-lock.json)里,而这两者在数量上的差距, 几乎就是供应链安全之所以困难的全部原因。

为什么需要它

事故发生后的第二天早上,最先来的问题永远是同一个:“我们用了那个吗?” 这个问题之所以难,不是因为答案复杂,而是因为根本没有可以用来回答的资料。 仓库里有我们亲手写的依赖清单。可是,其中列出的每个名称, 又各自拉来别的名称,而那些名称又拉来另一些名称。五行变成二十五个, 不需要发生任何事件,只要安装一下,就会变成这样。

因此,“里面有什么”成了构建结束之后才需要重新追问的问题。 无论把源码读多少遍,都得不出答案。答案在构建的那一刻就已经确定,一旦过了那一刻, 就消失了。锁文件(lockfile)和 SBOM,正是为了抓住那一刻而诞生的。

还要再分清一件事。已安装的东西并不全都会被打进发布物。测试工具、 打包器、linter 只在构建机器上运行,不会到达用户。如果把这两者算在一起, “需要修复的东西”的数字就会膨胀一倍,而膨胀的数字没有人会去看。

工作原理

锁文件会把“下载了什么”固定下来。npm 的 package-lock.json 在 lockfileVersion 3 中使用名为 packages 的映射,以安装位置作为键,而根 项目则以空字符串作为键放进去。 每个条目都有 version、记录下载位置的 resolved,以及保证下载到的字节确实是那个东西的 integrity。 只属于开发依赖树的条目会带上 dev 为 true (npm 文档)。 统计数量时没有排除根条目,结果多出一个,这是这里最常见的错误。

SBOM 是把这份清单转换成可以在工具和组织之间流转的格式。有两种被广泛 使用的格式。CycloneDX 由 OWASP 和 Ecma 共同维护,已被标准化为 ECMA-424, 可以表达组件、服务,以及同时包含直接依赖和传递依赖的 依赖图 (CycloneDX 概览)。JSON 文档的 前两个字段是 bomFormat 和 specVersion,而 bomFormat 的值被固定为 "CycloneDX" 这一个,原因很有意思——BOM 文件没有命名规则,JSON schema 里也没有 命名空间,所以必须仅凭文件本身就能知道这是什么格式 (CycloneDX 1.6 JSON)。

SPDX 用另一套词汇做同样的事。它为每个软件包记录名称、版本、校验和,以及 purl 这样的外部引用,并用 DEPENDS_ON、CONTAINS 这样的 关系名称来表达元素之间的关系 (SPDX 2.3 Package Information)。 无论用哪一种,真正重要的部分都只有三个。对象是什么、里面有什么, 以及是什么把什么拉了进来。 缺少最后一个部分的 SBOM 只是一份扁平的清单, 回答不了“这个为什么在这里”。

在现场相遇的样子

最常见的场景是:SBOM 生成了,但没有人拿两份来比较。 每次发布都会积累文件,却没有人去看上一次发布与这一次发布之间的差异。 然而供应链事故的信号大多就在这个差异里——没有人添加过依赖, 清单里却多出了一个新名称。有人把间接依赖的版本范围设得很宽, 新版本发布时,它就顺带被拉了进来。

第二种场景是扫描结果膨胀,以至于被忽视。如果把开发依赖也一起算进去, 数字就翻了一倍,而翻了一倍的清单,开发团队是不会去读的。必须拆开发送, 才会有人读。

第三种场景是清单中没有写明对象。components 密密麻麻二十五行, 却哪里也没有写这份清单属于哪个制品。这样几个月后打开那个文件的人, 就无从确认“这是不是当时那次发布”。对象应当用 摘要而不是名称来记录——名称可能用同一个名字指向不同的内容,而哈希不可能。 后续模块中的签名和来源证明(provenance attestation),全部都建立在这个“对象”部分之上。

最后,什么时候生成清单,本身也是一种设计。通过读取源码生成的清单,与 扫描构建产物生成的清单是不同的。前者是“决定使用什么”,后者是“实际 发布出去了什么”。两者通常相近,但出现偏差的那一天,就是出事故的那一天。

下一项实验要做什么

从一个锁文件中亲手统计出清单,并以 CycloneDX 格式写下来。追踪没有人 挑选过的软件包是通过哪条路径进来的,并与上一次发布的 SBOM 比较,统计出 增加、减少和变化了什么。把工具一秒钟完成的事情亲手做一遍之后, 再看工具给出的清单,就能发现漏掉了什么。