返回 2026-05-06
🔒 安全

软件包管理器的威胁建模Package Manager Threat Models

nesbitt.io·2026-05-05

本文探讨软件包管理器安全中非 CVE 相关的风险因素,提出应从攻击者视角分析依赖链中的潜在漏洞。作者强调,即使没有已知漏洞(CVE),恶意或配置错误的包仍可能导致供应链攻击。文章建议采用系统化的威胁建模方法评估 npm、PyPI 等平台的实际风险。

Andrew Nesbitt

前一篇博文列举了针对包管理器提交的漏洞:提取器中的路径遍历、git驱动中的参数注入、注册表中README渲染器的XSS攻击。这些漏洞可通过阅读代码、定位到具体行号并打补丁来发现。

本文是另一半内容。以下特性按设计正常工作,因此无人为其提交CVE。但几乎所有有名称的供应链事件中,它们恰恰是问题的真正来源——在event-stream、ua-parser-js、left-pad和xz等案例中,包管理器完全执行了其被设计的功能。

如果前文是一系列需要grep的模式,那么本文则是一组需要用自然语言回答的问题。逐项解答后,每节将输出几段文字,描述工具的实际行为,因为不同工具的答案差异很大,且大多数答案并未写明,仅存在于源码之中。

客户端

安装时执行代码

包管理器做出的最关键的设计决策之一,也是大多数事件记录所依赖的,就是安装时是否从包中运行代码:在用户机器上、以用户权限、访问其环境,甚至在用户查看任何内容之前。

多数语言的包管理器默认如此。npm运行postinstall,pip运行setup.py,Cargo编译并运行build.rs,gem运行本地扩展构建。该机制有其合理原因(无论如何都需要编译C部分),但也正是这一机制导致了event-stream、ua-parser-js、node-ipc、colors以及所有后续的安装脚本蠕虫。

列出所有生命周期钩子,哪些默认运行,是否对传递性依赖和直接依赖都运行,它们以什么身份运行,是否有关闭它们的标志,以及设置该标志后是否真的有效。同样地,也要考虑全局安装的情况——在某些平台上这意味着root权限——以及开发和可选依赖项,某些工具除非特别说明,否则会安装并运行它们的钩子。Go和Deno之所以有趣,正是因为它们选择了“什么都不运行”,并围绕这一约束构建了其余设计。

安装前执行代码

同一问题的较不明显版本。用户通常认为安装是危险命令,而lock、audit、outdated和metadata是安全的。《上一篇文章》中的CVE记录展示了这种模型如何意外地出错;本节则讨论它如何被设计得错误。

setup.py是一个Python程序,很长一段时间里从中获取版本号意味着运行它。build.gradle是一个Groovy程序,解析依赖图意味着求值它。TOML、JSON或受限制YAML子集这类数据格式的清单在此划出明确界限,而程序型清单格式则无法做到这一点。确定谨慎用户在未信任检出时可运行哪些命令;对几个工具而言,诚实答案是:一个都没有。

锁文件保证(by design)

前文讨论了锁文件漏洞:不应忽略锁文件却忽略的代码路径。其背后的设计问题是:锁文件究竟试图承诺什么?

锁定内容哈希值的 lockfile(如 go.sum、package-lock.json,以及 Cargo.lock 自 1.0 版本起)能确保你获取的字节与锁定的完全一致。其他 lockfile 仅锁定名称和版本号,并信任注册中心在相同版本下持续提供相同的字节(如 Gemfile.lock、经典版 yarn.lock)。此外,许多工具提供了两种安装命令:一种严格遵循 lockfile,另一种允许更新它——npm install 与 npm ci 就是人们最先接触的典型组合。

Go 的校验和数据库是目前最成熟的解决方案:它是一个公开的只追加日志,记录每个模块版本的哈希值,客户端默认会据此验证,因此无论是代理还是原始注册中心都无法事后更改某个版本解析出的内容。它独立于客户端和注册中心之外,这也是其设计引人注目的原因之一。

记录被锁定的内容、哪些命令会遵守该锁定,以及 CI 模板使用的是否为严格的那个。

包名身份识别

