site logo

Marico's space

GitHub Actions 手动 fork 触发只读缓存令牌

AI技术与应用 2026-06-30 17:34:11 5

最近折腾了 GitHub Actions 的缓存机制,踩了一个安全策略升级的坑,这篇把问题说清楚。

缓存是 CI(持续集成)流水线里没人会做威胁建模、直到它咬你一口才后悔的那部分。它快、方便,大家都当它是只读状态,偏偏它是流水线里少数攻击者可以在不克隆仓库的情况下写入数据的地方。(你做缓存的威胁建模了吗?)

2026年6月26日,GitHub 发布了一条更新:"不可信触发的只读 Actions 缓存",把缓存写入重新定义为一种权限,而新的默认值是"不允许写入"。

把缓存当作写入目标来思考

想想默认分支上的一个缓存条目本质上是什么。一块数据,由 GitHub 存储,键名由你的工作流指定,下一个在默认分支上运行的 Job 会在执行前静默提取它。如果任何互联网上的人都能触发的事件被允许写入这块数据,那你的受保护分支现在实际上在执行一个陌生人构造的代码。签名提交策略拦不住这个。必须 review 也拦不住。这个产物永远不会出现在你的仓库里。它只是在下次构建运行到两分钟时悄悄出现在 ~/.cache 里。

6月26日发生了什么

GitHub 把这个变更描述为对 Actions 缓存应用最小权限原则。操作层面来说:当两个条件同时满足时,工作流的缓存令牌就是只读的。

  1. 触发事件是不可信的,定义为仓库协作者以外的人可以触发的事件。
  2. 工作流的执行上下文和缓存范围来自共享的默认分支 SHA(Secure Hash Algorithm,安全散列算法)。

GitHub 明确列出了不可信触发器:pull_request_targetissue_comment,以及 fork pull-request 的 workflow_run 级联触发。这些正是多年来"夺权请求(pwn request)"漏洞的粘合剂,因为它们允许 fork 的载荷以目标仓库的身份运行。现在,只读是它们的最低保障。

保持读写权限的列表也是明确的:pushscheduleworkflow_dispatchrepository_dispatchdeleteregistry_packagepage_build。这些来自已经在信任边界内的主体,所以它们继续写入默认分支的缓存。非默认分支的范围(如 pull_requestrelease)也保持读写,因为攻击者无法利用它们的缓存范围来影响默认分支。

无需 opt-in。也没有开关可以切换。下次触发器激活时,这个变更就生效了。

你需要手动连接的部分

对大多数团队来说,坏掉的是那些通过评论或 fork 触发的工作流——它们以前把填充缓存当作运行时的副作用。现在这个副作用没了。GitHub 的指导是把缓存保存移到本身由可信触发器(通常是 push)驱动的工作流中,让不可信的工作流读取可信工作流写入的内容。

最小化的拆分大概长这样:

# .github/workflows/cache-warm.yml
on: push: branches: [main]
jobs: warm: runs-on: ubuntu-latest steps: - uses: actions/checkout@<full-40-char-sha> - uses: actions/cache/save@<full-40-char-sha> with: key: deps-${{ hashFiles('package-lock.json') }} path: node_modules

然后 PR 目标工作流调用 actions/cache/restore@... 而不是 actions/cache@...。在可信的一侧写入。在不可信的一侧读取。fork 载荷做的任何事都不应该决定下周默认分支构建里会出现什么。

这个变更在各 CI 平台上的位置

把缓存当作信任边界的问题并非 GitHub 独有。其他平台处理这个问题的方式各有不同。

  • GitHub Actions 现在是唯一把这个分离作为平台默认配置的。如果 fork PR 的缓存投毒在你的威胁模型里,这是目前最贴合的方案;其他平台仍然需要你在配置里自己动手。
  • GitLab CI/CD 按键名划分缓存范围,暴露了一个 policy: pull 设置可以把 Job 变成只读消费者。信任分离需要你自己在受保护分支和合并请求策略里接线。
  • CircleCI 使用内容寻址的缓存键。阻止 fork 流水线写入默认分支的键是项目配置层面的步骤,而不是默认行为。
  • Bitbucket Pipelines 按仓库划分缓存范围,没有内置的不可信触发器隔离。重视这个问题的团队通常会把不可信代码拆分到单独的流水线。
  • Jenkins 把缓存当作运维人员的事情。代理是否在 fork 构建之间共享卷取决于你的代理策略,而不是 Jenkins 原语提供的功能。
  • Buddy 维护一个不跨流水线共享的每流水线文件系统缓存。一种可行的形态是把缓存预热步骤放在 push 触发的流水线里,让 PR 流水线只读挂载那个缓存。具体原因是流水线边界同时充当了信任边界;缺点是你仍然需要自己编写这个策略,而这恰恰是 GitHub Actions 已经领先的领域。

剩余的问题

只读是地板,不是天花板。可信触发器内部的缓存污染仍然可能发生。键名仍然可以通过构建自身产生的文件内容被攻击者影响。一个不做验证就恢复缓存的工作流实际上是在运行 blob 里的任何东西。令牌上的最小权限是容易的那一半。验证取出来的东西仍然是你自己的责任。

信任缓存的程度,不要超过写它的那个人。