NPM 供应链安全:依赖越多,审查越要前置
一个 npm install 背后有多少信任假设
上周我在 review 同事的 PR 时发现,一次普通的依赖升级,lockfile diff 里多了 47 个新包。同事只改了 package.json 里的一行版本号,但这 47 个间接依赖就这么静默地进了项目。
这让我停下来想了一下:我们每次敲下 pnpm install 或者 npm ci,到底在信任什么?
信任注册表没有被劫持,信任包的维护者账号没有被钓鱼,信任 postinstall 脚本没有恶意行为,信任 lockfile 里的 hash 没有被篡改。任何一个环节出了问题,攻击面就从 node_modules 扩展到整个生产环境。
2025 年以来发生的一系列 npm 供应链攻击事件,让这些问题从理论风险变成了工程现实。
发生了什么:2025-2026 的供应链攻击态势
Shai-Hulud:第一个 npm 自我复制蠕虫
2025 年 9 月,一个叫 rxnt-authentication 的 npm 包被植入恶意代码。这个包不像传统的供应链攻击那样只是被动等受害者安装,它会窃取安装者的 CI/CD 凭证,用这些凭证去发布被感染的包新版本,形成自我传播。到 11 月的 Shai-Hulud 2.0 阶段,受感染的包已经超过 621 个。
Mini Shai-Hulud:带着合法签名的蠕虫
2026 年 5 月 10 日至 12 日,TeamPCP 组织发起了 Mini Shai-Hulud 攻击,影响 172 个包、400 多个恶意版本。目标包括 TanStack、Mistral AI SDK、UiPath 等知名项目。
最值得关注的一点:这次攻击产出的恶意包带有合法的 SLSA Build Level 3 来源证明。攻击者通过 GitHub Actions 缓存投毒拿到 OIDC Token,再用 Token 向 Sigstore Fulcio 换取签名证书,发布的恶意版本在签名校验层面完全合规。
Palo Alto Unit 42 在报告中指出,npm 生态在 2025 年 9 月达到了「临界拐点」,Shai-Hulud 是第一个具备自我复制能力的 npm 蠕虫。安全内参的报告显示,TeamPCP 组织在 2025-2026 年间污染了超过 600 个 npm 和 PyPI 包,影响超过 50 万台设备。
攻击手法分类
根据多份安全报告的归纳,npm 生态当前面临的攻击手法主要有以下几类:
| 攻击类型 | 原理 | 代表事件 |
|---|---|---|
| 维护者凭证窃取 | 钓鱼或泄露维护者 npm token,直接发布恶意版本 | 2025 年 9 月 debug、chalk 等 18 个包被劫持 |
| CI/CD 流程劫持 | 利用 pull_request_target 或缓存共享,在合法流水线中注入恶意代码 | Mini Shai-Hulud 攻击 TanStack |
| postinstall 脚本滥用 | 在安装钩子中执行恶意脚本,窃取凭证或植入后门 | Shai-Hulud 蠕虫的自我传播机制 |
| Typosquatting | 注册与热门包名称相似的包名,利用开发者拼写错误 | 1odash、chalkk、react-dom 仿冒 |
| Slopsquatting | 针对 AI 代码补全可能幻觉出的包名发布恶意包 | 2025 年末多起针对 AI 推荐链的投毒 |
| 依赖混淆 | 在公共注册表发布与内部包同名的更髙版本恶意包 | 企业内网包名被抢注 |
OWASP 的 NPM 安全清单把这些风险按生命周期阶段划分:开发阶段的不安全文档示例和弱依赖管理,构建阶段的 token 泄露和缓存投毒,发布阶段的签名缺失和 2FA 未启用,以及安装阶段的脚本执行和 lockfile 篡改。
从攻击链的角度看,这些威胁可以归纳为四个核心问题:
- 版本不确定性:
^和~范围导致每次安装可能拉到不同版本,lockfile 是唯一的确定性来源 - 脚本执行风险:
preinstall、postinstall、prepare等生命周期钩子可以执行任意命令 - 注册表信任边界:npm 注册表上的包不等于安全包,签名系统有明确的覆盖范围限制
- CI/CD 凭证暴露:OIDC Token、PAT、云凭证一旦暴露,攻击者可以以合法身份发布恶意包
案例一:Shai-Hulud 蠕虫如何通过缓存投毒盗取 OIDC Token
这个攻击链条很长,值得拆开看。
场景
攻击者瞄准了 TanStack 生态中一个使用 pull_request_target 触发器的 GitHub Actions 工作流。
翻车过程
- 攻击者 Fork 了目标仓库,开了一个 PR。
pull_request_target事件会以目标仓库的权限运行工作流,而不是 Fork 仓库的权限。 - 工作流中有一步恢复 pnpm 缓存的操作。攻击者在自己的 Fork PR 中,把恶意二进制注入了共享缓存。
- 维护者合并 PR 后,发布工作流执行,恢复了被投毒的缓存。
- 恶意二进制从 GitHub Actions Runner 进程内存中(通过读取
/proc/<pid>/mem)提取 OIDC Token。 - 拿到的 OIDC Token 被用来和 Sigstore Fulcio 交换签名证书,发布携带合法 SLSA 来源证明的恶意版本。
修复方案
问题的根源不是签名系统被破解了,而是 CI 环境的信任边界被穿透了。SLSA 来源证明只能验证「包确实从 TanStack 的 GitHub Actions 流水线发布」,不能验证「流水线执行的代码是维护者预期的代码」。
修复需要从 CI 环境隔离入手:
# 坏做法:PR 工作流和发布工作流共享缓存
# 攻击者可以通过 PR 投毒缓存,影响后续发布
# 好做法:发布工作流使用独立缓存,不复用 PR 缓存
name: Release
on:
push:
branches: [main]
# 关键:PR 工作流不要检出外部代码到特权上下文
# pull_request_target 不应搭配 actions/checkout 的 PR HEAD# CI 安装命令:禁用所有脚本
pnpm ci --ignore-scripts
# 限制工作流的 OIDC 权限,只在发布工作流开启
# .github/workflows/release.yml
permissions:
id-token: write # 只在需要签名的工作流开启
contents: read案例二:pnpm 11.4 修复的 6 个 lockfile 漏洞
pnpm 在 2026 年 6 月发布了 11.4 版本,一次性修复了 6 个供应链安全漏洞。这次集中修复暴露了之前版本中 lockfile 安全性的几个盲区。
场景
pnpm 用户日常使用 pnpm install 安装依赖,期望 lockfile 记录的 integrity hash 能保证包没有被篡改。
翻车过程
11.4 之前的版本存在以下问题:
| 漏洞 | 表现 | 风险 |
|---|---|---|
| Tarball hash 静默覆盖 | hash 不匹配时,pnpm 静默重新下载并覆盖 lockfile 中的 hash,而不是报错 | 被篡改的包可以通过重新安装「洗白」 |
| Lockfile 缺失 integrity | lockfile 中没有 integrity hash 时,pnpm 会从下载的 tarball 计算并写入 | 首次安装被篡改的包,hash 会被直接记录为「可信」 |
| 凭证作用域泄露 | 非作用域的 auth token 会被发送到任何注册表 | 注册表 URL 被劫持后,token 直接泄露 |
| Git commit 注入 | git 依赖的 commit 字段接受任意字符串 | 可以在 fetch 时注入命令 |
| Patch 路径穿越 | patch 文件可以引用 ../ 修改包目录外的文件 | 可以篡改项目中的其他文件 |
| 依赖别名穿越 | 别名中包含 ../ 路径 | 可以在 node_modules 外创建符号链接 |
修复方案
# 1. 升级 pnpm 到 11.4+
pnpm self-update
# 2. 在 .npmrc 中开启严格模式
# 坏做法:什么都不配,靠 pnpm 默认行为
# 默认行为在 11.4 之前是「静默容忍」
# 好做法:显式配置安全策略
echo "lockfile-check-integrity=true" >> .npmrc# .npmrc 安全配置
# 禁用全局安装脚本(需要时按包白名单放开)
ignore-scripts=true
# 阻止间接依赖从非常规源拉取
block-exotic-subdeps=true
# 新包延迟安装(默认 1440 分钟 = 24 小时)
minimum-release-age=1440
# 信任策略:不允许降级
trust-policy=no-downgrade// package.json:按白名单允许特定包的构建脚本
// 坏做法:完全放开 scripts
// { "pnpm": { "onlyBuiltDependenciesFile": "" } }
// 好做法:只允许已知需要编译的包
{
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"sharp",
"swc",
"node-sass"
]
}
}pnpm 11.4 的核心改变是把默认安全姿态从「fail open」切换到了「fail closed」:hash 不匹配直接报错(ERR_PNPM_TARBALL_INTEGRITY),不再静默重写 lockfile。如果你还在用更早的版本,升级本身就是最有效的修复。
案例三:Typosquatting 与 Slopsquatting —— 一行拼写错误的代价
场景
开发者在终端里敲 npm install lodahs,或者 AI 代码补全推荐了一个看起来很合理的包名。
翻车过程
Typosquatting 不是新攻击,但配合 AI 代码补全后,攻击面明显扩大了。攻击者在 npm 注册表发布与热门包高度近似的包名:1odash(数字 1 替代字母 l)、chalkk(多一个 k)、react-dom 的仿冒等。这些包通常带有恶意的 postinstall 脚本,安装即触发,窃取 SSH 密钥、云凭证或加密货币钱包。
Slopsquatting 是 2025 年出现的新变种:攻击者不是针对人类拼写错误,而是针对 AI 工具可能幻觉出来的包名提前注册恶意包。当开发者信任 AI 补全直接安装时,就会中招。
修复方案
# 坏做法:直接安装 AI 推荐的包
npm install utils-helper-lib
# 好做法:安装前先验证
npm view utils-helper-lib
# 检查清单:
# 1. 周下载量:合法热门包通常有数千或百万级下载
# 2. 发布历史:只有 1.0.0 且最近才创建的包要警惕
# 3. GitHub 仓库:是否有真实的代码、提交、贡献者
# 4. 维护者:是否有其他知名包的发布记录# 在 .npmrc 中禁用所有安装脚本作为兜底
# 即使误装了恶意包,postinstall 也不会执行
ignore-scripts=true# .npmrc:防止依赖混淆攻击
# 坏做法:内部包和公共包都走 npm 公共注册表
# @myorg/utils 可能被攻击者在公共注册表抢注更髙版本
# 好做法:作用域包指向私有注册表
@myorg:registry=https://npm.mycompany.comnpm 包的安全生命周期
从包的发布到安装,每个阶段都需要对应的安全控制:
包管理器安全特性对比
不同包管理器在供应链安全方面的默认行为和可配置性差异明显:
| 特性 | npm (2026) | pnpm (11.4+) | yarn (2026) |
|---|---|---|---|
| Lockfile 严格模式 | npm ci 命令 | --frozen-lockfile 默认开启 | --immutable |
| 安装脚本默认行为 | 默认执行 | 自动阻止,可白名单 | 默认执行 |
| 新包延迟安装 | 不支持 | minimumReleaseAge 内置 | 需插件 |
| Lockfile 完整性校验 | SRI hash 校验 | 11.4 起严格失败 | SRI hash 校验 |
| 来源证明(Provenance) | 支持 Sigstore | 支持 Sigstore | 支持 Sigstore |
| SLSA Build Level | Level 3 | Level 3 | Level 2 |
| 信任策略 | 无 | trustPolicy: no-downgrade | 无 |
| 私有注册表作用域 | .npmrc 配置 | .npmrc 配置 | .yarnrc.yml 配置 |
| 异国情调子依赖拦截 | 不支持 | blockExoticSubdeps | 不支持 |
CI/CD 流水线安全检查点
依赖审计工具对比
| 工具 | 类型 | 漏洞库 | CI 集成 | SBOM 生成 | 许可证检查 |
|---|---|---|---|---|---|
npm audit | 内置 | npm Inc. | 原生 | 否 | 否 |
pnpm audit | 内置 | npm Inc. | 原生 | 否 | 否 |
| Socket.dev | SaaS | 自建 + 行为分析 | GitHub App | 是 | 是 |
| Snyk | SaaS + CLI | Snyk Intelligence | 原生 | 是 | 是 |
| OWASP Dependency-Track | 自建 | NVD + 多源 | API | 是 | 是 |
| npq | CLI | 多源 | 手动 | 否 | 否 |
| Socket | CLI | Socket API | CI script | 是 | 否 |
代码示例:好/坏做法对比
1. 依赖安装命令
# 坏做法:默认安装,允许所有脚本执行,版本可能被范围解析漂移
npm install
# 好做法:禁用脚本,锁定版本,使用 CI 模式
npm ci --ignore-scripts
# 或 pnpm 用户
pnpm install --frozen-lockfile --ignore-scripts2. 安装脚本白名单
// 坏做法:什么都不配,所有包的 postinstall 都可以执行
// package.json 中没有 pnpm 配置
// 好做法:只允许已知需要编译原生模块的包
{
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"sharp",
"@swc/core"
]
}
}3. 依赖版本声明
// 坏做法:使用范围声明,每次 install 可能拉到不同版本
{
"dependencies": {
"lodash": "^4.17.21", // 4.17.21 ~ 4.18.0 都可能
"axios": "~1.7.0" // 1.7.x 都可能
}
}
// 好做法:精确锁定,lockfile diff 更干净,review 更有意义
{
"dependencies": {
"lodash": "4.17.21",
"axios": "1.7.9"
}
}4. CI 审计流程
# 坏做法:audit 只输出警告,不阻断流水线
npm audit
# 输出漏洞信息但 CI 继续跑,最终部署了有漏洞的依赖
# 好做法:高漏洞直接阻断,SBOM 归档用于后续追溯
pnpm audit --audit-level=high || exit 1
# 生成 SBOM(CycloneDX 格式)
npx @cyclonedx/cyclonedx-npm --output-file sbom.json
# 上传 SBOM 到 Dependency-Track 进行持续监控
curl -X POST "https://dtrack.example.com/api/v1/bom" \
-H "X-Api-Key: $DTRACK_API_KEY" \
-F "project=$PROJECT_UUID" \
-F "[email protected]"5. npm 发布配置
# 坏做法:使用长期 token,存在泄露风险
npm login
npm publish
# token 留在 CI 环境变量中,过期了也没人管
# 好做法:使用 OIDC 可信发布,自动短期凭证 + 来源证明
# 1. 在 npm 网站配置 Trusted Publisher(关联 GitHub repo + workflow)
# 2. 在 GitHub Actions 中发布# .github/workflows/release.yml
name: Release
on:
release:
types: [published]
permissions:
id-token: write # OIDC 签名
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: npm publish --provenance --access public6. 延迟安装策略
# pnpm-workspace.yaml
# 坏做法:新发布的包立即安装,没有时间暴露恶意行为
# 好做法:延迟 24 小时,让社区先踩坑
minimumReleaseAge: 1440 # 分钟,即 24 小时# npm 用户没有内置 minimumReleaseAge,可以在 CI 中检查
# 使用 package-json 获取发布时间
npm view <package-name> time --json | jq '.["1.2.3"]'
# 如果发布时间 < 24 小时,阻断安装安全检查清单
依赖引入阶段
- 新增包前用
npm view检查下载量、发布历史、维护者信息 - 核对包名拼写,警惕 typosquatting 和 slopsquatting
- 检查包是否包含
postinstall、preinstall、prepare脚本 - 对内部包使用作用域 + 私有注册表,防止依赖混淆
- 依赖版本使用精确版本号,避免
^和~范围漂移 - 新项目优先选择有 Sigstore 来源证明的包
CI/CD 构建阶段
- 使用
npm ci或pnpm install --frozen-lockfile,禁止 CI 中修改 lockfile - 安装命令加
--ignore-scripts,通过白名单放开必要的原生模块编译 - 配置
pnpm audit --audit-level=high阻断高风险漏洞 - 生成 SBOM 并归档,便于后续漏洞追踪
- 验证关键依赖的 Sigstore 来源证明
- 配置
trustPolicy: no-downgrade和minimumReleaseAge - 隔离 PR 工作流与发布工作流的缓存和权限
发布与运维阶段
- 使用 OIDC 可信发布替代长期 token
- 发布时附加
--provenance参数 - 轮换旧 token,限制 CIDR 范围和过期时间
- 启用 npm 账户 2FA(auth-and-writes 模式)
- 监控 npm 账户异常活动(异常发布、新 token 创建)
- 使用 Dependency-Track 等工具持续监控已生成 SBOM 中的新 CVE
应急响应
- 制定供应链攻击应急预案,明确「发现恶意依赖后」的撤销、轮换、重建步骤
- 凭证轮换必须从已知干净的环境执行,不要在可能受感染的机器上操作
- 每季度执行一次依赖树审计,清理无用依赖和过期 token
参考资料
- Unit 42: The npm Threat Landscape: Attack Surface and Mitigations (2026/06)
- CSA Labs: When Signed Provenance Certified a Supply Chain Worm (2026/05)
- pnpm: Mitigating Supply Chain Attacks
- OWASP: NPM Security Cheat Sheet
- Snyk: TanStack npm Packages Hit by Mini Shai-Hulud (2026/05)
- 安全内参: TeamPCP 组织 2025-2026 全球软件供应链攻击活动综合分析
- OpenAI: Our Response to the TanStack npm Supply Chain Attack (2026/05)
- SLSA: Bringing Improved Supply Chain Security to the Node.js Ecosystem
- GitHub Advisory: pnpm Lockfile Integrity Bypass (GHSA-7vhp-vf5g-r2fw)