lullu57/gh-actions-demo-cache-poisoning
GitHub: lullu57/gh-actions-demo-cache-poisoning
基于 TanStack 供应链攻击事件的可复现教学项目,完整展示了如何通过 GitHub Actions 缓存投毒链在不窃取任何凭据的情况下发布带有合法来源证明的恶意 npm 包。
Stars: 0 | Forks: 1
# cache-poisoning-pwn-demo
## 这个仓库是什么
这是一个针对 npm 包的 CI/CD 流水线发起的可完全复现的缓存投毒攻击,原型取自 [2026 年 5 月的 TanStack npm 供应链入侵事件](https://tanstack.com/blog/npm-supply-chain-compromise-postmortem)。该包是真实的,已发布到公共 npm,且攻击链在 GitHub Actions 基础设施上实现了端到端的完整运行。
**一句话总结:** 没有凭据被盗,没有维护者账号被攻陷,一个陌生人的 Pull Request —— *在未合并的情况下被关闭* —— 就会导致维护者*自己的* CI 在下一次正常推送到 `main` 分支时发布一个恶意版本。发布出的版本带有 **npm 来源证明** 签名,这是现代的“相信我,这来自真实的工作流”印记;该证明是正确签发且可验证的,因为攻击是*在*受信任的工作流*内部*运作的,而不是通过窃取其密钥。
## 这个仓库为何存在
为了让这种攻击类型变得触手可及。阅读 [TanStack 的事后分析](https://tanstack.com/blog/npm-supply-chain-compromise-postmortem) 可以了解其大致轮廓;而运行这个演示则能让人切身感受到它的*速度*和*隐蔽性*。观众在看到一个无害的 `git push` 几秒钟后,恶意版本就出现在 npm 界面上,看过这个演示的人很少会忘记它。
它也可作为一个**可分享的参考**,供需要加固其自身发布流水线的工程师使用:不安全链条中的每一个环节都在 [`fix/`](fix/README.md) 中有对应的安全替代方案,每一个“不良实践”都在 [`why/README.md`](why/README.md) 中附带了同理心说明,解释了导致这种诱人做法的合理工程原因。
## 如果你是偶然发现这个的(通过 npm、搜索或同事)
- npm 上的这个包是真实的。安装它会触发教学负载(打开计算器 + 打印标记信息)。它不会窃取数据、进行持久化,除了打开一个 GUI 应用程序外不会做任何其他事情。
- 这**不是**对任何流行包的抢注——名称 `cache-poisoning-pwn-demo` 被故意标记为演示用途。
- 如果你维护一个使用 GitHub Actions 构建的 npm 包,请阅读 [`fix/README.md`](fix/README.md)——那里的三项工作流级别更改可以从结构上杜绝此类攻击。
- 如果你在 CI 中使用 npm 包,作为消费端最有效的单一防御措施是 `npm config set minimum-release-age 10080` ——请阅读下面的[加固部分](#consumer-side-mitigation)。
## 一张图看懂攻击
```
Attacker fork PR (or any same-repo branch from a write-access account)
│
▼
.github/workflows/vulnerable-bundle-size.yml
│ (triggers on pull_request_target → runs in BASE repo trust context)
│ (checks out PR HEAD; runs npm install → fires attacker's prepare hook)
│ (caches node_modules — INCLUDING the poisoned is-number/index.js)
▼
[ shared GitHub Actions cache key: nm- ]
│ cache persists even after PR is closed
▼
Any future push to main (a typo fix, a Dependabot bump, anything)
│
▼
.github/workflows/vulnerable-release.yml
│ (restores the poisoned cache; runs build; bundles poisoned is-number into dist/postinstall.js)
│ (mints OIDC token; publishes v0.1.N with provenance attestation)
▼
npm registry: cache-poisoning-pwn-demo@0.1.N is now MALICIOUS
│
▼
Anyone running `npm install cache-poisoning-pwn-demo`
→ postinstall executes → calculator opens → "[supply-chain-demo] supply-chain attack PoC triggered."
```
## 演示展示了什么
| 阶段 | 发生的事情 | 观众可见吗? |
|-------|--------------|-------------------|
| 基线 | 维护者将 v0.1.0 发布到 npm。干净。 | `npm install` 显示 "thanks for installing" |
| 攻击 | Fork 的 PR 触发 `pull_request_target` 工作流。将 payload 植入 `node_modules/is-number`。缓存被投毒。PR 被关闭。 | PR 上的工作流日志(看起来无害) |
| 起爆 | 维护者推送任何更改到 `main`。发布工作流恢复缓存,构建,将 v0.1.1 发布到 npm。 | 带有来源证明的新版本在 npm 上可见 |
| Pwn | 观众运行 `npm install ` → 计算器打开。 | 计算器在每台观众机器上打开 |
## 文件概览
| 路径 | 是什么 |
|------|------------|
| [`package.json`](package.json)、[`src/`](src/)、[`scripts/build.js`](scripts/build.js) | 一个真实且可用的 npm 包——封装 `is-number` 的小工具。使用 esbuild 将 node_modules 打包到 dist/。 |
| [`.github/workflows/vulnerable-bundle-size.yml`](.github/workflows/vulnerable-bundle-size.yml) | 写入投毒缓存的 `pull_request_target` 工作流 |
| [`.github/workflows/vulnerable-release.yml`](.github/workflows/vulnerable-release.yml) | 恢复缓存、构建、通过 OIDC 受信任发布将包发布到公共 npm 的 `push: main` 工作流 |
| [`.github/workflows/safe-bundle-size.yml`](.github/workflows/safe-bundle-size.yml) + [`safe-release.yml`](.github/workflows/safe-release.yml) | 修复后的版本 |
| [`attack/README.md`](attack/README.md) | 攻击者的完整演练过程 |
| [`attack/fork-changes/`](attack/fork-changes/) | 攻击者提交到其 fork 的确切文件,以及可以通过一条命令搭建 fork 的 `apply-attack.sh` |
| [`attack/simulate-attack.js`](attack/simulate-attack.js) | 纯本地模拟(无需 GitHub 和 npm)——在演示者的机器上打开计算器 |
| [`SETUP.md`](SETUP.md) | 一次性设置:npm 账户、包名、OIDC 受信任发布者、仓库推送 |
| [`DEMO-SCRIPT.md`](DEMO-SCRIPT.md) | 现场演示时间安排,约 6 分钟,该点什么,该说什么 |
| [`fix/README.md`](fix/README.md) | 修复的逐行解释 |
| [`why/README.md`](why/README.md) | 为什么会存在这种不安全的模式 |
## 快速开始
三条路径,从最快到最具戏剧性:
### 路径 A —— 纯本地(无需 GitHub 和 npm)。约 30 秒。
```
npm install
node attack/simulate-attack.js
```
计算器打开。完成。这对于在登台实战版本之前证明链条有效非常有用。
### 路径 B —— 模拟实战(推荐用于演示)
1. 按照 [`SETUP.md`](SETUP.md) 操作:创建 npm 账户,手动发布 v0.1.0,配置受信任发布,推送仓库。
2. **预先布置**一次攻击([`DEMO-SCRIPT.md`](DEMO-SCRIPT.md) 的步骤 1-3),以使 v0.1.1 发布。
3. 在现场演示期间:执行步骤 4-5(观众安装,被攻陷,揭示真相)。
### 路径 C —— 在观众面前实战。约 6 分钟。
从头到尾遵循 [`DEMO-SCRIPT.md`](DEMO-SCRIPT.md)。观众观看攻击 PR 开启、缓存投毒、版本发布,然后安装其结果。
## 打包进发布构件中的内容
```
node_modules/is-number/index.js ← attacker plants payload here via fork PR
↓
esbuild
↓
dist/postinstall.js ← bundled output; published to npm in package files
↓
npm install
↓
postinstall hook runs
↓
`child_process.exec('open -a Calculator')` runs on consumer
```
合法的 `dist/postinstall.js`(由干净的 `node_modules` 构建)只打印 `"thanks for installing"`。被投毒的 `dist/postinstall.js`(由被投毒的 `node_modules` 构建)还会打开计算器。**维护者的源代码没有发生任何变化。**发生变化的只有打包产物——因为打包的输入被篡改了。
## 为什么这很难被检测到
- **维护者侧的审计看不出任何问题。** `main` 分支上没有恶意提交。发布提交是由 release-bot 发起的 `npm version patch`。发布的版本具有 GitHub 认证的来源证明,指向 `main` 上的一个真实且干净的提交。
- **恶意的 PR 已被关闭。** 它甚至可能都不会出现在仓库的主界面上。投毒步骤运行在归档于已关闭 PR 下的工作流日志中。
- **bundle-size 缓存的 diff 看起来无害。** 一个带有“预热构建缓存”注释的新 `scripts/dev-hook.js`。对 prepare 钩子的单字符微调。审查过该 PR 的人大多只会注意到 README 的错别字修复。
- **npm 来源证明对其进行了签名。** 来源证明是*正确的*——它准确说明了哪个工作流构建了哪个提交。只是该工作流碰巧打包了被攻击者投毒的缓存内容。
## 修复了什么
三项独立的更改,每一项都针对链条中的一个环节:
1. `pull_request_target` → `pull_request`。来自 Fork 的 PR 不再在基础信任上下文中运行。
2. 缓存键作用域划分。PR 工作流和发布工作流不能共享缓存。
3. `npm ci --ignore-scripts` + 拆分构建/发布作业。即使某个依赖项被攻陷,其代码也无法获取 `id-token: write` 权限。
参见 [`fix/README.md`](fix/README.md)。
## 消费端缓解措施
从消费端来看,针对此类攻击最有效的单一防御手段是拒绝安装刚刚发布不久的包版本。npm 10+ 原生支持此功能:
```
npm config set minimum-release-age 10080 # 7 days, in minutes
```
设置此项后,通过缓存投毒链条发布的恶意版本将会有 7 天的时间供人发现并在任何消费端的 CI 真正安装它之前被取消发布。结合在 CI 中使用的 `npm audit` / `osv-scanner`,这可以干净利落地捕获大多数已知的安全入侵场景。
注意:要在设置了此配置的情况下安装**此演示包**,请在安装命令后附加 `--minimum-release-age=0`——这是一个不影响默认配置的单次调用覆盖。
## 演示工作流如何在没有令牌的情况下发布
发布工作流使用了 **npm OIDC 受信任发布**——任何地方都没有设置 `NPM_TOKEN` 密钥。GitHub Actions 生成一个短期 JWT,npm 根据包的受信任发布者配置对其进行验证,随后 `npm publish` 成功。来源证明由 Sigstore 签名。
这是现代推荐的发布模式,被主流开源项目广泛使用。这个演示的重点在于,这种现代化*并不能*让你免受缓存投毒的危害——如果攻击者成功将他们的字节植入到发布作业的 `dist/` 中,他们就能免费获得注册中心的信任背书。
## 清理
演示结束后:
```
# 取消发布恶意版本(在 72 小时内或对于没有 dependents 的 packages):
npm unpublish @0.1.1
# 或者取消发布所有内容:
npm unpublish --force
# 重置本地 working tree:
git restore .
rm -rf node_modules dist
npm install
```
## 参考
- [TanStack 事后分析](https://tanstack.com/blog/npm-supply-chain-compromise-postmortem)
- [TanStack 事件跟进](https://tanstack.com/blog/incident-followup)
- [GitHub 安全实验室:保持你的 GitHub Actions 安全 (第 2 部分)](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/)
- [npm 受信任发布文档](https://docs.npmjs.com/trusted-publishers)
标签:CI/CD安全, DevSecOps, GitHub Actions, GitHub Advanced Security, JSONLines, Llama, MITM代理, npm安全, 上游代理, 供应链攻击, 安全加固, 教育演示, 暗色界面, 漏洞复现, 缓存投毒, 自动笔记, 自定义脚本, 软件供应链, 零信任