上周五下午,我像往常一样在项目里跑 npm install,结果 CI 直接红了。报错信息是一堆"node-gyp rebuild failed",native addon 编译全挂。
第一反应是 node 版本不对,检查了一遍发现没问题。又以为是网络问题,挂代理重试,还是挂。最后翻日志才发现------npm 开始提示一堆以前从没见过的 warning,说什么"preinstall script will not run"。
我这才反应过来:npm v12 真的要来了,而我完全没有准备。
一个被忽视十几年的"特性"
说实话,在这次翻车之前,我从来没仔细想过 npm install 到底在干什么。
不就是下载依赖包吗?
直到这次报错逼我去翻文档,才发现 npm 一直是这么玩的:当你执行 npm install,npm 不只是下载文件,它还会自动执行包里的生命周期脚本。package.json 里声明的 preinstall、install、postinstall,npm 都会自动跑。
很多包用这些脚本来编译原生模块、下载二进制资源、做环境初始化。比如 node-sass 要编译 C++ addon,sharp 要下载预编译的二进制,不跑脚本这些包根本没法用。
问题在于------这些脚本跑的时候,拿的是你当前进程的全部权限。
你在 CI 机器上跑 npm install,postinstall 脚本可以直接读取环境变量里的 AWS credentials、SSH key、npm token。它想干什么就干什么,不需要任何确认。
我一直以为 npm install 是安全的。事实证明,这种信任可能从一开始就是个误会。
供应链攻击已经盯上这个入口
让我真正重视这件事的,是看到 GitHub 6月初发的公告。
他们说 npm v12 要在 7 月发布一个"史上最大安全变更":安装脚本以后不会自动执行,必须手动批准。
配套的数据让我后背发凉:JavaScript 生态里超过 72% 的供应链攻击,都是通过安装脚本植入恶意代码实现的。
这不只是理论上的风险。就在今年6月,朝鲜黑客组织 Sapphire Slet 利用被劫持的 npm 维护者账号,在恶意包里植入后门,通过 postinstall 脚本收集开发者的 credentials。这波攻击影响了超过 140 个 npm 包。
想想你上周跑的 npm install,那个 postinstall 脚本到底干了什么?你真的清楚吗?
我之前从来没想过这个问题。
npm v12 要改什么
GitHub 这次决心很大,v12 的变更有三条,每条都是硬改:
第一条,allowScripts 默认关闭。
这是最核心的变更。以后 npm install 不会再自动跑 preinstall、install、postinstall 脚本,包括 node-gyp 触发的隐式编译。
也就是说,如果你的项目依赖 node-sass、sharp、canvas 这类需要编译原生模块的包,升级到 npm v12 之后,这些包装上了但没法用,因为编译脚本不跑了。
第二条,allow-git 默认值变成 none。
以前你可以直接在 package.json 里写 "dependencies": { "some-lib": "github:username/repo" },npm 会去 Git 拉代码。v12 之后这条路默认封死,必须显式开启。
第三条,远程 URL 依赖也砍了。
"some-lib": "https://some-cdn.com/file.tgz" 这种直接下载 tarball 的方式,v12 也不认了。所有包必须来自官方 registry。
这三个变更加在一起,就是把 npm 变成一个默认不信任任何东西的包管理器。想让什么东西跑,必须说清楚为什么信任它。
approve-scripts 是怎么工作的
npm 官方知道这个改动会很痛,所以给了一套工具让你平滑过渡。
核心命令是 npm approve-scripts。
在 npm 11.16.0 以上的版本里,你可以先跑这个命令看看自己项目里有哪些包有安装脚本:
css
npm approve-scripts --allow-scripts-pending
这个命令会扫描所有依赖,包括间接依赖,然后列出一个清单,告诉你哪些包想要跑脚本、跑的是什么脚本。
然后你需要一个个决定:信任还是不信任。
perl
# 批准某个包的脚本
npm approve-scripts <package-name>
# 拒绝某个包的脚本
npm deny-scripts <package-name>
批准的结果会写进 package.json,像这样:
json
{
"name": "my-project",
"scripts": {},
"dependencies": {},
"approvedScripts": {
"node-sass": ["install"],
"sharp": ["postinstall"]
}
}
这个配置是项目级别的,意味着你的同事拉代码之后,npm 会自动识别哪些脚本是被批准过的。未批准的脚本?对不起,不跑。
我的项目是怎么修复的
回到我开头说的那个 CI 报错。
查了一圈发现,问题出在 sharp 这个图片处理库。sharp 在安装时会下载预编译的二进制文件,这个操作是通过 postinstall 脚本完成的。
在旧版 npm 下,这一步自动完成,没人会注意。到了 v12,不批准这个脚本,sharp 就装了个空壳,运行时直接报错。
修复步骤其实不复杂:
先升级 npm:
css
npm install -g npm@latest
确认版本在 11.16.0 以上,然后跑 approve-scripts 扫描:
css
npm approve-scripts --allow-scripts-pending
输出里找到 sharp,看到它需要一个 postinstall 脚本。确认这个脚本确实是下载二进制文件,没有其他操作,批准:
npm approve-scripts sharp
这个命令把批准信息写进 package.json。然后 npm install 再跑一遍,sharp 的 postinstall 正常执行,问题解决。
整个过程大概花了十分钟,比我排查报错的时间还短。
但这个改动真的准备好了吗
我在 Twitter 上看到这个变更的讨论,反应挺两极的。
支持的人说这是"迟到十几年的安全加固",npm 早该像浏览器对待 JavaScript 一样默认关闭危险操作。也有人说 JavaScript 生态的包太乱了,很多老项目的 postinstall 脚本根本没人维护,升级之后直接跑不起来。
我的体感是,这两种声音都对。
npm 这次转向是对的,供应链攻击只会越来越频繁,默认信任的模式早该终结。但对于维护者来说,这个过渡期确实会有点痛。尤其是那些依赖很多 native addon 的项目,或者是接手了一个没人维护的老项目,approve-scripts 的清单可能长得让人绝望。
GitHub 给了 npm 11.16.0 以上的版本可以提前看警告,这个设计还算良心。但问题是,大多数开发者不会主动去看 warning,除非等到 CI 红了才反应过来。
你现在能做什么
如果你看到这篇文章,赶紧打开终端查一下自己的 npm 版本:
css
npm --version
低于 11.16.0 的,先升级:
css
npm install -g npm@latest
然后在你手头的项目里跑一下 approve-scripts,看看有哪些脚本需要审批:
css
npm approve-scripts --allow-scripts-pending
这个清单越早处理越好。别等到 7 月 npm v12 发布那天才发现项目跑不起来了。
对于团队项目,建议把 approvedScripts 配置纳入 code review,确保每个被批准的脚本都经过审查。那些没人维护的依赖,能替换就替换,能去掉就去掉。
npm 正在从一个"下载安装二合一"的工具,变成一个真正的包管理器。这个转变有点疼,但长期来看应该是个好事。
只是在那之前,我们得先熬过这个过渡期。