npm v12 供应链安全大革命:安装脚本默认禁用,Node.js 生态的信任危机与重构
引言
2026 年,npm 正式发布 v12。这是一个看似只是版本号递增的小更新,却在 Node.js 社区引发了地震级的讨论——npm v12 将默认停止运行任何包的安装脚本(preinstall / install / postinstall)。这意味着全球数千万开发者每天执行的 npm install,将从「自动执行一切」变为「先问我同不同意」。
这不只是个安全补丁。这是 npm 有史以来最激进的行为变更,也是整个 JavaScript 生态在供应链安全压力下被迫做出的最深刻反思。
本文将系统梳理这一变革的背景、机制、影响与应对策略,带你真正理解 npm v12 在做什么、为什么这样做,以及你和你的团队该如何应对。
一、血的教训:npm 供应链攻击编年史
理解 npm v12 的激进改革,必须先回望过去六年间 npm 生态经历的一系列触目惊心的安全事件。
1.1 经典案例:event-stream 事件(2018)
event-stream 是 Node.js 生态中一个处理流数据的核心工具库,周下载量超过百万次。2018 年,一位攻击者伪装成"善意贡献者",向原始维护者提出了一个"帮助维护"的请求。获得维护权限后,他在 event-stream@3.3.6 版本中植入了恶意代码——一个专门针对 Copay 比特币钱包的后门程序。
当用户安装受污染的 event-stream 并使用 Copay 钱包时,恶意代码会在后台运行,将用户的私钥和种子短语悄悄发送到攻击者控制的服务器。据估计,数千名加密货币用户的资产受到威胁。
这次攻击的精妙之处在于:它利用了 npm 生态最脆弱的信任链条——维护者的善意转让被当作安全护城河。npm 从未验证"新维护者"是否可信。
1.2 大规模钓鱼:2025 年 9 月事件
2025 年 9 月,npm 生态遭遇了有史以来最严重的一次供应链攻击。黑客通过伪装成官方通知的钓鱼邮件,成功入侵了多名知名开发者的账户。随后,攻击者在至少 18 个高频下载的核心软件包中植入了恶意代码。
根据 BleepingComputer 的报道,这些包的周下载量合计超过 26 亿次,几乎覆盖了 npm 主流生态的半壁江山。攻击者在恶意代码中加入了凭证窃取逻辑,当开发者执行 npm install 时,恶意脚本会自动扫描系统中的:
- GitHub Token
- npm Token
- 云服务 API 密钥
- SSH 私钥
- Kubernetes 配置文件
这些敏感信息随后被发送到攻击者控制的 C2 服务器。
1.3 Shai-Hulud 蠕虫系列(2025-2026)
Shai-Hulud 是一系列针对 npm 生态的自传播蠕虫式供应链攻击,命名灵感来自科幻小说《沙丘》中的沙漠蠕虫。慢雾科技于 2025 年 12 月发布了关于其 3.0 变种的安全预警。
Shai-Hulud 的攻击手法极为高明:攻陷一个维护者账户后,蠕虫会自动窃取该账户下所有包的访问权限,并以受害者身份发布恶意版本。由于 npm 的自动更新机制,全球开发者在下一次 npm update 时就会不知不觉地更新到被污染的版本。更可怕的是,Shai-Hulud 还具备横向传播能力——感染一个包后,会自动扫描并攻陷更多维护者账户。
1.4 2026 年 5 月大范围投毒事件
2026 年 5 月,国家网络与信息安全信息通报中心发布紧急通报:npm 平台遭"沙虫"供应链投毒攻击,涉及 300 余个独立程序包的 600 余个恶意版本。
受影响项目包括:
echarts-for-react(月下载量 380 万次)@antv/g2、@antv/g6、@antv/x6等 AntV 核心库(月下载量合计超数千万次)- TanStack 系列 42 个包
timeago.js等社区常用库
恶意软件的核心行为是在安装时自动执行敏感信息窃取,同时会在卸载依赖后继续在系统中留下残留文件(如 router_runtime.js、setup.mjs),形成持久化威胁。
1.5 为什么每次 npm install 都是一场冒险?
上述事件有一个共同的技术前提:npm 的安装脚本在依赖安装时会无条件自动执行。这意味着:
npm install xxx → 下载包 → 执行 preinstall → 执行 install → 执行 postinstall → 完成
在这条链条中,每一步都有可能包含恶意代码。而开发者在执行 npm install 时,往往无差别地信任了所有依赖——以及所有依赖的依赖(即传递依赖)的所有脚本。
一个典型的前端项目,node_modules 下可能有 1000+ 个包,其中任何一个包都可能在 postinstall 中做任何事。这不是理论风险,而是已经被反复验证的现实。
npm v12 的激进改革,正是在这一背景下诞生的。
二、核心机制:npm v12 到底改变了什么
2.1 --ignore-scripts 从可选变默认
在 npm v12 之前,--ignore-scripts 是一个需要主动传递的参数:
# 旧行为(v11 及之前)
npm install # 自动执行所有脚本 ← 默认行为
npm install --ignore-scripts # 跳过所有脚本 ← 需要手动指定
从 npm v12 开始,这一行为发生了根本性翻转:
# 新行为(v12+)
npm install # 跳过所有脚本 ← 变成了默认行为!
npm install --scripts-prepend-node-path auto # 需要显式运行脚本
这意味着,一个包是否能在安装时运行脚本,现在需要得到明确授权。这从根本上改变了 npm 的安全模型:从"默认信任,可以选择拒绝"变为"默认拒绝,需要显式允许"。
2.2 npm approve-scripts 工作流
为了解决"禁用之后那些真正需要运行脚本的包怎么办"这个问题,npm v12 引入了 approve-scripts 工作流。这是一个显式、细粒度的脚本授权机制。
2.2.1 安装时的授权提示
当 npm 在安装过程中遇到包含脚本的包时,不再默默执行,而是:
暂停安装,向用户显示警告:
npm WARN deprecated: package-x@1.2.3 contains lifecycle scripts npm WARN preinstall: node scripts/preinstall.js npm WARN postinstall: node scripts/build.js npm WARN npm WARN This package has [N] executable script(s) that will run automatically. npm WARN Run with --scripts-prepend-node-path=auto to allow, or use npm WARN `npm approve-scripts <pkg>` to pre-authorize specific packages.用户可以选择:
- 运行
npm install --scripts-prepend-node-path=auto重新安装并允许脚本 - 运行
npm approve-scripts <pkg>预授权该包的脚本(全局生效) - 忽略警告,继续安装(脚本不执行)
- 运行
2.2.2 approve-scripts 命令详解
npm approve-scripts 是 npm v12 新增的核心安全命令,它允许用户预授权特定包的脚本执行:
# 授权单个包的脚本
npm approve-scripts <package-name>
# 查看已授权的脚本列表
npm approve-scripts --list
# 撤销某个包的脚本授权
npm approve-scripts --revoke <package-name>
# 批量授权(monorepo 场景)
npm approve-scripts pkg-a pkg-b pkg-c
授权信息存储在用户级别的配置中(~/.npmrc 或用户配置文件),不会进入项目的 package.json,从而避免不同开发者之间的配置冲突。
2.2.3 自动化场景下的行为
在 CI/CD 环境中,npm v12 的行为同样发生了变化:
# CI 环境默认行为:跳过脚本,但设置环境变量告知哪些包有脚本
# CI 环境变量:NPM_SCRIPT_BLOCKED=true
# 若确实需要运行脚本,必须显式传递:
npm install --scripts-prepend-node-path=auto
# 或
npm install --approve-scripts=auto # 自动授权已知安全的包
2.3 npm 11.16.0 的过渡警告机制
npm v12 并非一夜之间推出。在正式发布之前,npm v11.16.0 引入了完整的警告机制,为开发者提供充足的过渡时间:
# npm v11.16.0 安装时,对包含脚本的包输出警告
npm install lodash # 无脚本,正常
npm install node-sass # 有 postinstall,输出警告:
# npm WARN v11.16.0: In npm v12, lifecycle scripts will not run by default.
# npm WARN Package 'node-sass' has the following scripts that will be skipped:
# npm WARN - postinstall: node scripts/build.js
# npm WARN
# npm WARN To prepare for this change:
# npm WARN - If this is your own package, see: npm docs package-scripts
# npm WARN - If this is a dependency, run: npm approve-scripts <package>
# npm WARN - For CI: use --scripts-prepend-node-path=auto flag
# 查看项目中所有包含脚本的包
npm audit scripts
这个命令非常有价值,它会列出项目中所有包含生命周期脚本的包,帮助开发者提前识别潜在问题:
$ npm audit scripts
=== Packages with lifecycle scripts ===
package: node-sass@4.14.1
scripts:
- postinstall: node scripts/build.js
package: sharp@0.33.0
scripts:
- install: node install/check.js
package: @nestjs/core@10.0.0
scripts:
- postinstall: node -e "console.log('...')"
package: electron@28.0.0
scripts:
- postinstall: node install.js
Total: 4 packages with lifecycle scripts
2.4 生命周期脚本的执行时机
理解 npm v12 的机制,需要清楚 npm 生命周期脚本的标准执行顺序:
# 根项目生命周期(npm install 时)
1. preinstall ← 安装前执行(根项目)
2. install ← 安装中执行(根项目)
3. postinstall ← 安装后执行(根项目)
4. prepare ← npm pack 和 npm install 时执行(用于发布前准备)
5. prepublishOnly ← 仅在 npm publish 时执行
6. prepack ← npm pack 前执行
7. postpack ← npm pack 后执行
# 依赖包的安装(npm install <dependency> 时)
1. preinstall ← 依赖安装前执行
2. install ← 依赖安装中执行
3. postinstall ← 依赖安装后执行 ← 这里是 most commonly exploited
postinstall 是最常见的攻击向量,因为它在依赖安装完成后立即执行,拥有对项目目录的完整访问权限。这就是为什么大多数供应链攻击都选择在 postinstall 脚本中植入恶意代码。
2.5 package.json 中的 scripts 字段
{
"name": "my-awesome-package",
"version": "1.0.0",
"scripts": {
"preinstall": "echo 'Installing dependencies...'",
"install": "node scripts/validate-env.js",
"postinstall": "node scripts/post-install.js",
"prepare": "npm run build", // npm install 和 npm pack 时触发
"prepublishOnly": "npm test", // 仅 npm publish 时触发
"prepack": "echo 'About to pack'", // npm pack 前
"postpack": "echo 'Packed!'", // npm pack 后
"preuninstall": "echo 'About to uninstall'",
"postuninstall": "echo 'Uninstalled'"
},
"dependencies": {
"lodash": "^4.17.21"
}
}
在 npm v12 下,这些脚本不再自动执行,除非用户显式授权。
三、为什么现在必须改?安全、经济与信任的三重危机
3.1 安全危机
npm 的安装脚本机制本质上是一个任意代码执行漏洞——只要某个包出现在你的依赖树中(无论是直接依赖还是间接依赖),它就可以在你的机器上执行任意代码。这在理论上不是漏洞,因为 npm 的设计初衷就是信任包作者。但当 npm 生态成长为全球最大的代码仓库(超过 250 万个包)时,信任模型已经彻底崩溃。
3.2 经济危机
供应链攻击造成的经济损失是惊人的:
- 开发者时间:每次供应链事件爆发,开发者需要紧急排查、更新依赖、轮换凭证,一个大型团队可能需要消耗数百人/天的工时。
- 数据泄露成本:GitHub Token、AWS 密钥等一旦泄露,攻击者可以在数分钟内完成横向渗透,数据泄露的补救成本动辄数十万美元。
- 信任损耗:每次事件都在蚕食开发者对 npm 生态的信任,导致部分开发者转向其他生态或自建私有 registry。
3.3 信任危机
npm 生态的核心理念是"人人可发布、包包可依赖"。这个开放的生态在过去二十年里极大地推动了 Node.js 的繁荣。但 npm v12 的出现,等同于 npm 官方承认了一个事实:我们需要重新审视这个信任模型。
一刀切地默认禁用脚本是痛苦的,但它是解决根本问题的最直接手段。
四、技术细节:深入理解 npm v12 的行为变更
4.1 npm v12 配置项详解
npm v12 引入了多个新的配置项来管理脚本执行策略:
# 启用脚本执行(全局默认)
npm config set enable-scripts true
# 设置默认脚本策略
# 可选值:deny(默认,拒绝), ask(询问), allow-all(全部允许,仅推荐用于本地开发)
npm config set script-safety-level ask
# 信任已知安全的脚本执行器白名单
npm config set script-whitelist "npm|yarn|pnpm"
4.2 包的 package.json 中的脚本声明
对于包作者来说,理解自己包的 scripts 字段含义非常重要:
{
"name": "my-native-module",
"version": "2.0.0",
"scripts": {
// 这些脚本在 npm v12 下需要用户显式授权
"install": "node-gyp rebuild",
"postinstall": "node scripts/download-assets.js",
"preinstall": "node scripts/check-platform.js",
// 这些脚本不受 npm v12 默认禁用影响
// 因为它们仅在包作者执行 npm publish 等操作时触发
"prepare": "tsc",
"prepublishOnly": "npm test",
"prepack": "echo 'Building...'",
"postpack": "echo 'Done!'"
}
}
4.3 node_modules 中的脚本扫描
开发者可以使用 npm v12 内置的命令扫描当前项目中的所有脚本威胁:
# 列出所有依赖包及其脚本
npm ls --all | grep -E "(preinstall|postinstall|install)"
# 使用 npm exec 运行安装后的检查
npx package-scripts-audit
# 导出脚本清单到 JSON 文件
npm audit scripts --json > scripts-report.json
4.4 peerDependencies 中的脚本处理
npm v12 对 peerDependencies 的处理也发生了变化。当安装带有 peerDependencies 的包时:
# npm v12 下,peerDependencies 的安装脚本同样默认禁用
# 用户需要单独对每个包含脚本的 peerDependency 进行授权
npm approve-scripts @emotion/react @emotion/styled
五、当前准备:npm 11.16.0 的预警机制
如果你还在使用 npm v11,npm v11.16.0 提供了完整的预警机制,帮助你提前为 v12 做准备。
5.1 升级到 npm 11.16.0
# 查看当前版本
npm --version
# 升级到 npm 11.16.0
npm install -g npm@11.16.0
# 验证版本
npm --version # 应该是 11.16.0
5.2 提前测试 v12 兼容模式
在 npm 11.16.0 中,可以通过设置环境变量来模拟 v12 的行为:
# 模拟 npm v12 的默认行为(跳过所有脚本)
export NPM_V12_COMPAT=true
npm install
# 或者通过命令行标志
npm install --simulate-v12
5.3 完整的预检清单
#!/bin/bash
# pre-flight-check.sh - npm v12 升级前的预检脚本
echo "=== npm v12 兼容性预检 ==="
echo ""
# 1. 检查 npm 版本
echo "1. npm 版本:$(npm --version)"
# 2. 列出所有包含脚本的依赖
echo ""
echo "2. 包含生命周期脚本的包:"
npm audit scripts 2>/dev/null || echo " npm audit scripts 命令不可用,跳过"
# 3. 检查 node-gyp 相关包(最常需要 postinstall 的场景)
echo ""
echo "3. 可能需要编译的原生模块:"
npm ls node-sass sharp node-pre-gyp @mapbox/node-pre-gyp 2>/dev/null | grep -v "npm ls" || echo " 未检测到原生模块依赖"
# 4. 检查 prepare/prepublishOnly 脚本
echo ""
echo "4. 包发布相关脚本:"
grep -E '"prepare"|"prepublishOnly"|"prepack"|"postpack"' package.json || echo " 当前包无发布脚本"
# 5. 建议的操作
echo ""
echo "=== 建议操作 ==="
echo "如果上述第 2 步有输出,请运行:"
echo " npm approve-scripts <package-name>"
echo ""
echo "如果上述第 3 步有输出,请确保构建环境已配置:"
echo " npm install --scripts-prepend-node-path=auto"
六、应对策略:从开发者、用户到 CI/CD
6.1 开发者角度:如何让自己的包兼容 npm v12
如果你是 npm 包的作者,你的包很可能依赖安装脚本来完成编译、下载二进制文件或配置工作。在 npm v12 下,你需要重新审视这些脚本。
6.1.1 原则:消除或延迟脚本依赖
最佳实践是让安装脚本成为可选的,而不是必需的:
// scripts/postinstall.js
// 判断是否应该执行:如果环境不支持,跳过
const fs = require('fs');
const path = require('path');
// 方案 1:检查是否存在预构建文件,如果存在则跳过
const prebuiltPath = path.join(__dirname, '..', 'prebuilt', 'native.node');
if (fs.existsSync(prebuiltPath)) {
console.log('[postinstall] Prebuilt binary found, skipping build.');
process.exit(0);
}
// 方案 2:检查环境变量,CI 中跳过
if (process.env.CI || process.env.NPM_SCRIPT_BLOCKED === 'true') {
console.log('[postinstall] Running in restricted environment, skipping build.');
process.exit(0);
}
// 方案 3:提供手动触发方式
if (!process.env.FORCE_POSTINSTALL) {
console.log('[postinstall] Skipping automatic build.');
console.log('[postinstall] To build manually, run: npm run build:native');
console.log('[postinstall] Or set FORCE_POSTINSTALL=1 to force build.');
process.exit(0);
}
// 正常构建逻辑
const { execSync } = require('child_process');
try {
execSync('node-gyp rebuild', { stdio: 'inherit' });
console.log('[postinstall] Build completed successfully.');
} catch (error) {
console.error('[postinstall] Build failed. This is expected in some environments.');
console.error('[postinstall] Try running manually: npm run build:native');
process.exit(0); // 不要让构建失败导致 npm install 失败
}
6.1.2 package.json 配置示例
{
"name": "my-native-module",
"version": "2.0.0",
"scripts": {
"postinstall": "node scripts/postinstall.js",
"build:native": "node-gyp rebuild",
"rebuild": "node-gyp rebuild",
"install:clean": "rm -rf build && npm run build:native"
},
"gypfile": true,
"binary": {
"module_name": "native",
"module_path": "./prebuilt/{platform}-{arch}/"
}
}
6.1.3 向用户明确说明脚本用途
在包的 README 中添加清晰的说明:
## 安装注意
本包在安装时会运行一个 postinstall 脚本,用于下载平台对应的预编译二进制文件。
在 npm v12+ 中,你需要显式授权:
```bash
npm approve-scripts my-native-module
npm install
或者手动构建:
npm install
npm run build:native
卸载注意
卸载本包后,请手动清理残留文件:
rm -rf ./prebuilt ./build
#### 6.1.4 使用可选依赖(optionalDependencies)
对于那些"最好有,没有也行"的原生模块,使用 `optionalDependencies` 而非 `dependencies`:
```json
{
"dependencies": {
"core-logic": "^1.0.0" // 必须依赖,正常安装
},
"optionalDependencies": {
"sharp": "^0.33.0", // 可选,性能优化用,不影响核心功能
"node-sass": "^9.0.0" // 可选,样式编译用,无则降级到纯 JS 实现
}
}
optionalDependencies 中的包安装失败不会导致整体安装失败,这是更健壮的设计模式。
6.2 用户角度:如何安全地运行可信包的脚本
作为 npm 包的使用者,你需要建立自己的信任决策模型。
6.2.1 信任决策框架
// scripts/evaluate-trust.js
// 帮助评估一个包是否值得授权其脚本
const TRUST_CRITERIA = [
{
name: '官方维护的包(@types/*, @babel/*, @eslint/*)',
score: 10,
reason: '由大型组织维护,有代码审查流程'
},
{
name: 'GitHub Stars > 10k',
score: 8,
reason: '社区关注度高,异常行为容易被发现'
},
{
name: '每月下载量 > 100万',
score: 7,
reason: '用户基数大,安全研究者关注度高'
},
{
name: '维护时间 > 3年',
score: 6,
reason: '有长期维护记录'
},
{
name: '个人维护者,Stars < 1k',
score: 2,
reason: '资源有限,安全审计能力弱'
},
{
name: '刚发布不久(< 6个月)',
score: 1,
reason: '未经时间检验'
}
];
function evaluatePackage(pkg) {
// 简化评估逻辑
let score = 0;
let reasons = [];
// 实际实现中,这里应该调用 npm registry API 获取包元数据
// 例如:https://registry.npmjs.org/{pkg}
return { score, reasons, decision: score >= 7 ? 'APPROVE' : 'REVIEW' };
}
6.2.2 授权管理最佳实践
# 查看当前已授权的包
npm approve-scripts --list
# 输出示例:
# Approved scripts:
# typescript@5.3.0 (preinstall, postinstall) - approved 2026-06-01
# @nestjs/core@10.0.0 (postinstall) - approved 2026-07-10
# electron@28.0.0 (postinstall) - approved 2026-07-15
# 撤销不再需要的授权
npm approve-scripts --revoke electron
# 查看特定包的脚本详情
npm approve-scripts typescript --show
6.2.3 安装时的安全检查脚本
#!/bin/bash
# safe-install.sh - 带脚本审计的安全安装脚本
set -e
PACKAGE=$1
if [ -z "$PACKAGE" ]; then
echo "Usage: $0 <package-name>"
exit 1
fi
echo "=== 安全安装检查 ==="
echo "Package: $PACKAGE"
echo ""
# 1. 获取包信息
echo "1. 获取包元数据..."
PKG_INFO=$(npm view "$PACKAGE" --json 2>/dev/null)
# 2. 检查维护者信息
echo "2. 维护者:$(echo "$PKG_INFO" | jq -r '.maintainers[0].name // "unknown"')"
# 3. 检查最近更新时间
echo "3. 最新版本:$(echo "$PKG_INFO" | jq -r '.version')"
echo " 更新时间:$(echo "$PKG_INFO" | jq -r '.time.modified')"
# 4. 检查脚本内容
echo "4. 脚本分析..."
SCRIPTS=$(echo "$PKG_INFO" | jq -r '.scripts // empty')
if [ -z "$SCRIPTS" ] || [ "$SCRIPTS" = "null" ]; then
echo " ✓ 无生命周期脚本"
npm install "$PACKAGE"
else
echo " ⚠ 包包含以下脚本:"
echo "$SCRIPTS" | jq -r 'to_entries[] | " - \(.key): \(.value)"'
echo ""
echo " 是否继续安装并授权脚本? [y/N]"
read -r response
if [ "$response" = "y" ] || [ "$response" = "Y" ]; then
npm approve-scripts "$PACKAGE"
npm install "$PACKAGE"
else
echo " 已跳过脚本授权。安装包但禁用脚本。"
npm install "$PACKAGE" --ignore-scripts
fi
fi
6.3 CI/CD 角度:如何适配新的工作流
npm v12 对 CI/CD 流水线的影响最大,因为大多数 CI 环境都依赖于安装脚本来构建原生模块、拉取资源等。
6.3.1 GitHub Actions 配置
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
# npm v12+ 推荐:在 CI 中显式控制脚本行为
- name: Install dependencies
run: |
# 方法 1:允许所有脚本(适合信任所有依赖的团队)
npm install --scripts-prepend-node-path=auto
# 方法 2:预授权已审核的包(推荐用于生产项目)
# npm approve-scripts typescript @nestjs/core
# npm install
# 方法 3:分阶段安装(更精细的控制)
# npm install --ignore-scripts
# npm run install:verified # 仅运行已手动审核的脚本
env:
NPM_SCRIPTS_IN_CI: allow
# 如果有原生模块需要构建
- name: Build native modules
run: |
npm run build:native # 来自 package.json 的自定义构建脚本
# 或者
npx node-gyp rebuild || echo "Native build skipped"
if: runner.os != 'windows' # Windows 构建可能需要额外配置
- name: Run tests
run: npm test
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.node-version }}
path: dist/
6.3.2 GitLab CI 配置
# .gitlab-ci.yml
stages:
- install
- build
- test
- deploy
variables:
NPM_SCRIPTS_IN_CI: "allow"
NODE_OPTIONS: "--max-old-space-size=4096"
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .npm/
- node_modules/
npm_install:
stage: install
image: node:20-alpine
script:
# npm v12 兼容方式:显式允许脚本执行
- npm install --scripts-prepend-node-path=auto --prefer-offline
artifacts:
paths:
- node_modules/
expire_in: 1 hour
build:
stage: build
needs: ["npm_install"]
script:
# 如果 npm install 跳过了脚本,在这里显式运行已审核的构建
- npm run build
- npm run build:native || echo "Native build skipped"
test:
stage: test
needs: ["build"]
script:
- npm test
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
6.3.3 Jenkins 配置
// Jenkinsfile
pipeline {
agent { label 'nodejs' }
environment {
NPM_SCRIPTS_IN_CI = 'allow'
// 在 CI 中显式允许脚本,避免 npm v12 的默认禁用
}
stages {
stage('Install') {
steps {
sh '''
# npm v12 兼容:显式传递 --scripts-prepend-node-path
npm install --scripts-prepend-node-path=auto
# 或者使用已授权列表
# npm approve-scripts typescript @nestjs/core
# npm install
'''
}
}
stage('Build Native') {
steps {
sh 'npm run build:native || true'
}
}
stage('Test') {
steps {
sh 'npm test'
}
}
}
post {
always {
cleanWs()
}
}
}
6.3.4 本地开发 vs CI vs 生产的差异化策略
// scripts/postinstall.js
const isCI = !!process.env.CI;
const isNpmV12 = process.env.npm_config_npm_version?.startsWith('12') ||
process.env.NPM_SCRIPT_BLOCKED === 'true';
const forceBuild = process.env.FORCE_POSTINSTALL === '1';
async function main() {
console.log('[postinstall] Environment analysis:');
console.log(` - CI: ${isCI}`);
console.log(` - npm v12 detected: ${isNpmV12}`);
console.log(` - Force build: ${forceBuild}`);
// 策略:CI 中允许,生产环境需要额外确认
if (isNpmV12 && !isCI && !forceBuild) {
console.log('[postinstall] npm v12 detected with no CI flag.');
console.log('[postinstall] To run this script, either:');
console.log('[postinstall] 1. Run: npm approve-scripts <package-name>');
console.log('[postinstall] 2. Run: npm install --scripts-prepend-node-path=auto');
console.log('[postinstall] 3. Set: FORCE_POSTINSTALL=1 npm install');
console.log('[postinstall] Skipping automatic execution for safety.');
return;
}
// 正常构建逻辑
await runBuild();
}
main().catch(err => {
console.error('[postinstall] Error:', err.message);
process.exit(0); // 永远不要让 postinstall 失败导致 npm install 失败
});
七、生态影响:monorepo、原生模块与构建工具
7.1 monorepo 的影响与应对
在 monorepo(如 Turborepo、Nx、pnpm workspace)中,npm v12 的影响是乘数效应——一个包含 50 个子包的 monorepo,可能有数十个包都在 postinstall 中做不同的事情。
// turbo.json - Turborepo 配置示例
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"// postinstall": {
// monorepo 中统一管理 postinstall
"cache": false
}
}
}
monorepo 最佳实践:
# 在 monorepo 根目录统一授权所有包的脚本
cd my-monorepo
# 获取所有包含脚本的包
npm audit scripts
# 批量授权(通过脚本)
#!/bin/bash
# approve-all-scripts.sh
for pkg in $(npm audit scripts --json | jq -r '.packages[]'); do
npm approve-scripts "$pkg"
done
7.2 原生模块的特殊处理
原生模块(如 node-sass、sharp、sqlite3)几乎都需要安装脚本来编译 C++ 代码或下载预编译二进制文件。npm v12 对这类包的影响最为直接。
// .npmrc 配置示例 - 为原生模块设置例外
# npm v12 兼容配置
# 为原生模块自动授权(谨慎使用)
enable-native-scripts=true
# 或者使用包名白名单
allowed-native-scripts[]=node-sass
allowed-native-scripts[]=sharp
allowed-native-scripts[]=@mapbox/node-pre-gyp
# 在 .npmrc 中配置后,可以通过以下方式使用
npm install node-sass # 自动允许 postinstall
# 或者在 package.json 中声明
{
"name": "my-app",
"scripts": {
"postinstall": "node-sass --include-path node_modules/sass --output-style expanded --output ./dist/css src/scss"
},
"approvedNativeModules": ["node-sass", "sharp"]
}
7.3 构建工具的适配
主流构建工具已经在积极适配 npm v12:
| 工具 | v12 兼容性状态 | 建议操作 |
|---|---|---|
| Webpack 5 | ✅ 已适配 | 无需特殊处理 |
| Vite 5 | ✅ 已适配 | 无需特殊处理 |
| esbuild | ✅ 原生二进制 | 无需 postinstall |
| @nestjs/core | ⚠️ 需要授权 | npm approve-scripts @nestjs/core |
| TypeScript | ✅ 无脚本 | 无需处理 |
| Electron | ⚠️ 需要授权 | npm approve-scripts electron |
| pnpm | ⚠️ 部分支持 | 等待 pnpm v9+ |
| yarn | ⚠️ 独立策略 | 参考 yarn 官方文档 |
7.4 node-gyp 用户的特别注意事项
# 确保 node-gyp 工具链可用
which node-gyp || npm install -g node-gyp
# 在 npm v12 下,预安装 node-gyp 依赖
npm install --ignore-scripts # 先不执行任何脚本
npm install -g node-gyp node-pre-gyp # 安装编译工具
npx node-gyp rebuild # 手动编译所有原生模块
八、安全审核:构建你自己的 npm 安全体系
8.1 依赖脚本审计脚本
// scripts/audit-dependency-scripts.js
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
/**
* 审计所有依赖包中的脚本文件
* 检测潜在的恶意模式
*/
class DependencyScriptAuditor {
constructor() {
this.threats = [];
this.audited = new Set();
}
// 恶意模式检测规则
static MALICIOUS_PATTERNS = [
{
pattern: /process\.env\.(GITHUB_|npm_|AWS_|AZURE_|KUBERNETES_)/i,
severity: 'HIGH',
description: '检测到敏感环境变量访问'
},
{
pattern: /\.ssh\//,
severity: 'CRITICAL',
description: '检测到 SSH 目录访问'
},
{
pattern: /base64|decodeURIComponent|Buffer\.from.*atob/,
severity: 'MEDIUM',
description: '检测到编码操作(需人工审查)'
},
{
pattern: /eval\(|new Function\(/,
severity: 'HIGH',
description: '检测到动态代码执行'
},
{
pattern: /child_process\.(exec|execSync|spawn)/,
severity: 'MEDIUM',
description: '检测到子进程执行'
},
{
pattern: /https?:\/\/[a-z0-9.-]*\.tk|bit\.ly|tinyurl/i,
severity: 'CRITICAL',
description: '检测到可疑外部 URL'
},
{
pattern: /curl\s|wget\s/,
severity: 'HIGH',
description: '检测到网络下载操作'
}
];
async audit() {
console.log('🔍 开始审计依赖脚本...\n');
const packages = this.getInstalledPackages();
console.log(`📦 检测到 ${packages.length} 个已安装包\n`);
for (const pkg of packages) {
await this.auditPackage(pkg);
}
this.report();
return this.threats.length === 0;
}
getInstalledPackages() {
const lockFile = path.join(process.cwd(), 'package-lock.json');
if (!fs.existsSync(lockFile)) {
console.warn('⚠️ 未找到 package-lock.json,跳过审计');
return [];
}
const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
const packages = Object.keys(lock.packages || {}).filter(name => name !== '');
return packages;
}
async auditPackage(pkgName) {
if (this.audited.has(pkgName)) return;
this.audited.add(pkgName);
const pkgPath = path.join(process.cwd(), 'node_modules', pkgName);
const pkgJsonPath = path.join(pkgPath, 'package.json');
if (!fs.existsSync(pkgJsonPath)) return;
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
const scripts = pkgJson.scripts || {};
for (const [scriptName, scriptContent] of Object.entries(scripts)) {
if (!['preinstall', 'install', 'postinstall', 'prepare', 'prepublishOnly'].includes(scriptName)) {
continue;
}
console.log(` 📝 审计 ${pkgName}: ${scriptName}`);
await this.analyzeScript(pkgName, scriptName, scriptContent, pkgPath);
}
}
async analyzeScript(pkgName, scriptName, scriptContent, pkgPath) {
// 如果是引用文件,分析文件内容
if (scriptContent.includes('.js') || scriptContent.includes('node ')) {
const scriptPath = path.join(pkgPath, scriptContent.split(' ').pop());
if (fs.existsSync(scriptPath)) {
const content = fs.readFileSync(scriptPath, 'utf8');
for (const rule of DependencyScriptAuditor.MALICIOUS_PATTERNS) {
if (rule.pattern.test(content)) {
this.threats.push({
package: pkgName,
script: scriptName,
severity: rule.severity,
description: rule.description,
file: scriptPath
});
}
}
}
}
// 直接在 package.json 中的脚本
for (const rule of DependencyScriptAuditor.MALICIOUS_PATTERNS) {
if (rule.pattern.test(scriptContent)) {
this.threats.push({
package: pkgName,
script: scriptName,
severity: rule.severity,
description: rule.description,
type: 'inline'
});
}
}
}
report() {
console.log('\n=== 审计报告 ===\n');
if (this.threats.length === 0) {
console.log('✅ 未检测到已知恶意模式');
return;
}
const grouped = {};
for (const threat of this.threats) {
if (!grouped[threat.severity]) grouped[threat.severity] = [];
grouped[threat.severity].push(threat);
}
const severityOrder = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
for (const severity of severityOrder) {
if (!grouped[severity]) continue;
const emoji = severity === 'CRITICAL' ? '🚨' :
severity === 'HIGH' ? '⚠️' :
severity === 'MEDIUM' ? '🔍' : '💡';
console.log(`${emoji} ${severity} (${grouped[severity].length} 个)\n`);
for (const threat of grouped[severity]) {
console.log(` 包: ${threat.package}`);
console.log(` 脚本: ${threat.script}`);
console.log(` 问题: ${threat.description}`);
if (threat.file) console.log(` 文件: ${threat.file}`);
console.log('');
}
}
console.log('建议: 运行 `npm approve-scripts --revoke <package>` 撤销可疑包的脚本授权');
}
}
// 运行审计
const auditor = new DependencyScriptAuditor();
auditor.audit().then(safe => {
process.exit(safe ? 0 : 1);
}).catch(err => {
console.error('审计失败:', err);
process.exit(1);
});
8.2 package.json 中的安全配置
{
"name": "secure-project",
"version": "1.0.0",
"scripts": {
"postinstall": "node scripts/postinstall.js",
"audit:scripts": "node scripts/audit-dependency-scripts.js",
"install:safe": "npm install --ignore-scripts && npm run audit:scripts"
},
"security": {
"approvedScripts": [
"typescript@5.3.0",
"@nestjs/core@10.0.0",
"sharp@0.33.0"
],
"autoApproveKnownSafe": false,
"auditOnInstall": true
}
}
九、npm 生态的未来:从信任危机到信任重建
9.1 npm 官方安全路线图
根据 GitHub 和 npm 官方的公开信息,npm v12 只是更大安全计划的一部分:
| 阶段 | 功能 | 预计时间 |
|---|---|---|
| npm v12 (当前) | 安装脚本默认禁用,approve-scripts 工作流 | 2026 Q3 |
| npm v13 | 包签名验证强制化 | 2026 Q4 |
| npm v14 | 增量发布审查(对包含敏感 API 的包进行人工审核) | 2027 Q1 |
| npm v15 | 可重现构建(Reproducible Builds)强制化 | 2027 Q2 |
9.2 社区层面的应对
npm v12 的推出将深刻改变整个 Node.js 生态的包维护文化:
- 包作者:需要重新审视自己的
postinstall脚本,问自己"这个脚本真的必要吗?" - 企业安全团队:需要建立 npm 包的预审制度,明确哪些包可以运行脚本
- 工具链维护者:Webpack、Vite、Rollup 等工具需要内建脚本执行白名单
- CI/CD 平台:需要提供内置的 npm v12 兼容模式
9.3 与其他生态的对比
npm v12 的变革在软件包管理领域并非孤例:
| 包管理器 | 安全机制 | 备注 |
|---|---|---|
| npm | approve-scripts(v12+) | 默认禁用,细粒度授权 |
| pip | --no-build-isolation | 需要显式允许构建依赖 |
| cargo | lock 文件 + 源码校验 | Rust 生态的信任模型 |
| apt | GPG 签名验证 | Debian 系发行版 |
| Homebrew | 源码编译 + CI | 社区审查 |
从对比可以看出,npm v12 的方案比大多数生态更激进——它不仅要求验证来源,还要求用户主动授权每个包的脚本。这既反映了 npm 供应链问题的严重性,也体现了 npm 团队的决心。
十、总结:拥抱变化,建立新的安全范式
npm v12 的到来,标志着 Node.js 生态正式从"开放信任"时代进入"验证优先"时代。这个变化是痛苦的,但也是必要的。
对于开发者(包作者):
- 重新审视你的
postinstall脚本,能不要就不要 - 如果必须要有,让它成为可选项,提供优雅的降级路径
- 在 README 中明确告知用户需要什么权限
对于使用者:
- 利用
npm audit scripts提前了解项目中所有脚本 - 对信任的包使用
npm approve-scripts预授权 - 对不熟悉的包保持警惕,不要盲目授权
对于团队:
- 在 monorepo 中建立脚本白名单制度
- CI/CD 流水线中明确区分需要脚本和不需脚本的安装步骤
- 定期运行脚本审计脚本,检测潜在威胁
对于整个生态:
- npm v12 的变革需要整个社区共同推动
- 工具链维护者需要积极适配新版本
- 安全研究者需要持续关注新的攻击向量
npm v12 不是一个终点,而是 Node.js 生态重建信任体系的起点。痛苦是暂时的,安全是长久的。
参考资料:npm 官方博客、GitHub 安全博客、国家网络与信息安全信息通报中心通报、慢雾科技安全研究报告、BleepingComputer 安全资讯