不同注册中心对两个包名何时被视为相等的规则各不相同,几乎所有注册中心都会对包名进行某种规范化处理:大小写、连字符/下划线/点号之间的转换,或 Unicode 宽度等。客户端曾多次与注册中心就具体应采用哪些规范产生分歧,而正是在这两类规范化之间存在“灰色地带”,使得一个包可能悄悄遮蔽另一个包。因此,必须明确记录客户端的规范化规则,并确认其与注册中心的规则完全一致——包括那些通过 lockfile 或传递性清单而非用户直接输入传入的包名。

多源解析

大多数客户端支持配置多个获取来源:例如一个公共注册中心加一个私有注册中心,或主源加镜像。2021 年发布的依赖混淆研究揭示了当同一名称存在于多个来源且解析器按版本而非来源选择时会发生什么:攻击者可在公共注册中心注册一个内部包名的高版本号,导致解析器优先选用它。pip 的 --extra-index-url 将所有索引视为同等地位的行为已被文档化,针对此漏洞提交的 CVE 也因这一机制本身存在争议而被驳回。

确定当某名称可从多个已配置来源满足时,解析器的行为是取所有来源中的最高版本、第一个拥有该名称的来源、显式逐依赖锁定,还是直接拒绝;同时判断是否允许为某一依赖添加的来源去满足其他依赖,以及 lockfile 是否会记录每个包实际来自哪个来源。

注册中心

命名空间分配

几乎每个公共注册中心都采用“先到先得”作为默认策略,这意味着名称的安全性依赖于最初注册该名称的人(比如 2011 年注册者)在 2026 年依然保持良好信誉。除非彻底破坏开放注册中心的核心价值,否则无法根本解决此问题,但边缘政策差异很大,影响也很深远。

查明名称是否可以转让、由谁决定;当所有者删除账户后该名称如何处理;是否允许他人重新注册已删除的名称,以及需要等待多久。最后一点即“复活劫持”攻击面——若注册中心允许立即重用释放出的名称,则任何曾愤怒退出的维护者所维护的包都将暴露于此风险之下。作用域命名空间(@scope/pkg)或组织前缀命名(group:artifact)能缩小攻击面,应特别留意此类机制的存在。

相邻表面是拼写劫持(typosquatting):名称与注册信息不同,但人类看起来相同。这已被证明适用于所有主要注册机构,设计问题是发布时的检查是否有用:与现有名称的相似性评分、保留前缀、阻止混淆的 Unicode,还是什么都不做。

维护者生命周期

xz 事件是参考案例:没有被黑,而是通过正常流程添加了新维护者,用户对原始作者的信任悄无声息地转移到了另一个人身上,而这个人有着不同的意图。包管理器无法解决社会工程攻击,但它确实决定了交接的可见程度。

明确如何向一个包添加维护者(邀请、请求、通过组织成员身份自动添加),当维护者集合发生变化时,现有用户是否会收到任何信号,新添加的维护者是否可以立即发布,或者是否有延迟,以及是否区分角色(发布、管理、只读)。大多数注册机构将所有维护者视为等同,并在添加时不通知任何人;也要记录下来。

维护者集合也可能在没有注册机构记录的情况下发生变化,因为在大多数注册机构中,维护者的身份本质上是由电子邮件地址控制的。密码重置会发送到档案中的任意地址,如果该地址所在的域名已失效,重新注册该域名就足以接管账户。弄清楚账户恢复依赖于什么:单一地址、第二因素、硬件密钥;该地址是否会被重新验证;以及一个长期休眠的账户是否仅凭重置链接就能发布新版本。

发布版本的不可变性

一旦 foo 1.2.3 被发布,字节内容就不应再改变。在大多数现代注册机构中确实如此,尽管其路径经过了 left-pad 事件,边界情况仍值得检查。

检查是否可以删除某个版本,如果可以,名称+版本是重新变为可用还是被标记为墓碑;“yank” 的含义是什么(是从解析中隐藏但仍可通过锁定文件安装,还是真的被移除);发布后是否存在一个窗口期,期间可以静默替换版本;CDN、API 和任何镜像之间的答案是否不同。允许重新发布已删除版本的注册机构,意味着仅靠版本号固定的锁定文件无法提供任何保证。

