我们只选了七个,装上的却有二十五个
一句话总结
依赖清单不在我们自己写的文件(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 比较,统计出 增加、减少和变化了什么。把工具一秒钟完成的事情亲手做一遍之后, 再看工具给出的清单,就能发现漏掉了什么。