每日壁纸

pnpm 和 npm 怎么防供应链攻击

Published on
/18 mins read/---

本文所有配置项都在本机实测过(npm 10.9.8 / pnpm 12.11.2 / Node 22)。凡是需要新版本才支持的选项,都标了版本号和验证方法。

结论先说:用 pnpm 升到最新版,默认就挡住了大半攻击面;继续用 npm,就把第五节那几行抄进 ~/.npmrc。注意你现在的 npm 版本可能还不支持全部选项——第四节有核对方法。


一、你装的不是 1 个包,是一整棵树

装一个库,实际落地的是几十上百个包。

以 express 为例,它自己就声明了 20 多个依赖:

{
  "dependencies": {
    "body-parser": "^2.2.1",
    "content-type": "^1.0.5",
    "cookie": "^0.7.1",
    "qs": "^6.14.0",
    "send": "^1.1.0",
    "serve-static": "^2.2.0"
  }
}

跑一次 npm install express,终端告诉你 added 66 packages。而这些包自己又有依赖,于是不断往下装,直到整棵树铺完。

数字本身不是重点,重点是这棵树的作者互不相识。你依赖的某个包,它又依赖某个陌生人 2016 年发在 npm 上的小包。攻击者只需要黑掉树里最末端的一个节点,就能同时打到所有这棵树下的人。

这就是为什么 JS 生态特别容易被供应链攻击:能力被拆得越细、树越大,末端节点越多,每个节点的作者防守能力越弱。你想知道 HTTP 状态码 404 对应什么文案,Go 里可以直接 http.StatusText,Python 里是 HTTPStatus(404).phrase,都有官方库;但 JS 生态没有这种东西,只能靠社区包撑着——statuses 每周 1.5 亿次下载,说到底只是一份 JSON 映射。生态越依赖社区,攻击面就越大。


二、四条真实的入侵路径

路径 1:版本号本身没被锁住

package.json 里写 "body-parser": "^2.2.1",安装时解析成当时最新的 2.x。哪天有人发布了恶意的 2.2.3,你的下一次全新 install 就会拿到它。

^ 的语义是「不跨 major,minor 和 patch 都接受」。以 body-parser 为例,^2.2.1 能接受任何 2.x.x,所以实际装到的是当时的最新版。方便,但代价是每次重新解析都跟着最新版走。

路径 2:拼写相近的仿冒包

express 手抖打成 expresss,就是最经典的例子。这类仿冒包(typosquatting)的攻击模式是:注册名字、控制下载量、利用开发者的手误和 review 时的视觉盲区。

危害在于它不一定马上发作。装的时候没报错、跑的时候没引用,看起来一切正常,直到某天有人真的 import 了它。npm 官方后来把 mongose(与 mongoose 一字之差)收归自己注册,防止被有心人抢注。

路径 3:postinstall 在你机器上直接执行代码 ← 最危险

npm 的包可以在 package.json 里声明 preinstall / install / postinstall,装完立刻在你的机器上跑任意 shell。

正常用途确实存在(puppeteer 的 postinstall 是去下载浏览器),但恶意利用的后果是:install 命令一执行,攻击就完成了,不需要你再做任何操作。axios 被攻击那次,就是某个子依赖声明了 postinstall 去跑 node setup.js,脚本里是恶意代码。

我本机实测了一遍,两个包管理器的行为差异非常明显:

npm(ignore-scripts 默认 false):

$ npm install esbuild
added 2 packages in 9s

esbuild 的 postinstall 是 node install.js,执行了,没有任何提示。

pnpm(v10 起默认拦截):

$ pnpm install esbuild
+ esbuild 0.28.2
Error: ERR_PNPM_IGNORED_BUILDS
  × adding a new package
  ╰─▶ Ignored build scripts: esbuild@0.28.2

直接报错中止,要装得先显式批准。

这就是两者的分水岭。 npm 是「默认允许,要安全得自己记得关」;pnpm 是「默认拒绝,要装得自己签字」。前者在项目里加新人、或者在赶时间时,防护是失效的。

路径 4:包根本不是从 registry 来的 ← 最容易被忽略

前面三条都在防「registry 里的坏包」。但依赖还有两个绕开 registry 的通道,所有基于 registry 的防护对它全部失效。

① 直接写 URL

依赖值可以是一个完整 URL。n8n 的元件 @n8n/instance-ai 里大部分依赖都正常,但有一个例外:

{
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
}

写的是 URL,不是版本号——这个包会直接从该地址下载,不经过 npm registry。(SheetJS 团队和 npm 有纠纷,最新版搬到了自己的服务器,官方文档也推荐直接装 URL。)

坏处就是除了 registry,你又多了一个需要担心的地方。这个 CDN 被拿下内容被换成恶意版本,你就直接下载到了;而且 min-release-age 不起作用,因为不走 registry 根本不知道发布时间。

所以第三方的 tarball URL 能避就避。

② git URL 依赖,以及它的绕过手法

依赖值可以是 git 地址。抓系统字体的 system-font-families 就有这么一个依赖:

