Hardening npm dependency security

Alex O'Callaghan

加固 npm 依赖安全

原文由 Alex O'Callaghan 发布,订阅该博客

2026 年 3 月 30 日,两个恶意的 axios 版本曾短暂被发布到 npm。Axios 每周下载量超过 1 亿次。攻击者入侵了一位维护者的账号,并利用该账号发布了 [email protected][email protected],两者都包含一个隐藏依赖,其 postinstall 钩子会静默安装跨平台的远程访问木马。

恶意版本上线约三小时后被下架,微软将此次攻击归因于朝鲜国家行为体。这是一次针对生态系统中最常用软件包之一的、有预谋的供应链攻击。

这件事也提醒我们,是时候审视一下自己究竟采取了哪些防护措施了。

从最基础的做起:使用 lockfile

这点本无需多言,但还是值得再强调一次。请提交你的 lockfile,不要执行会绕过它的安装操作。有了 lockfile,你安装的就是上次解析锁定的确切版本,而不是今天满足 semver 范围的任意版本。这也意味着供应链事件会以 diff 的形式显现,让你能及时发现传递依赖的意外变更。

缩小依赖面

限制供应链攻击暴露面最简单的方法就是减少依赖。每一个你没有安装的包,都是一个不可能被攻破的包。

首先,清理未使用的依赖。Knip 会扫描你的代码库,找出在 package.json 中声明但已不再被任何地方引入的包。项目随时间推移会积累无效依赖,而大多数团队不会主动清理。定期运行 Knip,或将其纳入 CI 流程,就能确保及时移除无用依赖。

其次,可以关注 e18e,这是一个致力于清理、现代化和提升 JavaScript 包性能的生态计划。其工作内容之一就是用更轻量的现代方案替换笨重、过时的依赖,例如本就不该作为独立依赖存在的 is-odd 这类包,以及如今已原生支持的 lodash 函数等。依赖越轻,需要担心的问题就越少。

pnpm 配置

这次的 axios 远程木马正是通过安装时自动执行的 postinstall 钩子投递的。这也是绝大多数 npm 供应链攻击所利用的机制。

pnpm v10 默认会禁用依赖中 postinstall 脚本的自动执行。它不会为所有提出请求的包都运行构建脚本,而是需要你显式地将确有需要的包加入白名单:

# pnpm-workspace.yaml
allowBuilds:
  esbuild: true
  "@parcel/watcher": true

如果某个依赖之前不需要构建脚本,那么它也不会突然去执行。即使某个包的被篡改版本试图利用 postinstall 钩子执行恶意代码,只要该钩子从未被加入白名单,就无法得逞。

你还应该开启以下几项 pnpm 配置来加强防护:

minimumReleaseAge: 10080 # 7 days in minutes
trustPolicy: no-downgrade
blockExoticSubdeps: true

minimumReleaseAge 会让 pnpm 拒绝安装发布时间短于指定分钟数的包版本。此次 axios 攻击仅存活了三小时,哪怕只设置一天的延迟(1440),也足以完全避开。我们设置为七天,与 Renovate 的稳定性窗口保持一致。

启用 trustPolicy: no-downgrade 后,如果某个包此前是通过可信 CI 流水线的 provenance 证明发布的,而新版本却缺少这一证明,pnpm 就会阻止安装。此次 axios 攻击就能通过这种方式被识别出来,因为恶意版本在发布时并未携带正规版本所具有的可信发布者绑定。

需要注意的是,当正规包的维护者不再提供 provenance 证明时,no-downgrade 偶尔也会产生误报。对于已手动验证过的包,你可以用 trustPolicyExclude 将其排除:

trustPolicyExclude:
  - "[email protected]"

另外请显式加上 blockExoticSubdeps: true。它可以防止任何传递依赖通过 Git 仓库或直接的 tarball 链接来解析,强制所有依赖都必须来自 registry。

为内部包添加作用域

如果你向私有 registry 发布内部包,请确保它们位于组织作用域下(例如 @myorg/package-name),而不是使用无作用域的名称。这能降低依赖混淆攻击的风险——攻击者在公共仓库发布与你内部包同名或相似的包。一旦 registry 配置回退,或新环境被错误配置为优先从公共 registry 拉取,无作用域的内部包名就会成为显而易见的攻击目标。

使用 Renovate 自动升级

我们在项目中使用 Renovate 来管理依赖更新。这里有两项配置需要配合使用。

minimumReleaseAge(原名为 stabilityDays)会让 Renovate 在新版本发布若干天后才为其创建 PR。我们设置为七天。这样可以让社区有时间在恶意版本进入我们的代码库之前将其捕获,同时也能避免刚升级完两天后又要打补丁的频繁变动。

安全更新的 PR 会完全绕过 minimumReleaseAge 限制。如果 Renovate 在依赖中检测到已知漏洞,无论修复版本发布了多久,都会立即创建 PR。稳定性延迟不会拖慢你对 CVE 的响应速度,它只是放缓了常规的版本升级。

对于内部包,则应配置一条独立的包规则,取消稳定性延迟。这样可以尽快发布,便于及早发现集成问题。

{
  "minimumReleaseAge": "7 days",
  "packageRules": [
    {
      "matchPackagePrefixes": ["@myorg/"],
      "minimumReleaseAge": "0 days"
    }
  ]
}

没有银弹

这些配置的作用在于争取时间,而非自行检测问题。我们依然要依靠安全研究人员、自动化扫描平台和开源社区来发现被投毒的包,并依赖 npm 官方团队及时下架它们。如果没有他人发现问题,稳定性窗口就无法发挥作用,它也无法替代你在引入依赖和拉取升级时保持的警惕。

本文章由 muse-spark-1.2-contributor 进行翻译

评论