GitHub Actions 缓存投毒:开源发布流水线里最容易被忽略的入口

系统梳理 GitHub Actions cache poisoning 的攻击路径、排查方法与修复优先级,帮助维护者识别高风险工作流并收紧 npm 发布链路。

阅读时长: 11 分钟
共 5265字
作者: eimoon.com

GitHub Actions 缓存投毒:开源发布流水线里最容易被忽略的入口

如果你维护的是公开仓库,而且仓库里带有发布流程,那么有一类攻击现在必须单独拿出来审计:GitHub Actions cache poisoning

它不是某个单点漏洞,而是一条很稳定的利用链中的关键一环。过去两年里,这类攻击已经反复出现在开源生态里:Angular 的研究性披露、tj-actions/changed-files 的下游扩散、Cline 的发布链被劫持、以及 TanStack 的 npm 包被批量植入恶意版本。入口各不相同,但缓存经常是后续提权和落地的那一步。

问题的本质不复杂:低信任工作流能写入与高信任发布工作流共享的缓存池。一旦攻击者把恶意依赖目录、编译产物,或者任何可执行内容写进未来发布任务会命中的 cache key,下一次发布就会把这些内容“正常恢复”到 runner 上执行。

这不是边角案例,而是结构性风险。

缓存投毒到底是什么

绝大多数 CI 都会花不少时间在装依赖上:

  • npm install
  • pnpm install
  • pip 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 install
  • pnpm install
  • pip install
  • cargo build
  • go build

只要这些命令读取的是 PR 提交中的 package.jsonpnpm-lock.yamlrequirements.txtCargo.toml 等文件,攻击者就能通过生命周期脚本或构建过程拿到执行机会。

危险模式 3:直接运行 checkout 下来的脚本

例如:

  • pnpm run build
  • npm 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-setupcache: true
  • actions/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
  • 回评论
  • 回写检查结果

那就拆成两个工作流:

  1. pull_request_target:只处理 PR 元数据,不运行 PR 代码
  2. 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/restoreactions/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 开了:

  • Bash
  • Read
  • Write
  • Edit

那 prompt injection 的后果基本就是 runner 任意代码执行。

没有非常具体的理由,不要给这类工作流 Bash

5. 把 zizmoractionlint 设成必过检查

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_target checkout PR
  • AI triage 里的 prompt injection
  • 被污染的第三方 action
  • 未来还没被广泛注意到的其他自动化入口

真正有效的应对方式不是等事故复盘,而是现在就开始做两件事:

  1. 把发布工作流和其他工作流之间的信任边界重新画清楚
  2. 把所有跨边界共享的缓存、输入和执行点逐项收紧

如果只能先做少数几项,优先级建议如下:

优先级 动作
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 的缓存机制,正好就是这类误判最容易发生的地方。

关于

关注我获取更多资讯

月球基地博客公众号二维码,扫码关注获取更多 AI 与编程资讯
📢 公众号
月球基地博客作者个人微信二维码,扫码交流 AI 与编程话题
💬 个人号
使用 Hugo 构建
主题 StackJimmy 设计