从源代码到制品的来源追踪

在包注册机构的历史大部分时间里,注册库中的 tarball 与它声称来源的仓库之间没有可验证的关联。清单中的 repository 字段是发布者输入的字符串。注册库中的 3.0.1 和 GitHub 上的 v3.0.1 标签仅靠约定相关联。

这种情况正在改变。可信发布(PyPI、RubyGems、crates.io、npm 等)将发布凭证绑定到特定的 CI 工作流,来源证明记录哪个提交和工作流产生了制品,客户端可以验证。注意注册机构是否支持其中任一机制,客户端是否展示这些信息,以及流行的包中有多少实际使用了它,因为对三个百分比的包采用可选证明,与强制使用相比,安全属性截然不同。

最小可行的发布凭证

发布令牌是攻击者从 CI 日志中窃取、从维护者处钓鱼获取,或在旧提交中找到的关键信息,因此其形式在注册表方面的重要性几乎超过其他任何因素。前文讨论了作用域控制存在缺陷的令牌;而本文关注的是这些作用域本身的存在与否。

梳理关键维度:作用域(仅一个包,或所有者可发布的所有内容)、权限(仅发布,还是也可添加维护者并更改设置)、有效期(强制、可选或无),以及发布时是否要求双因素认证(2FA),以及自动化令牌是否能绕过该要求,以及这种绕过能有多严格。再考虑泄露后的影响:令牌是否有可识别的前缀,且注册表已接入能自动撤销该令牌的机密扫描工具;还是说它是一个无标记的字符串,长期存在于公共提交中直至被使用。在一个凭证仅为会话等效 API 密钥且无有效期的注册表中,一条泄露的 CI 变量就等于永久拥有整个账户。而在另一个使用短期 OIDC 交换、仅针对单个包的令牌系统中,则只影响一次不良发布。

波及范围与检测

最后的问题不是“发生前”,而是“发生后”会怎样。如果维护者账户发布了恶意版本,它在被合理察觉之前能扩散多远?注册表又能为事件响应者提供哪些支持?

寻找发布时的异常检测机制(如新维护者、长期休眠的包、来自新国家/地区的版本发布);一种将已发布版本标记为恶意的方式,使客户端拒绝安装而非仅隐藏;一份记录谁从哪里发布了什么的审计日志;以及注册表通知下游用户他们安装的内容已被撤回的机制。这些措施无法阻止入侵,但“二十分钟内撤回并列出受影响安装列表”与“第三方三周后才发现”之间的区别,主要就在于这些机制是否存在。

工具的供应链自身

上述问题递归适用于工具自身的供应链。客户端是软件,通常依赖其所服务的生态系统的包:npm 是一个 npm 包,Bundler 是一个 gem,Cargo 是用 Cargo 构建的。注册表也是一个应用,其清单常会解析自身:rubygems.org 有 Gemfile,crates.io 有 Cargo.toml。任一树中的被攻陷包都会导致在其所运行的系统中执行代码——而这个系统正是整个生态系统必须信任的环境。

因此,上述问题同样适用于工具自身的清单文件。客户端和注册表的依赖如何处理:嵌入源码树(vendored)、通过提交锁文件按内容哈希固定(pinned),还是在构建时从实时注册表解析?pip 将一切放入 pip._vendor 以打破客户端循环。而注册表方面更关键的问题是:部署依赖树中的任何组件是否在运行时执行安装钩子,因为正是这些钩子让依赖项成为运行发布凭据的机器上的可执行代码。

一个以书面形式回答所有这些问题的项目,其威胁模型几乎等同于已发布的威胁模型。npm 的“威胁与缓解措施”页面和 OpenSSF 的《软件包仓库安全原则》已经涵盖了其中大部分内容——前者从维护者角度出发,后者则将注册中心作为运营商的成熟度模型进行规范。前文提到的 CVE 目录会随着漏洞的发现与修复不断增长,但这份列表基本不会更新,因此将答案明确写出来供用户查阅,比将其隐含在源代码中更为合理。

需要完整排版与评论请前往来源站点阅读。