axios 被投毒半年后,我把 npm 的"安全防线"实测了一遍:最信任的 SHA512 是安慰剂

20 阅读4分钟

今天刷掘金,热榜又有人复盘 axios 三月底那次投毒。加上 8 月那波 ChainDrop——keyv 开头、444 个包连锅端——今年 npm 的投毒故事多到复盘文都开始内卷。 复盘多了,有个问题一直没答案:真撞上了,几分钟能干什么。不是理念,是命令。今天下午我在 1 核 2G 沙盒把能跑的都跑了一遍,几个结论跟直觉相反。

先把事件底账摆清楚

3 月 31 日早上,axios 某个维护者的 npm 账号被人拿下,发了两个"新版本":1.14.1 和 0.30.4。tarball 里藏了个依赖 plain-crypto-js@4.2.1——crypto-js 4.2.0 的克隆,加了段 postinstall,装包时连 C2 服务器(sfrclak.com)拉远控,三大平台通吃。

窗口很短:00:21 到 03:29,UTC。GitHub Advisory 说得直白:三小时内跑过 npm install 的机器都该当作已被完全控制,凭据全部作废。@lightdash/cli 这种把 axios 写成 ^1.12.0 的项目,无锁文件新装就可能拉到毒版。还有博主出来认领:他的 GitHub Action 每小时跑一次,用的是 npm install 而非 npm ci,正好撞进窗口,账号密码被扒走。 官方处置是下架、降级、轮换凭据。

"现场"已经被抹掉了

先看毒版本还在不在:

$ npm view axios@1.14.1
npm error 404 No match found for version 1.14.1

plain-crypto-js@4.2.1 同样 404。registry 的止血是物理抹除,连元数据都不留。 坏东西删掉嘛,合理。直到我手动把测试项目的 lockfile 里 axios 改成 1.14.1——模拟 3 月 31 日生成的锁文件——跑 npm ci:

$ npm ci
npm error 404 Not Found - GET https://cdn.npmmirror.com/packages/axios/1.14.1/axios-1.14.1.tgz

直接断链,CI 全挂。客观上拦了毒包,算被动止血;但流水线的生死由 registry 的删除操作决定。更膈应的是验尸:版本抹掉后,registry 上查不到 tarball 里有什么,想还原只能翻 Socket、StepSecurity 当时抓的快照——事后追溯兜底的是安全公司的热心。

SHA512 到底校验了什么

我以前把 lockfile 里的 integrity 当安全特性——每条依赖跟一串 sha512-xxx,装包时逐个校验,感觉像上了指纹锁。

机制很简单:hash 是第一次安装时从 registry 元数据里抄的。npm ci 确认的是"下载的 tarball"和"registry 声称的 hash"一致——保证运输途中没人掉包,源头真假它管不着。3 月 31 日那天,毒 tarball 在 registry 上就是"官方版本",hash 被光明正大写进元数据,也抄进每个中招项目的 lockfile。校验照样通过,一分不差。

SHA512 校验的是一致性,不是正当性。这两个词的区别,就是我半年白信任它的原因。

我又试了个更损的:装完正常的 axios 1.14.0 后,往 node_modules/axios/lib/axios.js 追加一行注释,再跑 npm ls axios——

$ npm ls axios
axtest@1.0.0 /tmp/axtest
└── axios@1.14.0

一切正常,没有告警。integrity 只在安装那一瞬间校验一次,装完后 node_modules 变成什么样,npm 不看。这条"防线"防不了投毒(hash 来自毒源),防不了运行时篡改(装完就撒手),真正防的只有传输损坏和你手抖改锁文件。都是低频场景。

audit 是道什么防线

轮到 npm audit。先在官方源跑:装的 axios 1.14.0 是投毒前的正常版本,audit 甩出二十来条 GHSA——原型污染、SSRF、CRLF 注入,密度高得吓人。但仔细看,没有一条是那次投毒事件。 这不是 bug,是机制:audit 查的是漏洞库(advisory),恶意包走另一条通道,而数据库更新永远滞后于攻击。三小时投毒窗口里,audit 就是空白页——CI 每小时跑一次也拦不住。 然后撞上更实际的:这台沙盒默认走 npmmirror 镜像,npm audit 直接报错:

