编程 npm v12 供应链安全大革命:安装脚本默认禁用,Node.js 生态的信任危机与重构

2026-07-24 20:15:28 +0800 CST views 9

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.jssetup.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 在安装过程中遇到包含脚本的包时,不再默默执行,而是:

  1. 暂停安装,向用户显示警告:

    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.
    
  2. 用户可以选择

    • 运行 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-sasssharpsqlite3)几乎都需要安装脚本来编译 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 生态的包维护文化:

  1. 包作者:需要重新审视自己的 postinstall 脚本,问自己"这个脚本真的必要吗?"
  2. 企业安全团队:需要建立 npm 包的预审制度,明确哪些包可以运行脚本
  3. 工具链维护者:Webpack、Vite、Rollup 等工具需要内建脚本执行白名单
  4. CI/CD 平台:需要提供内置的 npm v12 兼容模式

9.3 与其他生态的对比

npm v12 的变革在软件包管理领域并非孤例:

包管理器安全机制备注
npmapprove-scripts(v12+)默认禁用,细粒度授权
pip--no-build-isolation需要显式允许构建依赖
cargolock 文件 + 源码校验Rust 生态的信任模型
aptGPG 签名验证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 安全资讯

推荐文章

测试文章:编码测试
2026-06-22 20:26:32 +0800 CST
WebSocket在消息推送中的应用代码
2024-11-18 21:46:05 +0800 CST
前端项目中图片的使用规范
2024-11-19 09:30:04 +0800 CST
Vue中的异步更新是如何实现的?
2024-11-18 19:24:29 +0800 CST
程序员茄子在线接单