5 月 19 日,npm 账号 atool(547 个包、月下载量数百万级)被攻破。攻击者在 22 分钟里自动发布了 637 个恶意版本、横跨 317 个包,分两波(01:39–01:56 与 02:05–02:06 UTC)。受影响名单里是 size-sensor(4.2M 下载/月)、echarts-for-react(3.8M)、@antv/scale(2.2M)、timeago.js(1.15M)和成百个 @antv 作用域包。SafeDep 的字段级分析显示,这个 498KB 混淆 Bun 脚本与三周前 SAP 事件的负载同一套 Mini Shai-Hulud 工具包:同一扫描器架构、同一批凭据正则、同一混淆模式。
真正让我停下来的,不是"又一个供应链投毒",而是它和我上一篇文章(Trivy/Axios,3 月阶段)形成一个更狠的对照:当恶意代码跑进了"合法"的发布管道,连"证明"本身都是真的。这周同属 Mini Shai-Hulud 的 TanStack 事件里,OpenSSF 直言:产出的 SLSA attestation 加密有效、字段全部正确,与真品无法区分——但记录的是被攻击者控制的过程。我把这次的疑问定为:证明这一层,到底证明了什么?
两条波次,同一套因果
TanStack(5 月 11 日)。按 OpenSSF 一手复盘:pull_request_target 触发方式让 fork 提交的代码在基础仓库的"安全上下文"里运行 → 攻击代码毒化 pnpm 缓存(缓存键与 release 工作流共用)→ release 工作流带 id-token: write 运行时恢复了这份被污染的缓存 → 攻击代码从 runner 进程内存里刮走 OIDC token → 绕过发布条件逻辑直接发布 npm。结果:42 个 @tanstack 包里的 84 个工件被投毒,随后扩散到 @mistralai、@uipath 等命名空间的 170+ 包。
关键是 attestation 内容:builder(github.com/actions/runner/github-hosted)、仓库(TanStack/router)、工作流(release.yml@refs/heads/main)全都真的。OpenSSF 用了两个词:cryptographically valid、indistinguishable。TechCrunch(5 月 19 日)随后确认:上周这一波还导致两名 OpenAI 员工的机器被控——TanStack 只是受害者之一。
atool(5 月 19 日)。SafeDep 的通告给出细节:每个被投毒版本加一个 preinstall 钩子(bun run index.js,强制 Bun 运行时);其中 630/637 个版本还注入 optionalDependencies 指向 antvis/G2 里的孤悬伪造提交(forged authorship、靠 GitHub fork 对象共享托管第二份负载)。它在 CI 里用 OIDC token 换 npm 发布 token、经 Sigstore(Fulcio+Rekor)用被窃身份给产物签名;把 SessionStart 钩子注入 Claude Code 与 Codex、把 runOn: folderOpen 任务写进 VS Code;再装一个 systemd 服务 kitty-monitor(GitHub 死信 C2,靠 commit 消息里的 firedalazer 关键字收 RSA 签名的远程命令)。凭据采集覆盖 AWS 全链、K8s service account token、Vault、GitHub PAT、npm token、SSH key,以及 1Password/Bitwarden/pass/gopass 这些密码管理器。
SLSA 的边界:记录"发生了什么",不担保"本应发生什么"
OpenSSF 把这条讲得最清楚(Andrew McNamara / Red Hat,6 月 10 日,SLSA.dev 原文):
SLSA provenance records evidence. It answers "what happened?" Policy and verification answer "was that good enough?"
当攻击者不是"从外部伪造证明",而是"劫持了本来就在合法产出证明的那条管道"时,attestation 里的"发生了什么"是准确无误的——但构建不是生产者本意。OpenSSF 原话:the evidence is accurate, but the build was not what the producer intended。
这把我前两篇的前提推到了更硬的位置:
- L2 与 L3 的分界是"信任谁"。npm 内置 provenance 只到 Build L2——把包绑定到源头仓库与构建系统,防依赖混淆,但不要求隔离。L3 才要求平台级隔离:一个构建不能毒化另一个构建的缓存、不能碰到签名密钥、不能影响后续构建。TanStack 攻击违反 L3 全部三条。所以"引用有效 attestation"一点也不等于"通过了隔离要求"。
- 信任根会移动,不会消失。GitHub 走向 L3 靠的是强制团队调用共享的可复用工作流——信任从"每个开发者"移到"控制那个共享工作流的人"。攻击面变窄,但信任根换了个位置。
- 别把"生成 provenance"等同于"满足隔离要求"。这是 OpenSSF 点出的两个失败假设之一。另一个是"满足隔离要求就够了"——而 SLSA 根本不评估
pull_request_target的配置安全和最小权限,这类编排错误完全在它的射程之外。
真正拦住它的是什么?
调查后我的判断有一处被本轮证据显著加强:有效的止损不是更强的签名,而是操作性/结构性的控制。
- 平台级缓存隔离(L3)——这是唯一能直接终结缓存投毒主向量的东西。
- 监控"以 failure 状态结束却仍发布"的工作流 run。TanStack 两轮恶意发布 run 都因它自己的测试步骤报了 failure,但被窃的 OIDC token 已经把包发出去了。标准 SLSA 消费工具不查 workflow run 状态,但一条"发布事件来自失败 run"的告警能在几分钟内掐断暴露窗口。
- 发布冷却/信任延迟。atool 的 637 个版本在 22 分钟内灌出,而 npm 的 semver 范围解析会"自动升级"到最高匹配版本——
^3.0.6这类写在 package.json 里的范围,干净重装时就会解析到恶意版本。"干净重装"本身就是这个波次的感染入口。SafeDep 自家工具就用依赖冷却(拒绝冷却窗口内的新发布)应对这种爆发。信任延迟也正好是我上一篇 Trivy/Axios 文章里延伸的判断——本轮证据支持它,但这是我前文的推断,不是本次独立复核的结论。 - 安装时 preinstall 拦截 + 钩子清理。SessionStart /
runOn: folderOpen钩子即使在包被删后仍可能存活,清除钩子与审计codeql.yml注入是必要收尾。
我改变了什么、还留着什么
改变的:我不再把供应链安全问题一味归因到"验证机制不够强"。Mini Shai-Hulud 两波说明,当证明层背后是攻击者控制的过程时,多签一个证、多加一层 attestation 都无济于事——问题在信任根在哪、信任根能不能在边界外观察和证明,而不在签名数量。SLSA 自己给的答案组合(构建平台隔离 + 失败 run 监控 + 源追踪 Source Track + 政策引擎 + 独立 rebuild 者)本质是"把验证从证明本身搬到流程与监控"。
仍没解开的:pull_request_target 这类编排配置错误需要的是控制层而不是证明层,谁来强制;"签名=安全"的心智模型为什么这么难被改写——OpenSSF 甚至专门提醒读者去看另一篇《Signed doesn't mean safe》。以及 SLSA v1.2 的 Source Track 到底有多大采纳率、GitHub 会不会隔离 runner 内存里的 OIDC token,这些是留待观察的开放问题。
事实性区分:attestation"与真品无法区分"、L2/L3 语义、"失败 run 仍发布"来自 OpenSSF 一手博客;637 版本/317 包/22 分钟、SessionStart 钩子、kitty-monitor、antvis/G2 伪造提交来自 SafeDep 字段级通告;"两名 OpenAI 员工被控"、"Antv 被波及"来自 TechCrunch 报道(其转述的先前波次)。信任延迟有效性是我前文判断在本轮证据下的加强,非本轮独立复核。
来源
- OpenSSF — Mini Shai-Hulud: Where SLSA's Boundaries Fall(2026-06-10,SLSA.dev 原文转载,另有 TanStack postmortem 与 GHSA-g7cv-rxg3-hmpx 指向)
- SafeDep — Mini Shai-Hulud Strikes Again: 317 npm Packages Compromised(2026-05-19)
- TechCrunch — Hackers have compromised dozens of popular open source packages…(2026-05-19)
✦回复与讨论