- 引言 — 2026 年 3 月 31 日 00:21 UTC
- Registry 留下的收据
- 入侵路径 — 不是令牌,是一个人的电脑
- trusted publishing 当时是开着的
- 一家厂商的说法,和时间线对不上
- 真正挡住这件事的是一个人
- provenance 到底证明了什么
- 真的跑一遍 npm audit signatures 会发生什么
- 真正本该起作用的管控 — 等待
- 没人知道波及范围到底有多大
- 那么,到底该做什么
- 结语
- 参考资料
引言 — 2026 年 3 月 31 日 00:21 UTC
供应链安全文章大体分两种。要么是事故发生了的新闻,要么是"上 SBOM、验签名"这类泛泛之谈。两种都帮不上什么忙。前者不告诉你到底哪个管控为什么失效,后者解释不了一个早就打开了那项管控的人为什么还是中招了。
2026 年 3 月 31 日的 axios 事件,恰好就是后一种。axios 当时已经在用 npm 的 trusted publishing。前一个正常版本带着 SLSA provenance。维护者账号开着 2FA。然后,还是被攻破了。
本文和别的文章不一样的地方在于,这里出现的大部分时间戳都是我自己从 npm registry API 直接核实的,而且你现在就能用同样的方法验证。事件已经结束,但收据还留在 registry 里。
Registry 留下的收据
npm registry 的包元数据里有一个 time 映射。它有一个有意思的性质:即便某个版本被 unpublish,它在 time 里的条目依然会保留。 也就是说,被删除的恶意版本是什么时候上传的,现在仍然是公开信息。
你可以自己试一下。
curl -s https://registry.npmjs.org/axios | python3 -c "
import sys, json
d = json.load(sys.stdin)
t = d['time']
for v in ['1.14.0', '1.14.1', '0.30.3', '0.30.4']:
print(v, t.get(v), '| 现在是否存在:', v in d['versions'])
"
我在 2026 年 7 月 16 日跑出来的结果是这样的。
1.14.0 2026-03-27T19:01:40.915Z | 现在是否存在: True
1.14.1 2026-03-31T00:21:58.168Z | 现在是否存在: False
0.30.3 2026-02-18T17:19:20.081Z | 现在是否存在: True
0.30.4 2026-03-31T01:00:57.285Z | 现在是否存在: False
1.14.1 和 0.30.4 这两个版本,发布时刻是记录在案的,但现在已经不存在了。 这就是被 unpublish 的痕迹。而且这两个时间戳,和维护者 Jason Saayman 写的官方事后分析里的时间线,精确到秒都对得上。
被注入的依赖包那边也是一样。
curl -s https://registry.npmjs.org/plain-crypto-js | python3 -m json.tool | head -20
plain-crypto-js 的 time 映射里,4.2.0 留着 2026-03-30T05:57:32.867Z,4.2.1 留着 2026-03-30T23:59:12.278Z,而它现在的 dist-tags.latest 是 0.0.1-security.0。repository 字段是 npm/security-holder — 这是 npm 删除恶意包之后,用来占住这个名字的占位符。那个占位符上线的时刻是 2026-03-31T03:25:11.636Z。
把顺序拼起来就是:攻击者先在 3 月 30 日 05:57 发布了一个无害的 plain-crypto-js@4.2.0 占好位置,同一天晚上 23:59 又发布了带恶意 postinstall 的 4.2.1,不到一小时后,也就是 3 月 31 日 00:21,就发布了把这个包当作依赖引进来的 axios@1.14.1。诱饵先埋下,本体后面才动手。
入侵路径 — 不是令牌,是一个人的电脑
按照事后分析的说法,事情的起点不是 registry,而是一个人。攻击者用了大约两周的定向社会工程学攻击,在主维护者的电脑上植入了 RAT(远程访问木马),并从那里拿到了 npm 账号凭据。
这里关键的一点是,2FA 当时是开着的(根据 Datadog Security Labs 的分析)。这不奇怪 — 一旦攻击者已经坐在维护者自己的机器里,2FA 就只是走个过场。2FA 挡的是"从别处登录的陌生人",挡不住"已经变成你电脑本身的那个人"。
payload 是一个同时针对 macOS、Windows、Linux 的跨平台 RAT,C2 是 sfrclak[.]com(端口 8000)。事后分析建议可能受影响的人在锁文件里 grep axios@1.14.1、axios@0.30.4、plain-crypto-js,一旦命中就把那台机器当作已被入侵,轮换上面所有的凭据;如果命中的是 CI runner,那就要轮换注入到那次构建里的所有 secret。
trusted publishing 当时是开着的
这是这起事件里最有意思的部分。axios 当时已经在用 trusted publishing 了。从 registry 里把每个版本的发布者和 provenance 状态拉出来,这一点立刻就看得见。
curl -s https://registry.npmjs.org/axios | python3 -c "
import sys, json
d = json.load(sys.stdin)
for v in ['1.13.0', '1.14.0', '1.18.1', '0.30.3']:
ver = d['versions'][v]
att = ver['dist'].get('attestations')
print(v, '| 发布者:', ver['_npmUser']['name'], '| provenance:', 'O' if att else 'X')
"
结果如下。
1.13.0 | 发布者: jasonsaayman | provenance: O
1.14.0 | 发布者: GitHub Actions | provenance: O
1.18.1 | 发布者: GitHub Actions | provenance: O
0.30.3 | 发布者: jasonsaayman | provenance: X
从这里能读出两件事。
第一,1.13.0 到 1.14.0 之间切换到 trusted publishing 的痕迹清清楚楚地摆在那里。 发布者名字从个人账号变成了 GitHub Actions。也就是说,攻击发生前四天、3 月 27 日发出的那个正常版本 1.14.0,是 GitHub Actions 通过 OIDC 发布的,provenance 也是自动带上的。
第二,旧的 0.x 版本线根本没有 provenance。 它一直是从个人账号手动发出去的。攻击者同时盯上 1.14.1 和 0.30.4,可能不是巧合 — 0.x 这条线本来就是个软柿子,连一个可以被漏掉的信号都没有。
于是问题就变成了:明明开着 trusted publishing,为什么还会有发布是从个人账号出去的?
答案就写在 npm 自己的文档里。设置了 trusted publishing 之后,npm 会接受来自已批准工作流的发布,但用文档原话照搬过来,是"在(in addition to)npm 令牌或手动发布这类传统认证方式之外"接受的。也就是说,trusted publishing 默认是一条附加路径,而不是排他路径。要关掉令牌和手动发布这两条路,得在包设置里另外勾选 "Require two-factor authentication and disallow tokens"。
所以准确地说,trusted publishing 并没有被攻破,也没有被密码学意义上绕过。一扇没上锁的侧门就那么摆在那里,攻击者径直走了进去。 维护者在事后分析的"接下来要改变的事"那张表里写下 "Proper adoption of OIDC flow for publishing",说的就是这个意思。重点落在 proper 这个词上 — 不是打开它,而是让它成为唯一的路径。
Orca Security 在这个结构性的点上说得也很准。用他们的话来说,"Trusted Publishing only works if it's the sole publishing path" — 只有当它是唯一的发布路径时才有效。这句话是对的,npm 自己的文档也能佐证。
一家厂商的说法,和时间线对不上
不过,同一篇 Orca 的文章里还写着这样一句话 —"The likely entry point was a long-lived classic npm access token, which can sidestep 2FA requirements entirely."一个老旧的 classic 令牌还留着,那大概就是入口 — 这是一个假设。听起来挺有道理。一个遗留令牌绕开了最新的管控这种叙事很干净,也是安全行业喜欢的那种故事。
问题出在日期上。GitHub 在 2025 年 12 月 9 日的更新日志里明确写道 —"We've permanently revoked all existing npm classic tokens. They can no longer authenticate, be recreated, or be recovered."所有 classic 令牌都被永久吊销,无法再认证、重建或恢复。同一次公告里,npm login 也改成了签发 2 小时的会话令牌,而不是长期令牌。
axios 遭到攻击是在那之后大约三个半月,2026 年 3 月 31 日。在那个时间点上,classic 令牌根本不可能完成认证。 所以 Orca 的这个假设很难站得住。而维护者本人在事后分析里说的是一条简单得多的路径 — 通过社会工程学在电脑上植入 RAT,再从那里拿走凭据。
平心而论,Orca 用了"likely"来打了埋伏,而且他们那个结构性的结论("必须是唯一路径")是对的。我不是说他们在撒谎。但这是事故分析里一种常见的失败模式 — 把一起事件往已知的失败类型上套,结论就算碰巧是对的,应对方向也会走偏。 相信这个假设,得出的行动项会是"审计遗留的 classic 令牌",可那早在四个月前就已经被 npm 全局解决了;而真正的问题 — "维护者工作站被入侵"和"手动发布路径一直开着" — 反而根本碰不到。
教训很简单。厂商博客对 IOC 和技术分析有用,但入侵路径这类叙事,要拿一手的事后分析和 registry 时间线去对照。这一次,对照花的时间是五分钟。
真正挡住这件事的是一个人
那么到底是什么挡住了它?不是 provenance,不是 trusted publishing,不是 2FA,也不是任何扫描器。
事后分析里的原话说得最准确 —"There was no automated way to detect an unauthorized publish. Detection depended entirely on the community noticing."没有任何自动化的手段能检测到未经授权的发布,检测完全依赖于社区自己发现。
实际发生的事情是这样的。经 GitHub API 核实,PR #10591 是 axios 的协作者 DigitalBrainJS 在 2026-03-31T01:38:47Z 开的,01:42:15Z 合并。标题平平无奇,chore(ci): add deprecate action;。距离恶意发布(00:21)过去了77 分钟。他把被入侵的版本标记为 deprecated,通知了社区,并直接联系了 npm。恶意版本在 03:15 从 registry 下架。
这里有两个容易被忽略的细节。
攻击者一直在删除举报。 按事后分析的说法,大约在 01:00,社区成员开始提交 issue 报告这次入侵,而攻击者用被劫持的账号把这些 issue 删掉了。账号被劫持,交出去的不只是发布权限 — 审核权限也一并交了出去。 攻击者可以一边发布恶意版本,一边删掉举报它的帖子。
响应者的权限比攻击者低。 维护者在致谢里写道,DigitalBrainJS 是"在被劫持的账号权限高于自己"的情况下快速行动,促使 npm 采取了处理。这是做应急响应预案时几乎没人会去设想的情形 — 当权限最高的那个账号本身是敌对的,剩下的人还能做什么。
三小时这个窗口看起来很短,但这份短暂不是任何管控的成果,而是一个恰好还醒着的协作者的成果。 如果他那天晚上正好睡着了,这个窗口本会长上好几倍。依赖人的检测,是没法规模化的。
provenance 到底证明了什么
这里有必要把 provenance 讲清楚,因为很多人把它误解成一张"安全证书"。
直接打开 1.14.0 的 attestation bundle,就能看到里面实际装的是什么。
curl -s "https://registry.npmjs.org/-/npm/v1/attestations/axios@1.14.0" | python3 -c "
import sys, json, base64
d = json.load(sys.stdin)
for a in d['attestations']:
if 'slsa' not in a['predicateType']: continue
p = json.loads(base64.b64decode(a['bundle']['dsseEnvelope']['payload']))
print(json.dumps(p['predicate']['buildDefinition']['externalParameters'], indent=2))
"
出来的内容是这样的。
{
"workflow": {
"ref": "refs/tags/v1.14.0",
"repository": "https://github.com/axios/axios",
"path": ".github/workflows/publish.yml"
}
}
也就是说,provenance 签名要说的是这件事 — 这个 tarball 是从 axios/axios 仓库的 v1.14.0 标签上,用 publish.yml 这个工作流,在 GitHub 托管的 runner 上构建出来的。只有出处和构建路径。
仅此而已。npm 自己的文档直接点明了这一点 —"When a package in the npm registry has established provenance, it does not guarantee the package has no malicious code."有 provenance,不代表没有恶意代码。
这为什么重要呢?因为这次的攻击者控制着维护者的电脑。在那种状态下,他们很可能也一并拿到了 GitHub 凭据。假如攻击者不是直接向 npm 发布,而是往仓库推送了一个提交、打上标签,那么 CI 就会去构建它,最终产出的会是一个带着完全有效的 provenance 的恶意包。 签名是真的,出处是真的,只有代码是恶意的。provenance 抓不住这种情况。它本来就不是为了抓这个而设计的。
所以,如果当初把 trusted publishing 设成排他路径,这一次的攻击本可以被挡住 — 但那并不是消灭了这类攻击,只是把它挪到了一条更贵的路径上。 仓库推送 → 代码评审 → 打标签 → CI 这条路径上,挂着分支保护和评审,而且最重要的是,它会留下提交这个痕迹。这才是管控诚实的价值所在:不是让攻击变得不可能,而是让它变贵、变吵。
真的跑一遍 npm audit signatures 会发生什么
不过,这起事件还有更让人难受的一面。信号当时就摆在 registry 里。
1.14.0 有 provenance,发布者是 GitHub Actions。1.14.1 没有 provenance,发布者是个人账号。这个差异,在发布的那一刻就已经暴露在公开 API 上了。只要消费者一侧强制要求"axios 必须带有来自 axios/axios 仓库的 provenance",1.14.1 就会被自动挡在门外。
问题是没有人去强制这一条,因为默认情况下就不是这样。
我自己核实了一下。把有 provenance 和没有 provenance 的包装在一起安装,跑了一遍 npm audit signatures。
npm init -y
npm i axios@1.14.0 left-pad@1.3.0
npm audit signatures
结果如下。
audited 24 packages in 0s
24 packages have verified registry signatures
1 package has a verified attestation
退出码是 0。通过了。
24 个包里只有 1 个的 attestation 通过了验证,却依然算通过。也就是说,npm audit signatures 只验证存在的东西,不会为缺失的东西较真。 provenance 不存在,不算错误。哪怕攻击者当初发布 1.14.1 时压根没带 provenance,这条命令给出的结果照样会是那个 0。
这就是这起事件最核心的讽刺之处。这个生态在制造信号这件事上是成功的,在消费信号这件事上是失败的。 provenance 是发布者一侧产出的东西,只要消费者不强制验证,它的价值就是零。徽章挂得漂漂亮亮,可 CI 什么都没问过。
这里面还藏着一个鸡生蛋的问题。因为 provenance 的覆盖率低,所以"没有 provenance 就失败"没法当作默认设置打开;而因为没人打开它,provenance 的价值也就一直涨不上去。所以现实中可行的强制手段,不是全局策略,而是针对你自己知道的少数几个高风险包做的定点验证。 axios、构建工具、CI action — 从数得过来的那几个开始。
真正本该起作用的管控 — 等待
那么作为消费者,这次攻击真正能被什么管控挡住呢?不是签名,是时间。
pnpm 有一个 minimumReleaseAge 设置。按官方文档的说法,它会延迟安装新发布的版本,从而降低安装到被入侵包的风险。这个值以分钟为单位,作用于所有依赖,包括传递依赖。文档拿来作依据的那句话,和这起事件几乎完全对得上 —"In most cases, malicious releases are discovered and removed from the registry within an hour."大多数恶意发布都会在一小时之内被发现并从 registry 移除。
默认值很关键。从 pnpm v11 开始,minimumReleaseAge 的默认值是 1440 分钟,也就是 24 小时(v11 之前是 0)。
axios 那个恶意版本存活的窗口大约是三小时。24 小时的等待,把这整个窗口都盖住了。事发当时如果用的是 pnpm v11 的默认设置去安装,根本不会碰到 1.14.1。不需要签名,不需要扫描器,也不需要威胁情报,只是多等了一天而已。
这个结论多少有点让人泄气。相比生态这三年在 sigstore 和 SLSA 上投入的力气,"多等一天"听起来实在有点寒酸。但就这一起具体的事件而言,真正起作用的是它,而不是 provenance。只要供应链攻击的主流形态还是"在一个短窗口里发布,被发现了就下架",等待就是一种不对称的强力手段 — 因为它直接瞄准了攻击者手里唯一的资产,也就是那个窗口本身。
当然,这里面有取舍,得诚实地说出来。
- 安全补丁会来得更晚。 就算是真正紧急的 CVE 修复,也得等上 24 小时。所以才有
minimumReleaseAgeExclude这个机制,用来给特定依赖开例外。 - 挡不住潜伏期长的攻击。 如果攻击者发布了恶意版本之后干脆等上一周,等待就失效了。等待只对"很快就会被发现的攻击"管用,发现得越晚,它的价值就越低。
- 这是规避,不是检测。 等待不会告诉你任何事情,它只是让别人先踩雷。这在整个生态层面是不是一种公平的策略,是另一回事 — 如果所有人都等 24 小时,那就没人会先发现问题了。这类管控,恰恰是在别人不用它的时候效果最好。
没人知道波及范围到底有多大
最后聊聊数字。报道这起事件的文章大多引用"周下载量 1 亿次"和"依赖包超过 17.4 万个"。Orca 和 Datadog 两家都写的是"roughly 100 million weekly downloads and over 174,000 dependent packages"。
我自己用 npm 官方的下载量 API 重新量了一遍。
curl -s "https://api.npmjs.org/downloads/point/last-week/axios"
{"downloads":85473581,"start":"2026-07-09","end":"2026-07-15","package":"axios"}
2026 年 7 月 9 日到 15 日,实测是 8547 万 3581 次。"约 1 亿"这个说法舍入得相当宽松,而且不管怎么说,这也是现在这个时间点的数字,不是事发当时的数字。差距不算大,但值得指出的是,在照搬一个被引用的数字之前,花 30 秒就能查到原始出处。
更重要的是这一点:恶意版本到底被实际安装了多少次,是没有公开数据可查的。 看 npm 按版本划分的下载量 API,1.14.1 和 0.30.4 压根就没出现在列表里。
curl -s "https://api.npmjs.org/versions/axios/last-week" | python3 -c "
import sys, json
dl = json.load(sys.stdin)['downloads']
for v in ['1.14.0', '1.14.1', '0.30.4']:
print(v, dl.get(v, '无'))
"
1.14.0 1735295
1.14.1 无
0.30.4 无
被 unpublish 的版本,下载数不会暴露出来。所以那些"1 亿人当时处于风险中"之类的说法,全都是潜在暴露规模,不是实际受害规模。 就算是一个周下载量 8500 万次的包,在那三小时的窗口里,究竟有多少人跑了新装、又有多少人一直跟着 latest,是完全不同的两个问题。如果你锁文件里钉死了版本,而且那三小时没跑 fresh install,那你就是安全的 — 事后分析也正是这么说的。
Orca 拿"live for under three hours"这一点,给实际暴露范围加了个"有限"的限定语,这个说法是公允的。但留在标题里的永远是那个 1 个亿。安全厂商的统计口径大多倾向于把潜在暴露量往最大值上估,这不代表它总是假的,但它总是一个上限,这点值得记住。诚实的答案是这样的 — 不知道。registry 不公开这个数据。
那么,到底该做什么
从这起事件里能真正推导出来的行动,因角色而异。
如果你是发布包的一方
- 别以为打开 trusted publishing 就完事了。得在包设置里把令牌和手动发布关掉,它才会成为排他路径。只是打开了 trusted publishing,就像 axios 证明的那样,还留着一扇侧门。
- 别忘了那些遗留的发布线。axios 的 0.x 一直是没有 provenance、从个人账号发出去的。攻击者挑的不是守得最严的那条线,而是守得最松的那条。
- 给未经授权的发布建一条告警通道。这次的检测,完全是靠人的运气。
如果你是消费包的一方
- 打开
minimumReleaseAge(从 pnpm v11 开始,24 小时已经是默认值)。就这起具体事件而言,这是唯一真正会起作用的管控。 - 在锁文件里钉死版本,别在随便什么时候跑 fresh install。当时锁死了版本的人,就是安全的。
- 只针对少数几个高风险包,强制验证 provenance 的出处。
npm audit signatures不会为缺失的 provenance 较真,所以不能只靠它。 - 考虑禁用安装脚本。这次的 payload 就是通过
postinstall执行的。
不管是哪一方
在一台被植入了 RAT 的维护者工作站面前,registry 层面的大多数管控都会失效。这起事件的根本原因不是 npm,而是两周的社会工程学攻击。而那不是靠签名能解决的问题。
结语
归纳一下:axios 当时开着 trusted publishing,在发布 provenance,也用着 2FA — 可它还是在三小时里散播了一个跨平台的 RAT。不是因为 trusted publishing 被攻破,而是因为它不是排他的;不是因为 provenance 失效了,而是因为没人强制验证它。真正挡住这件事的,不是任何一项管控,而是那天晚上恰好还醒着的一位协作者。
如果这篇文章只能留下一句话,那就是这句 —管控只有被做成唯一路径、并被强制执行,才值这个价,光是打开它是不够的。 一项只是"开着"的管控,能让你通过审计清单,却挡不住仪表盘之外的攻击者。
还有一件事。本文里的大部分事实,不是来自厂商博客,而是来自 curl 和官方的一手事后分析;在这个过程中,我发现一个被广泛引用的入侵路径说法,其实和 registry 的时间线对不上。读事故报告时,先从一手资料看起。registry,大体上是不会撒谎的。
参考资料
- Post Mortem: axios npm supply chain compromise — Jason Saayman (axios/axios#10636) — 维护者本人撰写的一手事后分析
- PR #10591 — DigitalBrainJS 把被入侵版本标记为 deprecated 的提交
- Compromised axios npm package delivers cross-platform RAT — Datadog Security Labs — 技术分析与 IOC(安全厂商自身的分析)
- Supply Chain Attack on Axios Delivers Cross-Platform RAT via Compromised npm Account — Orca Security — 结构性的指出是站得住的,但 classic 令牌那个假设和时间线对不上(安全厂商自己的统计口径)
- npm Trusted Publishers 官方文档 — "in addition to" 这个措辞,以及阻断令牌的设置项
- Generating provenance statements — npm 官方文档 — 明确写明 provenance 不保证没有恶意代码
- npm classic tokens revoked, session-based auth — GitHub Changelog (2025-12-09)
- pnpm settings — minimumReleaseAge
- npm 供应链攻击为何挥之不去(相关文章)
현재 단락 (1/141)
供应链安全文章大体分两种。要么是事故发生了的新闻,要么是"上 SBOM、验签名"这类泛泛之谈。两种都帮不上什么忙。前者不告诉你到底哪个管控为什么失效,后者解释不了一个早就打开了那项管控的人为什么还是中...