{
  "dependencies": {
    "file-type": "^10.11.0",
    "ttfinfo": "https://github.com/rBurgett/ttfinfo.git"
  }
}

我本机实测,在项目 .npmrc 写入 ignore-scripts=true 的情况下:

$ npm install git+https://github.com/rBurgett/ttfinfo.git
added 1 package in 6s

装上了。 ignore-scripts 拦不住它——因为 npm 走的是 clone 仓库的路径,脚本执行开关在这条路径上的语义不同。装完 lockfile 里会记下解析结果:

{
  "node_modules/ttfinfo": {
    "version": "0.2.0",
    "resolved": "git+ssh://git@github.com/rBurgett/ttfinfo.git#f00e43e2a6d4c8a12a677df20b7804492d50863c"
  }
}

注意 f00e43e2... 这个 commit hash——它 pin 住了具体版本,所以有 lockfile 是安全的。但 system-font-families 本身没写版本号,没有 lockfile 的话你每次都会装到最新的 ttfinfo,min-release-age 同样不起作用。

安全厂商 KoI 报告过一个更彻底的组合拳:npm 安装 git 依赖时会 clone 仓库并在里面再跑一次 install,而 npm 有个 git 配置项可以指定用哪个命令执行 git。恶意包在自己的仓库里放一个 .npmrc:

git=./pwn.sh

配合一个 git 子依赖,安装时系统就会执行那个脚本。ignore-scripts=true 在这里形同虚设。 npm 当时把它归为「有意设计」而非漏洞,直到近期版本才给出正式开关。

这意味着:光配 ignore-scripts 是不够的,必须同时关掉 git 依赖。


三、三层防御

第一层:别装到 —— 收益最高

① 发布冷却期

有厂商盯梢 + 自动化侦测,这类攻击通常几小时内就会被发现并从 registry 移除。TanStack 被攻击后 20 分钟就被 StepSecurity 发现,axios 约 1 小时后被发现、恶意版本发布 3 小时后被 npm 移除。

攻击窗口很短,但你恰好站在窗口里就中招。 所以只要不装「刚发布」的包,就能躲开绝大部分攻击——注意这不是 100%,没人发现的话照样会装到。

三个包管理器都有这个参数,但单位和默认值完全不同,很容易配错:

工具参数名单位默认值
pnpmminimumReleaseAge分钟v11 起默认 1440(1 天)
npmmin-release-age天默认未开启
bunminimumReleaseAge秒默认未开启

② lockfile

同一个版本号发布后不能覆盖,所以 2.2.2 是安全的就永远是安全的。lockfile 把每个包的精确版本 + 完整性哈希记下来,装之前会校验,被篡改就报错:

{
  "node_modules/body-parser": {
    "version": "2.2.2",
    "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
    "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="
  }
}

前提:lockfile 必须提交进版本控制。没有它,每次 install 都重新解析 = 主动把自己置于路径 1 的风险里。

③ 装之前先扫

代表工具是 Socket 的 sfw(Socket Firewall)。装之前先查它的数据库,已知恶意直接拦下——包括在 registry 官方下架之前的那段空窗期(前面提到 axios 被发现到下架之间还有 2 小时),这段窗口恰恰是攻击收益最高的时段。免费版只提示,企业版可以阻断。它也不只 npm 能用,Python 的 pip/uv、Rust 的 cargo 都支持。

第二层:装了也别执行

对应路径 3。三家的默认立场不一样:

  • npm:ignore-scripts 默认 false,要安全得自己主动关
  • pnpm:v10 起默认全部挡掉,要跑得显式批准(pnpm approve-builds)
  • bun:内建信任清单,预设只有清单里的约 300 个包能跑脚本

