GitHub Actions 是最薄弱环节GitHub Actions is the weakest link
作者 Anne Nesbitt 强烈批评 GitHub Actions 作为 CI/CD 工具的安全性和可靠性问题。她指出 .github/workflows 文件缺乏权限隔离、易受供应链攻击,且默认配置过于宽松。通过对比 GitLab CI 和 CircleCI 等竞品,她认为 GitHub Actions 在安全设计上存在严重缺陷。建议企业应谨慎使用或自行封装以降低风险。
Andrew Nesbitt
在过去十八个月里,几乎任何一起开源供应链事件都可以追溯到 .github/workflows YAML 文件。Ultralytics 向 PyPI 发布了一个加密货币挖矿程序,nx 包将数千台开发者机器变成了凭证窃取工具,tj-actions 从 23,000 个仓库泄露了 secrets,Trivy 在三周内两次被入侵,elementary-data 在陌生人留下 GitHub 评论十分钟后发布了恶意 wheel 包。尽管攻击手法各异、受害者不同,但每次都是 GitHub Actions 功能完全按照文档说明运行所致。
我在去年十二月曾指出 Actions 的一个核心问题:它本质上是一个没有 lockfile、没有完整性哈希校验、也没有传递依赖可见性的包管理器,而 uses: 行实际上是对可变 git 标签的依赖声明,运行器每次执行都会重新解析。这一论点至今依然成立,并在生产环境中得到了充分验证,但它只是更大问题的冰山一角。
整个产品由一系列各自便利的功能组成,这些功能单独看都很容易使用,但组合起来极易构成危险。构建和发布全球大部分开源项目的工作流程运行在一个平台上,该平台默认配置最初是为私有企业 CI 工具设计的,从未真正针对匿名 fork 和即点即用的 pull request 进行重新审视。
事件回顾
近期事件链中最早的是 2024 年 11 月的 spotbugs,其工作流使用了 pull_request_target 触发器,该触发器会检出并构建来自不受信任 fork 的代码。此触发器的存在是为了让工作流能够执行诸如为 fork 的 PR 添加标签等操作,为此它会在基础仓库的上下文中运行,拥有完整的 secret 访问权限和一个具有写入范围的 token。
结合对 fork 的 head.sha 的检出,攻击者即可在你的信任边界内获得代码执行权限——这正是 spotbugs 所发生的情况:一个恶意 PR 获取了维护者的个人访问令牌(PAT),该 PAT 有权访问 reviewdog,四个月后同一攻击者利用它发起了 tj-actions/changed-files 的攻击。自 2021 年起,GitHub 官方文档就已警告过这种组合的风险,至今仍将该触发器直接提供,除了文档中的一段说明外,没有任何防护措施。
在 spotbugs 事件一个月后,Ultralytics 遭遇了同样的触发器攻击,但采用了不同的第二阶段手段。由于 fork 的 PR 无法直接获取发布凭据,攻击者转而污染了一个 GitHub Actions 缓存条目;当合法的发布工作流稍后恢复该缓存时,在构建 wheel 的过程中执行了恶意 payload。两个版本的 ultralytics 因此包含了挖矿程序并上传至 PyPI。
缓存以分支为键,并向下共享给子级;pull_request_target 作业默认在主干分支上运行;无论是用户界面还是 API 都不会提示你某个条目是由处理不受信任输入的作业写入的。
2025 年 3 月的 tj-actions 事件最广为人知,因为 CISA 发布了相关通报,且原始目标最终被确认为 Coinbase。攻击者利用从 spotbugs 事件中窃取的 PAT,向 reviewdog/action-setup 推送了一个恶意 commit,并将 v1 标签指向该 commit。tj-actions/eslint-changed-files 通过标签引用了 reviewdog,tj-actions/changed-files 又引用了前者,而 23,000 个下游仓库则通过标签引用了 changed-files。它们无一例外地运行了一个内存抓取器,将 runner 的 secrets 输出到公开的构建日志中。
出错的平台特性在于:操作版本使用的是他人仓库中的 git 引用,任何拥有该仓库写入权限的人都可以强制推送修改,并且默认通过一个移动标签(而非内容哈希值)来获取这些引用。
未固定的标签是扫描公共工作流时最常见的发现项,但我怀疑其中相当一部分风险可以在不修改用户 YAML 文件的前提下,由操作加载器自身解决。GitHub 将一个仓库及其所有 fork 存放在同一个共享对象池中,运行时会根据 uses: owner/action@<ref> 的引用在池内查找匹配项。因此,即使某个 SHA 只存在于陌生人的 fork 中,且从未在主分支上出现或经过审查,也能通过父命名空间被拉取,仿佛维护者自己将其放到了那里。Chainguard 早在 2022 年就将此现象记录为“冒名提交”。
恶意 tj-actions 提交是一个游离对象,不属于仓库的任何分支,但 runner 仍执行了它,因为某个标签指向了该提交。如果加载器能验证解析出的 SHA 是否可从主仓库的分支到达,而不仅仅是在 fork 网络中存在,那么标签劫持就需要真正推送到真实分支,SHA 固定才能真正代表代码曾出现在上游过。
8 月出现了 s1ngularity 事件,nx 构建系统的仓库中包含一个 pull_request_target 工作流,该工作流会将拉取请求标题插入 shell 步骤。$ 模板语法会在 shell 看到脚本之前展开,因此一个标题包含命令替换的拉取请求就会变成可执行的代码,加之触发条件满足,这段代码便使用 npm 发布令牌的作用域成功运行。
后续出现的恶意 nx releases 开始在开发者机器上搜索 AI 编程助手凭据,并利用它们枚举和窃取私有仓库。这也解释了为何一条未经清理的字符串最终导致五千多个私有仓库被短暂公开。
到 2026 年,攻击者不再逐个寻找漏洞,而是开始发动大规模攻击。prt-scan 行动在 3 月和 4 月期间持续六周,针对存在 pull_request_target 配置错误的仓库批量发起数百个拉取请求,使用临时账号轮换,并生成符合语言习惯的差异补丁以伪装成合理贡献,直到触发工作流为止。
几乎同时,Trivy 的操作仓库也遭到入侵——同样是利用 pull_request_target 工作流,攻击者在 2 月下旬发现了这一漏洞。Aqua 团队进行了修复,但凭据轮换并非原子操作;三周后,同一攻击者利用首次事件中获取的令牌,强制推送了 77 个历史版本标签中的 76 个,导致即使用户固定到旧版“已知安全”的 @0.x.y,仍会运行凭据窃取程序。
上周,一个仅存在两天的 GitHub 账号在一个旧的 elementary-data 拉取请求下留言。该仓库有一个监听 issue_comment 的工作流,会将 $ 符号输出到 bash 中,评论正文恰好闭合了 echo 字符串并 curl 了一个启动器,而由于工作流未设置权限限制,默认获得了写作用域的 GITHUB_TOKEN。十分钟内,该启动器伪造了 github-actions[bot] 作者身份推送了一个提交,触发现有发布工作流,并将凭据窃取轮子上传至 PyPI,镜像推送至 GHCR,整个过程无需维护者接受 PR、点击按钮或保持清醒。
共同因素
说实话,我不认为上述维护者中有谁在做什么特别出格的事。这些工作流看起来就像 GitHub 文档里的示例,也和其他成千上万个仓库的工作流没什么两样。
把这些事件并列来看,GitHub Actions 的某些功能反复出现:使用 pull_request_target 和 issue_comment 触发器运行不受信任事件的完整 secrets 工作流、未加引号的 $ 展开将文本直接代入 shell 脚本、GITHUB_TOKEN 在 2023 年 2 月之前创建的仓库中默认拥有写入权限、使用可变 git 引用版本的 action,以及静默跨越信任边界的缓存。
严格来说,这些都不是 bug,据我所知它们也不会消失。部分功能已在文档中加入警告提示,pull_request_target 也在去年十一月调整了行为,总是从默认分支读取工作流文件,但真正能阻止上述大多数问题的改动——即不对从未接触过仓库的人触发的工作流授予写入令牌和密钥——并未发生。
每个漏洞工作流至少会触发 zizmor 默认规则集中的一项审计:dangerous-triggers(针对 spotbugs、Ultralytics、nx、prt-scan 和 Trivy)、cache-poisoning(针对 Ultralytics 的权限提升)、unpinned-uses(针对 tj-actions 和 Trivy 下游的所有项目)、template-injection(针对 nx 和 elementary-data),以及 excessive-permissions(针对导致 elementary-data 注入成为发布的默认写入令牌)。其中 elementary-data 的问题在我为即将发表的演讲运行 zizmor 扫描所有 PyPI 包的工作流时就已出现在结果中,比评论发布早了三周就被标记为高/高风险。
在仓库中添加 zizmorcore/zizmor-action 大约只需四行 YAML 代码,这可能是维护者目前能做的最有用的措施——除非完全放弃 GitHub Actions,我也能理解这种选择。我这么说是在强烈推荐这个工具,但对平台而言,最好的防御手段竟是一个主要由一人维护的第三方 linter,它能发现 GitHub 自己埋下的“脚枪”并加以拦截,这多少有点令人不安。
可信发布
我之所以一直纠结于此而非其他十几种可能泄露包的方式,是因为包注册机构集体押注于它。PyPI、npm、RubyGems 和 crates.io 都已采用基于 OIDC 的可信发布机制,专门为了把长期有效的 API 令牌从仓库密钥中剥离出来,这确实比 PYPI_API_TOKEN 在仓库里存几年后最终出现在某人的 dotfiles 里要好得多。但这意味着这些注册机构的安全性现在大致取决于持有 id-token: write 权限的 GitHub Actions 工作流的强度。
我们花了十年时间通过 lockfiles、强制双因素认证(2FA)、签名、审计日志和来源证明来加固包管理器,将所有这些都接入 OIDC 后,原本分散在数千个维护者凭证上的信任被集中到了一个本身不具备这些安全特性的 CI 平台上。虽然还有其他可信发布身份提供商,比如 GitLab 和 Google Cloud Build,但实践中绝大多数发往大型注册表的 OIDC 发布都来自 GitHub 托管的 runner。如今,想要将恶意内容上传到 PyPI 或 npm 的攻击者,往往更倾向于直接攻击工作流文件,而不是钓鱼维护者,这就给 GitHub 带来了巨大压力,必须确保其安全性。
GitHub 的回应
GitHub 上个月确实发布了一份安全路线图,值得肯定的是其中包含了一些切实可行的修复措施:为工作流添加 lockfile,将直接和间接的 action 依赖项固定到 SHA;提供策略控制,可彻底禁止 pull_request_target;将 secrets 的作用域限定在特定工作流而非整个仓库;为托管 runner 设置出口防火墙。我对此感到沮丧的是,所有改进都是“可选”的,全部标注为“三到六个月内进入公开预览”,而那个要求引入 lockfile 的问题早在三年前就被关闭并标记为“不计划实现”。与此同时,社区关于 Actions 默认安全的讨论中充斥着 GitHub 员工解释称,更改默认设置会破坏现有工作流——这确实是事实,我也理解他们的困境。
但我认为,破坏现有工作流恰恰是重点所在,因为这些工作流正是问题反复出现的原因:91% 使用第三方 action 的 PyPI 包至少引用了一个带有可变标签的 action,三分之二没有设置权限限制:至少有一个工作流未被阻止,而就在 tj-actions 事件发生一年后,仍有数百个包继续通过标签引用它。可选的安全功能只会被那些本就关注安全的团队采纳,而被长期忽视的则是大量仓库——它们的维护者合理地认为平台默认设置是安全的。
对于私有仓库,谨慎行事是合理的,因为一个出错的内部流水线主要影响的是所有者本人。但对于构建工件并发布到包注册表、供数百万下游用户拉取的公共仓库,我认为其风险计算方式不同,尤其是当那些因默认设置变更而不便的人和当前正在被攻击的人群 largely 并不重合时。我认为 GitHub 可以合理地对这两种情况采取不同的处理方式。
我愿意为了几项改动而承担一些构建失败的风险。将 token 默认设为只读,适用于所有公共仓库(无论创建日期);拒绝在 run: 步骤中展开 github.event.*;拒绝在 pull_request_target 作业中恢复缓存;要求任何请求 id-token: write 的工作流中的 action 必须使用不可变引用。每一项改动都会破坏某些东西、惹恼一些人,但每一项都能让上述至少一次事件不再发生。
除非 GitHub 愿意做出会破坏现有系统的改变,否则请运行 zizmor,将依赖项固定到 SHA,在每个工作流文件的顶部设置 permissions: {},并假设任何未经验证的用户都可以在 PR 标题、分支名或 issue 评论中放置 shell 脚本——最终这将成为你的薄弱环节。再见。
引用的相关事件:
需要完整排版与评论请前往来源站点阅读。