npm warn audit 404 Not Found - POST .../-/npm/v1/security/audits/quick
{ error: '[NOT_IMPLEMENTED] /-/npm/v1/security/* not implemented yet' }

国内镜像源的 audit 接口压根没实现。不少项目默认就是镜像源,等于这道防线天生是断的,还断得悄无声息——只是一行 warn 小字。我今天才注意到。

provenance 能验真,但有边界

audit 不行,我寄希望于 npm 这两年推的 provenance——有点东西,但边界比预想窄。 好的一面:axios 正常版本,投毒前的 1.14.0、今天 registry 上的 latest 1.20.0,都带 dist.attestations:

$ npm view axios@1.14.0 dist.attestations
{
  url: 'https://registry.npmjs.org/-/npm/v1/attestations/axios@1.14.0',
  provenance: { predicateType: 'https://slsa.dev/provenance/v1' }
}

这是 GitHub Actions 用 OIDC 签的 SLSA 证明:tarball 确实出自官方构建流程。投毒的 1.14.1 没有它——攻击者只有发布权限,伪造不了官方流水线。当时要是习惯性瞄一眼这个字段,那次投毒肉眼就能排掉。 然后我把 axios 的依赖树扫了一遍,热情凉了一半:follow-redirects,没有;form-data,没有;proxy-from-env,还是没有。全项目 23 个依赖,有 provenance 的只有 axios 自己。

原因不难猜:provenance 要求走官方 CI 加 OIDC,axios 这级别是标配,长尾包是负担。"没有 provenance"在 npm 是常态,没法当异常信号,否则全项目天天红灯。它适合的场景很具体:对最核心的几个直接依赖装之前看一眼;当全量扫描工具用,不行。

真管用的几招,土得掉渣

验尸最快的是 grep:advisory 会给恶意包名,拿 plain-crypto-js 在 lockfile 里搜一把,或 npm ls plain-crypto-js,几秒出结论。3 月 31 日当天,知道包名的人就是这么确认死没死的。 顺着这个思路,我写了个十行小脚本,扫 node_modules,把带 install 钩子的捞出来:

import json, os
for d in sorted(os.listdir('node_modules')):
    pj = os.path.join('node_modules', d, 'package.json')
    if os.path.isfile(pj):
        m = json.load(open(pj))
        hooks = {k: v for k, v in m.get('scripts', {}).items()
                 if k in ('preinstall', 'postinstall', 'install')}
        if hooks:
            print(d, m.get('version'), hooks)

结果:23 个依赖,0 个钩子。axios 本体的 scripts 里也只有 build 和 test。这是个比 provenance 靠谱的基线——正常运行的项目里 install 钩子是稀有事件,谁新冒出来都值得多看一眼。那次投毒撬动你的,就是那个 postinstall。

至于 --ignore-scripts,实测装一遍,axios 照常 require,能拦 postinstall 的执行。但边界要说清:依赖还在树里,文件还在磁盘上,只是不运行——脚本不跑了,包还躺在 node_modules 里,文件一点没少。

事前的活就两件:CI 里把 npm install 换成 npm ci——那位中招的同学,出事条件是"无锁文件 + 高频构建 + npm install",去掉任何一个都不会中;再给 Renovate 配 minimumReleaseAge,发布后晾三天才进 lockfile。听着耽误事,但 axios 那次窗口三小时,ChainDrop 被 npm 全面处置也就一两天,晾三天等于让社区帮你当小白鼠。代价是新版本晚三天用——我的答案是值。

十分钟能做完的事

折腾到这儿,最实的话就一句:npm 默认机制里可用性优先,安全是你自己的事。我把 CI 模板里的 npm install 换成了 npm ci,加了钩子扫描——三件事不到十分钟。 就写到这。ChainDrop 那 444 个包的完整清单,想看的人多的话,下回单独扒。