pnpm 当初为此开了个 GitHub 投票(Should we block lifecycle script of dependencies during installation,#8918),七成人选择默认挡掉。我个人更认同 pnpm 的做法:直接全挡,要跑明确批准。

第三层:把来源锁死在 registry

对应路径 4,也是最容易被跳过的一层。

  • pnpm:blockExoticSubdeps,v11 起默认开启。设计很聪明——第一层依赖(你亲手敲进命令行的)放行,因为你知道自己在干嘛;传递依赖里的 git/URL 依赖一律拒绝,因为那些东西你根本不知道是什么。
  • npm:v11.10.0 起提供 allow-git / allow-remote,可设 none / root / all;官方已公告 v12 起 allow-git 默认改为 none。

四、先确认你的版本

这是最容易翻车的地方。这几个配置项在旧版本里是静默失效的——你写进 .npmrc 了,不报错,但完全不生效。

我本机 npm 10.9.8 的实测结果:

$ npm config get ignore-scripts    → false    (有此项)
$ npm config get min-release-age   → undefined(不支持)
$ npm config get allow-git         → undefined(不支持)
$ npm config get allow-remote      → undefined(不支持)

也就是说在这台机器上,只有 ignore-scripts 能用,另外三项写了也是白写。

自查命令:

npm -v
npm config get ignore-scripts    # 期望 true
npm config get min-release-age   # 期望有数字
npm config get allow-git         # 期望 none

返回 undefined 就是该版本不支持,别以为配好了。

结论:想用完整防护,升到 npm 11.10+;否则就只剩 ignore-scripts 一层,第三层防御形同虚设。这是目前选 pnpm 最大的实际理由。


五、npm 怎么配

写进 ~/.npmrc(用户级,全局生效,不用每个项目重复写):

# 不执行 preinstall/postinstall 等安装脚本
ignore-scripts=true
 
# 不下载 3 天内发布的包(需 npm 11.10+)
min-release-age=3
 
# 禁止 git URL 依赖,堵住 ignore-scripts 的绕过路径(需 npm 11.10+)
allow-git=none
 
# 禁止直接 URL 下载的 tarball(需 npm 11.10+)
allow-remote=none

配完 npm config list 确认一遍。前三项如果显示 undefined,说明要升级。

冷却期的副作用:漏洞修复版本也得等几天。着急时临时绕过:

npm install -g @openai/codex --min-release-age=0

我的判断标准:看漏洞能不能被实际触发。只有特定功能触发、且你根本没在用的漏洞,等几天完全值得;可远程利用的,别等,立刻修。

allow-git=none 确实会挡掉部分正常依赖。公司内部项目依赖 git URL 的话,改成 root(只允许第一层)而不是 all。

六、pnpm 怎么配

升到 v11+,然后 minimumReleaseAge + blockExoticSubdeps + 默认拦脚本三项自动生效,什么都不用做。

确实需要构建脚本的包(esbuild、puppeteer、sharp 这类),显式批准:

pnpm approve-builds

它会写进 pnpm-workspace.yaml:

onlyBuiltDependencies:
  - esbuild
  - puppeteer
  - sharp

别看到 ERR_PNPM_IGNORED_BUILDS 就顺手加 --ignore-scripts 或 dangerously-allow-all-builds=true 把错误压掉——那等于把整个第二层防御拆了。正确做法是逐个确认这个包确实需要跑构建脚本。

顺便说个 pnpm v11 的实际副作用:如果你用 codex 这类每天检查更新的 CLI,发现它问你要不要更新、你说好、装完又问,陷入鬼打墙——多半是冷却期在起作用(版本发布还没过一天所以没装到),不是坏了。我自己碰到过一两次,后来才反应过来。


七、发版前自检

# 1. 官方漏洞库扫描(免费,但只覆盖已知 CVE)
npm audit
pnpm audit
 
# 2. 依赖里有没有混进非 registry 来源 —— 这条最容易漏,也最重要
grep -nE '"(git\+|git://|https?://|github:)' package-lock.json
grep -nE 'resolution:.*(git\+|tarball)' pnpm-lock.yaml
 
# 3. lockfile 跟主干有没有异常差异
git diff main --stat -- package-lock.json pnpm-lock.yaml

第 2 条建议直接做成 CI 检查:解析 lockfile,出现非 registry 来源就 fail build。比事后审计便宜太多——路径 4 只有这条检查能可靠发现。


八、几个容易踩的坑

坑说明
旧版本 npm 静默失效npm config get 返回 undefined 就是没生效,见第四节
lockfile 没提交前面所有配置的意义都被抵消
只看 npm audit只报已知 CVE。被投毒 ≠ 有 CVE,audit 全绿不代表包安全
以为不用就没事装了恶意包,即使没 import,postinstall 也已经跑完了
只配 ignore-scripts挡不住 git URL,见路径 4,第三层必须配
冷却期一刀切全局设死会挡住紧急安全更新,需要的是临时绕过而不是永久放弃
在 CI 里裸装CI token 价值更高,开发机的配置同样要落到 CI
看到警告就绕过ERR_PNPM_IGNORED_BUILDS 应该逐个批准,不是全局关掉

九、还想要更多

  1. provenance / trustPolicy:发布时附带可验证的构建来源,降低账号被盗后恶意发布的风险。
  2. 开发容器:装包和开发全程在容器内,能访问的东西被限制在容器边界内。安全性更高,成本也更高。
  3. CI 凭据最小权限 + 短期有效:攻击的终点往往是 CI 里高价值的凭据,这层的投入产出比和前面配置相当。
  4. 锁发布账号:开启 2FA、限制 token 作用域。攻击常是「黑一个包 → 横向扩散 → 进公司」,上游收紧能直接掐断链条。

总结

按投入产出排序,做完前三条就已经比大多数人安全得多:

  1. 先查版本(npm config get 看那几个参数是不是 undefined)——不做这步,后面全白配
  2. 用 pnpm 升到 v11+,零配置拿到三层保护
  3. 继续用 npm 就抄第五节的配置,并确认三项都生效了
  4. lockfile 提交进版本控制
  5. CI 加一条「不允许非 registry 依赖」的检查

风险没法归零,但上面这几条加起来,能挡掉现实中绝大部分的攻击。剩下的交给时间和运气。

← Previous postAI类博客阅读推荐