GitHub Actions 缓存投毒:开源发布流水线里最容易被忽略的入口
如果你维护的是公开仓库,而且仓库里带有发布流程,那么有一类攻击现在必须单独拿出来审计:GitHub Actions cache poisoning。
它不是某个单点漏洞,而是一条很稳定的利用链中的关键一环。过去两年里,这类攻击已经反复出现在开源生态里:Angular 的研究性披露、tj-actions/changed-files 的下游扩散、Cline 的发布链被劫持、以及 TanStack 的 npm 包被批量植入恶意版本。入口各不相同,但缓存经常是后续提权和落地的那一步。
问题的本质不复杂:低信任工作流能写入与高信任发布工作流共享的缓存池。一旦攻击者把恶意依赖目录、编译产物,或者任何可执行内容写进未来发布任务会命中的 cache key,下一次发布就会把这些内容“正常恢复”到 runner 上执行。
这不是边角案例,而是结构性风险。
缓存投毒到底是什么
绝大多数 CI 都会花不少时间在装依赖上:
npm installpnpm installpip install- 下载 Rust crates
- 构建原生模块
在全新 runner 上,每次都从零开始会很慢。GitHub Actions 提供了缓存机制:工作流完成安装后,可以把某个目录存起来,例如 node_modules 或 pnpm store,并给它一个 key。后续工作流只要请求相同 key,就能直接恢复这份目录,省掉重复安装。
典型 key 长这样:
Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}
做法本身没问题:锁文件变了,hash 变了,就拿新缓存;锁文件没变,就复用旧缓存。
危险在于:同一个仓库内,缓存池默认是共享的。不同分支、不同触发器、不同 job,都会读写同一个缓存空间。仓库总共最多有 10 GB 可用缓存。
这意味着:
- 昨天 PR 工作流写入的缓存
- 今天 release 工作流读取的缓存
完全可能是同一个 key。
这正是缓存有用的地方,也是它危险的地方。
如果攻击者能向这个共享缓存池写数据,就可以预先种下一份“看起来合法、实际上被污染”的缓存内容。后续高权限工作流一恢复缓存,恶意代码就在合法步骤运行前已经落到了 runner 上。
攻击者如何拿到缓存写权限
常见有两种路径。
直接写入
攻击者先让一个高权限工作流执行自己控制的代码,然后计算出发布工作流会使用的 cache key,再把恶意内容写进去。
这条链已经多次出现:
- 利用
pull_request_target工作流 checkout PR 代码 - 利用 AI issue triage 机器人发生 prompt injection
- 利用其他能在 runner 上执行任意代码的工作流入口
TanStack 走的是前者。Cline 走的是后者。
驱逐后重写
GitHub Actions 的缓存条目一旦写入就是不可变的,不能直接覆盖已有 key。
所以如果目标 key 已经被正常缓存占用,攻击者会先往缓存池里灌 10 GB 垃圾,利用 GitHub 的 LRU 淘汰机制把原有缓存挤掉,然后再用相同 key 写入恶意缓存。
这类操作已经有 PoC 工具自动化,典型工具是 Cacheract。
命中后的效果
一旦缓存被投毒,攻击者只需要等。
等下一次发布工作流运行,正常恢复缓存。然后恶意代码会在一个具备发布能力的工作流里执行,并接触到发布凭据或等价能力。
这类攻击为什么一直有效
1. 缓存跨越了信任边界
PR 工作流、定时任务、issue 机器人、release 工作流,默认都读写同一个缓存池。
GitHub Actions 没有内建“按信任级别隔离缓存”的机制,也没有“只允许生产工作流使用”的缓存标记。
2. permissions: contents: read 并不能阻止缓存写入
很多团队会下意识把工作流权限收紧到:
permissions:
contents: read
这有价值,但对缓存投毒不够。缓存使用的是 runner 内部的独立 token,而不是工作流的 GITHUB_TOKEN。也就是说,哪怕你把 GitHub API 权限降得很低,runner 依然可能有能力写缓存。
3. OIDC trusted publishing 缩短了凭据生命周期,但把工作流内每个步骤都变成了“可发布步骤”
从长期 NPM_TOKEN 迁移到 OIDC trusted publishing 是对的,这能消灭一大类静态 token 被窃取的问题。
但代价是:只要工作流具备 id-token: write,这个工作流里的每个步骤理论上都能申请 OIDC token。token 会在短时间内出现在 runner worker 进程内存中,任何在 runner 上执行的代码都可能去读它。
所以 OIDC 不是“用了就安全”,而是“用了之后,工作流内部边界必须更干净”。
4. pull_request_target 是最常见入口,但不是唯一入口
这类攻击最常见的起点确实是 pull_request_target,因为它会用基仓库权限执行,即使 PR 来自 fork。
但任何能在共享缓存上下文里运行不可信输入的工作流,都可能成为入口。Cline 的事故就不是从 pull_request_target 进来的,而是从 issues: opened 触发的 AI triage 流程开始的。
先做排查:今天就能在仓库里跑的 6 组审计
建议先从你最重要、权限最高的仓库开始。下面这些命令默认仓库里有 .github/workflows/ 目录。
审计 1:所有 pull_request_target 工作流
先找出来:
grep -rn "pull_request_target" .github/workflows/
然后逐个确认一个问题:它是否 checkout 或执行了 PR 里的代码?
重点看三类模式。
危险模式 1:显式 checkout PR 代码
- uses: actions/checkout@v4
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
# 下面这些同样危险:
# ref: ${{ github.event.pull_request.head.sha }}
# ref: ${{ github.event.pull_request.head.ref }}
这些 ref 指向的都是 PR 代码,不是基仓库代码。一旦 checkout,下游步骤就可能执行 PR 带进来的脚本、配置和锁文件。
危险模式 2:从 PR 控制的文件安装依赖
例如:
npm installpnpm installpip installcargo buildgo build
只要这些命令读取的是 PR 提交中的 package.json、pnpm-lock.yaml、requirements.txt、Cargo.toml 等文件,攻击者就能通过生命周期脚本或构建过程拿到执行机会。
危险模式 3:直接运行 checkout 下来的脚本
例如:
pnpm run buildnpm test./scripts/setup.sh
这类命令本质上就是在执行 PR 代码。
如果这三类模式中的任意一种出现在 pull_request_target 工作流里,那就是 GitHub Security Lab 所说的 Pwn Request。
审计 2:所有插值了不可信输入的工作流
grep -rnE '\$\{\{\s*github\.event\.(issue|pull_request|comment)\.' .github/workflows/
这里要找的是任何把以下内容直接塞进 shell run: 或 AI agent prompt 的地方:
- issue title
- issue body
- comment body
- PR title
- PR body
这些字段全部是攻击者可控输入。
- 流进
run:,是脚本注入 - 流进 AI 提示词,是 prompt injection
结果都可能变成 runner 上的任意代码执行。
审计 3:所有带 id-token: write 的工作流
grep -rn "id-token" .github/workflows/
每一处命中都意味着:这个工作流具备发布能力。
对于每个这样的工作流,把所有步骤列出来,包含:
- 第三方 action
- setup action
- shell script
- 自己维护的复用 action
逐个问:如果这个步骤被攻陷,它能不能利用 OIDC token 去发布?
如果答案是能,这个步骤就在你的发布信任边界内。
审计 4:所有用 tag 而不是 commit SHA 固定版本的第三方 action
查找方式:
grep -rn "uses:" .github/workflows/ | grep -v "uses: \./" | grep -v "@[0-9a-f]\{40\}"
这个命令会列出:
- 所有
uses: - 排除本地 action,如
uses: ./something - 排除已经固定到 40 位 SHA 的 action
剩下的就是:
@v4@v3@main- 其他 branch/tag 引用
这类引用的风险点很明确:tag 和 branch 都是可变的。
例如:
uses: actions/checkout@v4
你以为拿到的是某个稳定版本,但 action 仓库所有者可以随时把 v4 指向新的 commit。如果维护者账号被钓鱼、token 泄漏或机器失窃,攻击者就能重打 tag,把恶意代码推给所有消费方。
tj-actions/changed-files 影响 23,000+ 下游工作流,就是这类机制造成的。
正确做法是固定到 commit SHA:
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v3.0.0
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
即使是你们组织内部维护的 action 仓库,也一样要固定 SHA。内部仓库同样可能被重写引用。
审计 5:检查缓存 key 是怎么设计的
grep -rn "actions/cache" .github/workflows/
grep -rn "cache:" .github/workflows/
对每个缓存配置,重点看 key:
- 发布工作流和 PR 工作流是不是用了相同 key
- key 是否只基于 lockfile hash
- 是否给 release 流程单独做了前缀隔离
如果你用了下面这类自动缓存:
pnpm/action-setup的cache: trueactions/setup-node的包管理器缓存
那么大概率 PR 和 release 正在共享 cache key,因为默认 key 往往只是锁文件 hash。
审计 6:npm scope 与发布权限
这一项不在 GitHub Actions 里,但同样属于发布面。
对每个发布包:
npm access list collaborators <package-name>
对每个 npm 组织:
npm team ls <org>:developers
npm team ls <org>:publishers
看有没有这些不该存在的账号:
- 已经离职的人
- 长期不活跃账号
- 以前的外部贡献者
- 还挂着权限的个人账号
再看历史发布时间线:
npm view <package> time --json
有任何你认不出来的发布时间点,都要当事故处理。
同时检查组织策略:
- 是否要求所有写操作必须开启 2FA
- 是否仍允许 SMS 作为 2FA 方式
SMS 在 2026 年已经不能算可靠二次验证,TOTP 或 WebAuthn 才值得信任。
修复优先级:先做什么,后做什么
下面按优先级排序。前几项不是“最佳实践”,而是止血动作。
1. 删除所有会 checkout PR 代码的 pull_request_target
如果 PR 事件上根本不需要写权限或 secrets,就把触发器直接换成 pull_request。
# before
on:
pull_request_target:
paths: ['packages/**']
# after
on:
pull_request:
paths: ['packages/**']
pull_request 在 fork PR 上默认只读、不给 secrets,正适合跑构建、测试、benchmark。
如果你确实需要在 PR 事件里执行高权限动作,比如:
- 打 label
- 回评论
- 回写检查结果
那就拆成两个工作流:
pull_request_target:只处理 PR 元数据,不运行 PR 代码pull_request:执行真正的构建和测试,只用低权限上下文
如果实在保留不了某个会接触 PR 代码的 pull_request_target,至少加同仓保护,只允许基仓库内部 branch 的 PR 运行:
jobs:
benchmark:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
这不是根治,只是额外一层防线。
2. 对可发布工作流禁用缓存,或者做严格隔离
最简单、也最值得优先做的修复,是在所有带 id-token: write 的工作流里直接关掉缓存。
# release.yml
jobs:
publish:
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@<sha>
- uses: pnpm/action-setup@<sha>
- uses: actions/setup-node@<sha>
with:
node-version: 20
# 不设置 `cache: 'pnpm'`
- run: pnpm install --frozen-lockfile
- run: pnpm publish
发布工作流本来就不会高频执行。省下那几十秒,不值得拿发布安全去换。
如果一定要保留缓存,就用 actions/cache/restore 与 actions/cache/save 分离控制,并且给 release 专门做不同的 key 前缀:
- uses: actions/cache/restore@<sha>
with:
path: ~/.local/share/pnpm/store
key: release-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
# 与 PR 工作流使用不同前缀
3. 所有第三方 action 固定到 commit SHA
把下面这种:
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
替换成:
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v3.0.0
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
版本注释保留着就行。Dependabot 和 Renovate 都支持 SHA pinning,会在新版本发布时自动提 PR 更新 SHA 和注释。
4. 把不可信输入当不可信输入,尤其是 AI agent 周边
不要把攻击者可控字段直接插进 run::
# bad
- run: echo "Triaging issue: ${{ github.event.issue.title }}"
# good
- run: echo "Triaging issue: $TITLE"
env:
TITLE: ${{ github.event.issue.title }}
对 shell 来说,这至少避免了直接拼接命令。
对 AI agent,更关键的是收工具权限。如果一个由 issues: opened 触发的工作流给 agent 开了:
BashReadWriteEdit
那 prompt injection 的后果基本就是 runner 任意代码执行。
没有非常具体的理由,不要给这类工作流 Bash。
5. 把 zizmor 或 actionlint 设成必过检查
zizmor 是专门分析 GitHub Actions 工作流的静态检查器,能抓到很多高危模式:
pull_request_target+ checkout PR- tag pinning
- 不可信输入插值
- 其他工作流安全问题
最小配置示例:
# .github/workflows/lint-workflows.yml
name: Lint workflows
on:
pull_request:
paths: ['.github/workflows/**']
jobs:
zizmor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
- uses: woodruffw/zizmor-action@<sha>
然后把它加入 branch protection 的 required checks。
6. 给 .github/ 配置 CODEOWNERS
能改 .github/workflows/ 的人,实质上就能改 CI 安全边界。
把这个目录锁给极少数核心维护者:
# .github/CODEOWNERS
/.github/ @your-org/core-maintainers
再配合 branch protection 要求 CODEOWNERS review,能显著降低维护者账号被拿下后直接改工作流的风险。
7. 还没迁移的话,尽快切到 OIDC trusted publishing
不要因为这类事故就退回长期 NPM_TOKEN。OIDC 仍然更好。
长期 token 的问题是:
- 任何能读 secrets 的工作流都能拿到它
- 不轮换就一直有效
- 一旦泄漏,影响时间跨度很长
OIDC token 只在单次工作流内短时有效。真正需要补上的,是前面几项:清理入口、收紧缓存、减少工作流内部攻击面。
最小发布工作流可以是这样:
name: Release
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@<sha>
- uses: actions/setup-node@<sha>
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish --provenance --access public
这里的 --provenance 会附带由哪个 GitHub 工作流构建该 tarball 的签名声明,便于下游校验产物来源。
8. GitHub 和 npm 全面强制非 SMS 的 2FA
适用对象是所有拥有发布权限的人,没有例外。
组织级别要做两件事:
- GitHub 要求 2FA,并禁用 SMS
- npm 组织要求写操作必须开启 2FA
允许 SMS,本质上还是给钓鱼和 SIM swap 留了口子。
9. 给包管理器配置安装冷却时间
这是对未知未来供应链攻击最有性价比的被动防御之一。
已知配置包括:
- pnpm 11+ 默认
minimumReleaseAge = 1440分钟,也就是 24 小时 - yarn 4+ 支持
npmMinimalAgeGate - uv 支持
exclude-newer - bun 也有类似设置
pnpm 示例:
# pnpm-workspace.yaml
minimumReleaseAge: 4320 # 分钟,72 小时
minimumReleaseAgeExclude:
- '@my-org/*' # 内部包跳过限制
如果 2026-05-11 当天你的依赖安装策略带有 24 小时冷却,就不会装上那批很快被下架的受污染 TanStack 版本。
10. 把 AI agent 配置文件当源代码管理
一些攻击载荷会把下面这些文件写进仓库:
.claude/settings.json.claude/setup.mjs.vscode/tasks.json
这些文件会配置 AI 编码代理或编辑器在打开项目时执行任意代码。
它们不是“普通配置”,而是代码执行入口。应该:
- 纳入版本控制
- 在 PR 里严格审查变更
- 加入 CODEOWNERS
如果已经中招,先别急着撤销凭据
预防和处置是两回事。
如果你在受影响版本发布窗口里执行过 npm install,先假定本机已经被植入持久化机制。一个已观察到的载荷会安装“死人开关”式监控程序:它持续检测被窃取的 GitHub token 是否仍然有效,一旦发现 token 被撤销,就执行破坏动作。
已观察到的落点包括:
- Linux:
~/.local/bin/gh-token-monitor.sh,并注册成 systemd user service - macOS:LaunchAgent,名称为
com.user.gh-token-monitor
它会每 60 秒请求一次 api.github.com/user。如果返回 40x,说明 token 已失效,随后执行:
rm -rf ~/
所以顺序不能错:先找持久化,再撤销凭据。
检查命令:
# Linux
systemctl --user list-units
ls -la ~/.config/systemd/user/
# macOS
launchctl list | grep gh-token-monitor
ls -la ~/Library/LaunchAgents/
而且这很可能不是全部持久化机制。最稳妥的做法仍然是:把安装过受污染依赖的主机直接重装。
确认持久化被移除后,再统一轮换该主机可接触到的所有凭据:
- AWS
- GCP
- Kubernetes
- Vault
- GitHub
- npm
- SSH
一份更务实的结论
GitHub Actions 缓存投毒不是某个“补丁打完就结束”的具体漏洞,而是 GitHub Actions 当前缓存共享模型带来的结构性问题。
只要低信任与高信任工作流还共享缓存池,攻击者就会持续寻找新的进入点:
pull_request_targetcheckout PR- AI triage 里的 prompt injection
- 被污染的第三方 action
- 未来还没被广泛注意到的其他自动化入口
真正有效的应对方式不是等事故复盘,而是现在就开始做两件事:
- 把发布工作流和其他工作流之间的信任边界重新画清楚
- 把所有跨边界共享的缓存、输入和执行点逐项收紧
如果只能先做少数几项,优先级建议如下:
| 优先级 | 动作 |
|---|---|
| P0 | 删除所有会运行 PR 代码的 pull_request_target |
| P0 | 在带 id-token: write 的发布工作流里禁用缓存 |
| P1 | 所有第三方 action 固定 SHA |
| P1 | 审计并移除不可信输入进入 run: 和 AI prompt 的路径 |
| P1 | 给 .github/ 配置 CODEOWNERS,加入 zizmor/actionlint |
| P2 | 迁移 OIDC、清理 npm 发布权限、强制非 SMS 2FA |
| P2 | 为依赖安装增加最小发布时间门槛 |
开源维护者最怕的不是某个 bug,而是把“方便”误当成了“默认可信”。GitHub Actions 的缓存机制,正好就是这类误判最容易发生的地方。
关于
关注我获取更多资讯