我们如何保护新闻编辑部免受npm供应链攻击
我们很幸运地遇到了 Shai-Hulud 2.0。
2025 年 11 月,一个自我复制的 npm 蠕虫感染了 796 个软件包,每月下载量达 1.32 亿次。 该攻击利用预安装脚本窃取凭据、安装持久后门,并在某些情况下清除整个开发人员环境。 我们没有受到影响——不是因为我们有强大的防御措施,而是因为在攻击期间我们没有运行 npm install 或 npm update。
运气不是安全策略。
我们是谁
我是 Ryan Sobol,西雅图时报的首席软件工程师。 多年来,我们一直使用 npm 作为默认的包管理器,虽然也曾短暂地尝试过 Yarn,但始终没有流行起来。 现在我们正在试用 pnpm,专门用于其客户端安全控制,以补充 npm 一直在推出的注册表级改进。
对于新闻机构而言,信任至关重要,尤其是在当今时代。 供应链被攻破可能会暴露客户数据、员工凭证、生产基础设施和源代码——这些可能需要数周才能恢复,甚至需要向读者发出泄露通知。 我们明白这些事件会造成多么巨大的时间和金钱损失。 我们不想走上那条路。
尽管坚持 npm 存在组织惯性,但我们认为 pnpm 在这里确实有机会。 它的真的直接替换——相同的命令、相同的工作流程、相同的注册源。 这使得过度成为可能,而之前的替代方案则无法做到这一点。
这并非一份精心打磨的案例研究。 这是来自一个刚刚开始研究供应链安全的团队的真实数据点。 我们遇到的挑战以及我们思考这些控制措施的方式,或许对你自己实施这些措施有所帮助。
为什么客户端控制很重要
npm 在供应链安全方面取得了巨大进展。 可信发布、来源证明和细粒度访问令牌都是重大改进,使得在维护者帐户被攻破后发布恶意软件包变得更加困难。
但问题在于:这些注册表改进保护的是_发布_方面。 它们并不能阻止消费*恶意包。
当你运行 npm install 或 npm update 时,生命周期脚本(例如 preinstall 和 postinstall)会在软件包经过安全评估之前,以完整的开发者权限从互联网执行任意代码。 这些脚本可以访问您的凭证(npm、GitHub、AWS、数据库)、源代码、云基础设施和整个文件系统。
这是 Shai-Hulud 等攻击所利用的根本漏洞。 即使有了这些注册表改进,如果合法维护者的帐户被盗用,攻击者仍然可以发布一个带有恶意生命周期脚本的版本,这些脚本会在安装后立即执行——在社区检测到被盗用之前。
这就是为什么我们觉得需要在两方面都进行防御:npm 的改进使得发布恶意软件包更加困难;pnpm 的客户端控制使得使用恶意软件包更加困难。 这些方法是互补的,而不是竞争的。 pnpm 使用 npm 的注册表,并受益于 npm 的所有安全改进,同时在客户端增加了一层额外的保护。
这是深入的防御。
我们正在使用的三层控制
在我们的试点项目中,我们使用了三个协同工作的 pnpm 安全控制措施。 每个控制措施都针对不同的攻击途径,并且每个控制措施都为合法的例外情况提供了逃生通道。 我们知道,我们需要这些例外——真正的世界是混乱的。
控制 1:生命周期脚本管理
我们考虑 pnpm 的主要原因之一是了解到它默认情况下会阻止生命周期脚本。 与其他包管理器不同,它不会隐式地信任和执行包中的任意代码。
实际上,当一个软件包有 preinstall 或 postinstall 脚本时,pnpm 会阻止它们,但安装会继续进行并发出警告。 这已经提供了重要的保护——如果您不明确允许,恶意脚本将不会被执行。 但是,我们担心警告很容易被忽略,尤其是在安装似乎成功的情况下。 我们希望通过设置 strictDepBuilds: true 来获得更严格的控制:
strictDepBuilds: true
onlyBuiltDependencies:
- package-with-necessary-build-scripts
ignoredBuiltDependencies:
- package-with-unnecessary-build-scripts
所谓“必要的”,指的是那些真正需要其生命周期脚本才能运行的软件包——例如从源代码编译的本地扩展程序,或者链接到特定于平台的库的数据库驱动程序。脚本 所谓“不必要的”,指的是那些可选的优化脚本或设置步骤,它们不会影响软件包在我们的使用场景中是否能正常运行。
如果设置 strictDepBuilds: true,则安装程序在遇到生命周期脚本时会立即失败,迫使我们:
- 确定哪些软件包包含生命周期脚本——pnpm 会准确地告诉你哪些软件包包含生命周期脚本
- 研究每个脚本的功能,这可以很简单,只需将独立的预安装或后安装脚本输入到生成式人工智能中进行解读即可
- 运用人的判断力,做出有意识的、有记录的决定,决定是否允许或阻止它
对于我们的团队来说,这确保我们能够提前做出深思熟虑的选择,而不是在之后才发现问题。
注意: pnpm 团队正在考虑将 strictDepBuilds: true 设置为 v11 中的默认行为,并且正在根据在实践中实施这些控制的团队的反馈,探索更清晰的允许/拒绝语法命名。
控制 2:发布冷却
此控制阻止安装在冷却期内发布的软件包。 这样做的目的是给社区时间在恶意软件到达你的环境之前检测并清除它们。
minimumReleaseAge: <duration-in-minutes>
minimumReleaseAgeExclude:
- package-with-critical-hotfix@1.2.3
我们的思维转变: 我们必须重新训练自己,停止认为“最新的就是最好的”。 我们从供应链安全角度了解到,情况并非总是如此——稍微老一些的产品往往更安全。 一个软件包如果已经可用一段时间,就能给社区和安全研究人员时间来发现潜在的问题。
从最近的攻击事件来看,恶意软件包的检测和清除所需时间各不相同。 [2025年9月npm供应链攻击](https://www.wiz.io/blog/widespread-npm-supply-chain-attack-breaking-down-impact-scope-across-debug-chalk)被攻破的 debug 、chalk 及其他16个软件包在大约2.5小时内被移除,而 [Shai-Hulud 2.0](https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/)(2025年11月) 大约花了12个小时。 每次攻击的情况都不同,每次恢复时间也会有所不同,但适当的冷却期取决于你组织的风险承受能力——它可以以小时、天或周来衡量。 不管怎样,冷却期都会阻止这些袭击。
我们接受的折衷: 考虑到我们组织的规模和我们的优先事项,我们并非总是在最新的软件包上,尽管我们做出了最大的努力。 因此,这种冷却政策更符合我们的实际情况,而不是扰乱它。 当我们确实需要更新版本(关键安全补丁、重大漏洞)时,经过审查后,我们可以暂时豁免。
控制 3:信任策略
当软件包版本的身份验证强度低于先前发布的版本时,此控制措施会阻止安装——这通常表明攻击者窃取了维护者的凭据,并从他们自己的机器而不是官方的 CI/CD 管道发布了软件包。
trustPolicy: no-downgrade
trustPolicyExclude:
- package-that-migrated-cicd@1.2.3
工作原理: npm 会跟踪已发布软件包的三个信任级别(从最强到最弱):
- **可信发布者:**通过 GitHub Actions 发布,带有 OIDC 令牌和 npm 来源
- **来源:**来自 CI/CD 系统的签名证明
- **无信任凭据:**发布时使用用户名/密码或令牌进行身份验证
如果新版本的身份验证强度低于旧版本,则安装会失败。 例如,如果 v1.0.0 是通过 Trusted Publisher 发布的,而 v1.0.1 是通过基本身份验证发布的,则 pnpm 会阻止 v1.0.1。
在 2025 年 8 月的 s1ngularity 攻击 中,攻击者窃取了维护者的凭证,并从他们自己的机器上发布了恶意版本。 由于他们没有 CI/CD 访问权限,恶意版本没有来源信息——这明显降低了信任度。 这种控制会阻止安装。
以下情况可能合理地导致信任降级: 新的维护者尚未设置溯源,CI/CD 系统迁移,CI/CD 系统宕机期间手动发布了紧急热修复程序。 在这些情况下,我们会调查信任级别下降的原因,验证其安全性,然后将其添加到 trustPolicyExclude 中。
**注意:**此功能于 2025 年 11 月添加到 pnpm 中,相当新。 我们仍然在了解在实践中正当的信任下降的频率。
它们如何协同工作:React 示例
我们认为这些控制措施都不是万全之策。 它们就像多层防御——当我们不得不对其中一层控制措施做出例外处理时,其他层控制措施仍然会继续保护我们。
让我们来看一个真实的场景:2025 年 12 月披露的 严重 React 漏洞。
这是一个严重的安全问题,需要立即修复。 通常情况下,我们的版本发布冷却期会阻止我们安装最近发布的软件包版本。 但这是一个至关重要的安全补丁——我们不能再等了。
在这种情况下,多层防御机制将如何运作:
你做了什么: 在审查了脆弱性披露并核实补丁是合法的后,将特定的React版本添加到 minimumReleaseAgeExclude。
哪些因素仍然能保护你:
- 生命周期脚本管理仍然处于活动状态——如果攻击者将恶意生命周期脚本注入到 React 补丁中,这些脚本将被阻止(React 通常没有生命周期脚本,因此任何脚本都会立即引起怀疑)
- 信任策略仍然有效——如果攻击者窃取了 React 的发布凭据,并从其自身机器推送了恶意“补丁”,则信任降级将被阻止
这就是为什么我们认为例外情况是可以接受的。 你出于正当理由,有意识地、有记录地决定绕过一项控制措施,但你仍然受到其他层面的强大保护。 没有单一故障点。
这就是我们实际运用纵深防御的方式。
我们的试点体验
我们在我们的后端服务之一实施了所有三项安全控制,作为概念验证。 总设置时间:几小时研究、理解和定义我们的方法。
在设置过程中,pnpm 识别了三个带有生命周期脚本的软件包:
- esbuild: 优化 CLI 工具启动时间(以毫秒为单位)——由于我们仅使用 JavaScript API,因此不需要此功能
- @firebase/util: 自动配置客户端 SDK——由于我们只使用服务器 SDK,因此不需要此配置
- protobufjs: 检查版本模式兼容性——由于它是传递依赖项,因此不需要此检查
我们研究了每个脚本的功能(阅读文档并将脚本输入人工智能进行解释),确定没有一个脚本对我们的用例是必要的,因此我们阻止了它们。 对功能没有影响。
仅此而已。 只需几个小时的初始投入,即可持续抵御 Shai-Hulud 式的攻击。
摩擦感是怎样的: 这些控件的设计本身就带有摩擦感——对我们来说,这是一个特性,而不是一个缺陷。 这种摩擦迫使我们有意识地决定在我们的环境中运行哪些代码,而不是盲目地信任一切。 当新增依赖项包含脚本时,我们预计审查和记录该决定大约需要 15 分钟。
我们期望,在我们更熟悉这一进程时,摩擦将变得更加直觉。
我们正在学习什么
我们从我们的试点学到了一些东西:
纵深防御模型确实有效。 在客户端设置多层防御——再加上 npm 发布端改进带来的好处——意味着我们可以务实地处理例外情况。 当我们出于正当理由需要绕过某个控制措施时,其他控制措施仍然会保护我们。 这样就消除了做出例外处理的焦虑——它们不是安全漏洞,而是系统按设计运行的结果。
这种思维模式的形成需要时间。 从“便利性优先”转变为“安全优先”需要一个学习过程。 但一旦人们理解了这种心理模型——稍微旧一点的包更安全,明确的决定比隐性的信任更好——工作流程就会感觉很自然。
这些控制对中型团队是实用的。 我们不是一个拥有专职安全团队的大型科技公司。 我们是一个中等规模的新闻媒体组织,工程资源有限。 如果我们能够成功实施这些控制措施,那么大多数团队都可以使用这些措施。
我们仍在学习。 威胁正在演变,我们的方法也将会改变。 信任策略功能推出仅几周,我们还不知道在实践中合法的信任降级会发生多频繁。 我们计划在不久的将来将这些控制措施扩展到其他代码库,这将为我们提供更多关于它们如何随着具有不同依赖关系图的应用程序而扩展的数据。
对于其他正在考虑此方案的团队
如果你正在考虑使用 pnpm 的安全控制措施,以下是我们总结出的有效方法:
**从一个项目开始。**先在一个代码库上进行试点,可以让我们熟悉工作流程,了解痛点,并在考虑更广泛推广之前建立信心。
提前做好例外情况的规划。 要做好心理准备,你需要为生命周期脚本(需要编译的软件包)、发布冷却时间(关键安全补丁)和信任降级(CI/CD 迁移)设置例外情况。 这不是失败——这是系统设计的工作方式。
从一开始就使用 strictDepBuilds: true。 我们认为依赖警告风险太大。 我们希望安装立即失败,从而迫使他们做出决定。 这可以防止软件包在以后出现潜在的故障,并确保经过深思熟虑的选择。
记录所有例外情况。 写下你允许生命周期脚本运行或豁免某个软件包的原因。 这样可以创建审计跟踪,帮助未来的团队成员理解原因,并使以后清理异常情况变得容易。
相信多层防护。 当你为其中一个控件破例时,请记住其他两个控件仍然在保护你。 纵深防御模型给了你务实的空间。
分享您的体验
我们很想听听其他团队是如何实施或考虑实施这些控制措施的。 哪些方法有效? 挑战是什么? 你学到了什么? 加入 pnpm GitHub Discussions 的讨论,或在社交媒体上分享你的经验——我们都在共同学习。
谢谢
感谢 pnpm 团队构建了这些控件,并感谢他们以周到的方式使它们既强大又实用。 感谢邀请我们分享我们的故事。
你正在做的工作很重要。 这些控制措施提供了真正的保护,是对 npm 注册表改进的补充。 它们共同为像我们这样的团队提供了对抗日益复杂的供应链攻击的机会。
Ryan Sobol 是《西雅图时报》的首席软件工程师,他负责移动和 Web 开发、云基础设施和开发者工具。 本文表达的观点仅代表作者个人意见,并基于《西雅图时报》对 pnpm 安全控制措施的试点实施情况。