NPM 供应链安全:依赖越多,审查越要前置

0阅读10分钟

一个 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注册与热门包名称相似的包名,利用开发者拼写错误1odashchalkkreact-dom 仿冒
Slopsquatting针对 AI 代码补全可能幻觉出的包名发布恶意包2025 年末多起针对 AI 推荐链的投毒
依赖混淆在公共注册表发布与内部包同名的更髙版本恶意包企业内网包名被抢注

OWASP 的 NPM 安全清单把这些风险按生命周期阶段划分:开发阶段的不安全文档示例和弱依赖管理,构建阶段的 token 泄露和缓存投毒,发布阶段的签名缺失和 2FA 未启用,以及安装阶段的脚本执行和 lockfile 篡改。

从攻击链的角度看,这些威胁可以归纳为四个核心问题:

  1. 版本不确定性^~ 范围导致每次安装可能拉到不同版本,lockfile 是唯一的确定性来源
  2. 脚本执行风险preinstallpostinstallprepare 等生命周期钩子可以执行任意命令
  3. 注册表信任边界:npm 注册表上的包不等于安全包,签名系统有明确的覆盖范围限制
  4. CI/CD 凭证暴露:OIDC Token、PAT、云凭证一旦暴露,攻击者可以以合法身份发布恶意包

案例一:Shai-Hulud 蠕虫如何通过缓存投毒盗取 OIDC Token

这个攻击链条很长,值得拆开看。

场景

攻击者瞄准了 TanStack 生态中一个使用 pull_request_target 触发器的 GitHub Actions 工作流。

翻车过程

  1. 攻击者 Fork 了目标仓库,开了一个 PR。pull_request_target 事件会以目标仓库的权限运行工作流,而不是 Fork 仓库的权限。
  2. 工作流中有一步恢复 pnpm 缓存的操作。攻击者在自己的 Fork PR 中,把恶意二进制注入了共享缓存。
  3. 维护者合并 PR 后,发布工作流执行,恢复了被投毒的缓存。
  4. 恶意二进制从 GitHub Actions Runner 进程内存中(通过读取 /proc/<pid>/mem)提取 OIDC Token。
  5. 拿到的 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 缺失 integritylockfile 中没有 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.com

npm 包的安全生命周期

从包的发布到安装,每个阶段都需要对应的安全控制:

流程图画布 · 115%
Mermaid 流程图加载中...

包管理器安全特性对比

不同包管理器在供应链安全方面的默认行为和可配置性差异明显:

特性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 LevelLevel 3Level 3Level 2
信任策略trustPolicy: no-downgrade
私有注册表作用域.npmrc 配置.npmrc 配置.yarnrc.yml 配置
异国情调子依赖拦截不支持blockExoticSubdeps不支持

CI/CD 流水线安全检查点

流程图画布 · 115%
Mermaid 流程图加载中...

依赖审计工具对比

工具类型漏洞库CI 集成SBOM 生成许可证检查
npm audit内置npm Inc.原生
pnpm audit内置npm Inc.原生
Socket.devSaaS自建 + 行为分析GitHub App
SnykSaaS + CLISnyk Intelligence原生
OWASP Dependency-Track自建NVD + 多源API
npqCLI多源手动
SocketCLISocket APICI script

代码示例:好/坏做法对比

1. 依赖安装命令

# 坏做法:默认安装,允许所有脚本执行,版本可能被范围解析漂移
npm install
 
# 好做法:禁用脚本,锁定版本,使用 CI 模式
npm ci --ignore-scripts
 
# 或 pnpm 用户
pnpm install --frozen-lockfile --ignore-scripts

2. 安装脚本白名单

// 坏做法:什么都不配,所有包的 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 public

6. 延迟安装策略

# 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
  • 检查包是否包含 postinstallpreinstallprepare 脚本
  • 对内部包使用作用域 + 私有注册表,防止依赖混淆
  • 依赖版本使用精确版本号,避免 ^~ 范围漂移
  • 新项目优先选择有 Sigstore 来源证明的包

CI/CD 构建阶段

  • 使用 npm cipnpm install --frozen-lockfile,禁止 CI 中修改 lockfile
  • 安装命令加 --ignore-scripts,通过白名单放开必要的原生模块编译
  • 配置 pnpm audit --audit-level=high 阻断高风险漏洞
  • 生成 SBOM 并归档,便于后续漏洞追踪
  • 验证关键依赖的 Sigstore 来源证明
  • 配置 trustPolicy: no-downgrademinimumReleaseAge
  • 隔离 PR 工作流与发布工作流的缓存和权限

发布与运维阶段

  • 使用 OIDC 可信发布替代长期 token
  • 发布时附加 --provenance 参数
  • 轮换旧 token,限制 CIDR 范围和过期时间
  • 启用 npm 账户 2FA(auth-and-writes 模式)
  • 监控 npm 账户异常活动(异常发布、新 token 创建)
  • 使用 Dependency-Track 等工具持续监控已生成 SBOM 中的新 CVE

应急响应

  • 制定供应链攻击应急预案,明确「发现恶意依赖后」的撤销、轮换、重建步骤
  • 凭证轮换必须从已知干净的环境执行,不要在可能受感染的机器上操作
  • 每季度执行一次依赖树审计,清理无用依赖和过期 token

参考资料

评论 0

0 / 1000