强化 npm 依赖项安全性
2026 年 3 月 30 日,两个恶意版本的 axios 曾短暂发布到 npm。Axios 每周下载量超过 1 亿次。攻击者入侵了一名维护者的账户,并利用该账户发布了 [email protected] 和 [email protected],这两个版本都包含一个隐藏依赖,其 postinstall 钩子会在不引人注意的情况下安装跨平台 Remote Access Trojan(远程访问木马)。
这些恶意版本存在了大约三个小时后被移除,Microsoft 将此次攻击归因于朝鲜国家行为者。这是一次有针对性且准备充分的供应链行动,目标是生态系统中使用最广泛的软件包之一。
这很好地提醒我们重新审视自己究竟采取了哪些保护措施。
从显而易见的事情开始:使用 lockfile
这件事本来不言自明,但还是值得再说一遍。提交你的 lockfile(锁文件)。不要执行绕过它的安装操作。lockfile 意味着你安装的正是上次解析得到的版本,而不是今天随意拉取任何满足 semver range(semver 范围)的版本。它还意味着,供应链事件会以 diff 的形式出现,这样当传递依赖意外发生变化时,你就能看出来。
缩小依赖面
限制供应链攻击暴露面的最简单方法,就是减少依赖数量。每个不安装的软件包,都不可能被攻陷。
首先,审查未使用的依赖。Knip 会扫描你的代码库,找出那些虽列在 package.json 中、却已不再被任何地方导入的软件包。项目中的无效依赖会随着时间积累,而大多数团队并不会主动清理它们。定期运行 Knip,或将其纳入 CI 流水线,可以确保移除未使用的依赖。
其次,了解一下 e18e。这是一个专注于清理、现代化改造和提升 JavaScript 软件包性能的生态倡议。其工作的一部分,是用更轻量的现代替代方案取代庞大、过时的依赖,例如没有理由作为依赖存在的 is-odd 类软件包、如今已经可以使用原生功能替代的 lodash 函数,等等。依赖负担越小,需要操心的软件包就越少。
pnpm 设置
axios RAT 是通过安装软件包时会自动运行的 postinstall 钩子传递的。这是大多数 npm 供应链攻击背后的机制。
pnpm v10 默认会禁止依赖项中的 postinstall 脚本自动执行。你不再需要让任何提出请求的软件包运行构建脚本,而是要明确将确实需要这些脚本的软件包加入 allowlist(允许列表):
# pnpm-workspace.yaml
allowBuilds:
esbuild: true
"@parcel/watcher": true如果某个依赖之前不需要构建脚本,它就不会突然运行一个脚本。恶意版本的软件包无法利用 postinstall 钩子执行恶意代码,前提是该钩子从未被加入 allowlist。
你还应该启用一些 pnpm 设置,以进一步获得保护:
minimumReleaseAge: 10080 # 7 days in minutes
trustPolicy: no-downgrade
blockExoticSubdeps: trueminimumReleaseAge 会告诉 pnpm,拒绝安装发布时长少于指定分钟数的任何软件包版本。axios 攻击持续了三个小时。延迟一天(1440)就足以完全避开这次攻击。我们使用七天,与 Renovate 的稳定窗口一致。
使用 trustPolicy: no-downgrade 时,如果某个软件包之前发布时带有受信任 CI 流水线提供的来源证明,而新版本缺少该证据,pnpm 就会阻止安装。axios 攻击可以通过这种方式检测出来,因为恶意版本发布时没有合法版本中存在的受信任发布者绑定。
但有一点需要注意:当合法软件包的维护者不再提供来源证明时,no-downgrade 偶尔会产生误报。你可以使用 trustPolicyExclude,将已手动验证过的特定软件包排除在外:
trustPolicyExclude:
- "[email protected]"还要明确添加 blockExoticSubdeps: true。这会阻止任何传递依赖从 git 仓库或直接 tarball URL 解析,强制它们从 registry 获取。
限定内部软件包的作用域
如果你将内部软件包发布到私有 registry,请确保它们使用组织作用域(例如 @myorg/package-name),而不是无作用域名称。这样可以降低依赖混淆攻击的风险:攻击者可能发布一个名称与内部软件包相同或相近的公共软件包。如果 registry 配置发生回退,或者新环境被错误配置为优先从公共 registry 拉取,那么无作用域的内部软件包名称就是一个直接的目标。
使用 Renovate 自动升级
我们使用 Renovate 管理各个项目中的依赖更新。这里有两个设置会协同工作。
minimumReleaseAge(以前称为 stabilityDays)会延迟 Renovate 为新软件包版本创建 PR,直到该版本发布达到指定天数。我们将其设置为七天。这样可以给社区留出时间,在恶意版本进入我们的代码库之前发现它们;此外,它还能避免刚接入一个版本、两天后该版本就发布补丁所带来的变动。
安全更新 PR 完全绕过 minimum release age。如果 Renovate 检测到某个依赖存在已知漏洞,它会立即创建 PR,无论修复版本最近发布了多久。稳定性延迟不会减慢你对 CVE 的响应,只会减慢例行升级。
对于内部软件包,配置一个不设置稳定性延迟的独立软件包规则。你希望快速推出这些更新,以便及早发现集成问题。
{
"minimumReleaseAge": "7 days",
"packageRules": [
{
"matchPackagePrefixes": ["@myorg/"],
"minimumReleaseAge": "0 days"
}
]
}这些都不是万能药
这些设置的作用是争取时间,而不是自行检测任何问题。我们依赖安全研究人员、自动扫描平台和开源社区来识别遭到入侵的软件包,同时也依赖 npm registry 团队及时将其移除。稳定窗口只有在其他人发现问题时才会发挥作用,它不能替代你对所添加依赖和引入升级版本保持警惕。
随机一篇博客