GitHub Actions 脚本注入:Snowflake 案例真正说明了什么

不受信任的 issue 文本在 Snowflake 的 GitHub Actions 工作流中变成了 shell 代码。尽管是一个自主智能体发现并调整了这条路径,但注入方式本身是传统的。

分享这篇文章

一个公开的 GitHub issue 标题在 Snowflake 仓库的自动化流程中变成了可执行的 shell 代码。安全公司 Wiz 称,其自主 Red Agent 找到了这条路径,利用它获取了一个内部 Jira 凭据,并于 2026 年 6 月 23 日向 Snowflake 报告。公开的仓库历史确认,该工作流在当天得到修复;Wiz 称 Snowflake 也轮换了该凭据。

实际教训并不是一个 AI 击败了另一个 AI,而是 CI 工作流跨越了三个信任边界,却没有把它们当作边界来处理:公开输入进入了生成的脚本,该脚本在特权 worker 上运行,而作业持有另一个服务的凭据。

这是一次 GitHub Actions 脚本注入:攻击者控制的数据在 runner 的 shell 启动前被插入代码。仅仅在 YAML 中给表达式加引号并不能解决问题。可靠的修复方式是让不受信任的值远离生成的代码,然后限制成功攻陷 runner 后能够触及的范围。

GitHub Actions 脚本注入是如何发生的

Snowflake connector PR #1218 修改了一个将公开 GitHub issue 复制到公司 Jira 系统的工作流。该拉取请求于 2026 年 6 月 18 日合并。它的 jira_issue.yml 文件以如下形式处理标题:

run: |
  TITLE=$(echo '${{ github.event.issue.title }}' | sed ...)

这个片段经过有意缩写。要理解问题,不需要利用载荷。

GitHub 在准备作业时处理 ${{ ... }} 表达式。它的脚本注入文档解释说,结果值会在 shell 执行前被替换进临时脚本。如果公开 issue 标题包含能够结束引号字符串的 shell 语法,后面的 sed 命令根本没有机会让这个值变安全。此时 shell 已经在解析攻击者控制的代码。

触发条件扩大了暴露范围。该工作流会在新 issue 创建时运行,因此普通 GitHub 用户都可以提供标题。一个 if 表达式看起来像是排除了机器人,但 Wiz 报告称,对于 issue 事件,被引用的拉取请求字段为 null,使作业对每个 issue 作者开放。

  1. 公开输入

    任何 GitHub 用户都可以提供 issue 标题。

    控制措施: 限制触发条件。

  2. 模板展开

    GitHub 将该标题放入生成的脚本中。

    控制措施: 使用环境变量。

  3. Runner 执行

    Shell 将生成的文本解释为代码。

    控制措施: 检查工作流并对数据加引号。

  4. 特权边界

    该作业暴露了内部 Jira 服务的凭据。

    控制措施: 缩小令牌权限和网络可达范围。

注入攻击打开了 runner 边界;作业凭据和可达服务决定了影响范围。这是基于公开工作流和已归因于 Wiz 的披露所绘制的原创解释图。

公开仓库确认了存在漏洞的修改和修复。事件的私有部分则更难独立观察。Wiz 的披露称,其智能体调整了一个失败的概念验证,获取了属于 QA 账户的 Jira token,并验证了对内部工程、合规和漏洞赏金项目的读取权限。Wiz 还称,Snowflake 的审计日志没有发现五天暴露窗口内存在第三方访问,且 Wiz 删除了其访问的数据。这些影响和取证结论来自 Wiz 与 Snowflake;在研究本文时,我没有找到公开的独立事件报告。

更安全的模式让数据远离 shell 语法

Snowflake 的 6 月 23 日修复恢复了环境变量边界,并使用 jq 构造 JSON:

env:
  ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
  PAYLOAD=$(jq -n --arg title "$ISSUE_TITLE" '{summary: $title}')

在这里,GitHub 将标题写入环境变量,而不是写入生成的 shell 程序。shell 脚本包含固定文本 "$ISSUE_TITLE",普通的 shell 引号会将该值作为一个参数传递。随后由 jq --arg 执行 JSON 编码,而不是依靠手写的一串替换。

这个区别很容易被忽略:环境变量是数据边界,不是神奇的净化器。后续命令仍然可能误用它。应当为 shell 展开加引号,将值作为结构化参数传递,并避免执行构造出的字符串。

GitHub 的 安全使用参考建议,在专用 action 无法替代内联脚本时使用中间环境变量。JavaScript action 或编译型 action 往往更强,因为不受信任的值作为参数传入,不会参与 shell 源代码生成。

工作流 linter 可以捕捉最小模式

在本文中,我在两个一次性工作流文件中复现了数据流,并离线运行了 Zizmor 1.29.0。它的 template-injection 审计run: 中直接出现的 ${{ github.event.issue.title }} 表达式报告为高置信度代码注入发现。使用相同命令时,环境变量版本没有产生报告的发现。

这很有用,但不是普遍保证。linter 可以识别已知的源到汇模式,却无法证明每个动态脚本、第三方 action、token 权限范围或网络路径都安全。应将针对工作流的静态分析视为一项必需的审查信号,而不是最终结论。

一个实际的 CI 检查可以从以下命令开始:

zizmor .github/workflows/

应在最终合并后的工作流表示上运行它,锁定工具版本并审查抑制规则。将它与常规 YAML 验证、依赖锁定、秘密扫描以及覆盖特定事件上下文的测试结合起来。拉取请求工作流和 issue 工作流接收的载荷并不完全相同。

AI 故事比标题所暗示的更有限

公开的提交历史没有证明是 AI 编写了存在漏洞的修改。PR #1218 包含一个由“Copilot Autofix powered by AI”共同署名的独立提交,但存在漏洞的 jira_issue.yml 修订似乎出现在另一个由人类贡献者署名的提交中。Wiz 于 8 月 17 日更正了其文章,称 Copilot 检查了合并后的修改并返回“全部通过”,但存在漏洞修改的作者身份并不明确。

Wiz 也在销售发现该问题的 Red Agent。其叙述是研究团队提供的有价值的一手报告,但在 Snowflake 发布自己的报告或得到独立复现之前,关于该智能体自主性、私有 Jira 访问和确切取证范围的说法仍应保留归因。

比“坏 AI 对好 AI”更有用的可辩护结论是:

  • AI 辅助代码应接受与人工代码相同的安全审查。
  • 自动化的“全部通过”是来自某个工具和配置的证据,不是安全性的证明。
  • 智能体式测试可以快速探索失败链条,但其结果仍需要逐个工件验证并进行负责任披露。
  • 人类审查者需要工作流威胁模型:谁控制每个事件字段、它在哪里变成可执行内容,以及 runner 可以访问什么。

我们的如何阅读 AI 评测指南在另一个语境中提出了同一个测量要点:绿色分数只能回答评测实际测试的问题。8 月 18 日每日摘要提供了简短的事件时间线。

接下来关注什么

尚未解决的问题不是 AI 是否能发现工作流漏洞;这个案例至少在一次供应商报告的测试中说明它可以。更困难的问题是,团队是否会在下一次公开输入抵达 shell 之前,加入了解事件的工作流分析并降低 runner 权限。

对于这一具体事件,有价值的后续证据包括 Snowflake 撰写的事后分析、应用于最终 PR 修订的确切 GitHub 安全检查和规则集,以及对 Red Agent 决策过程的可独立复现说明。在此之前,公开的工作流历史支持一个有力的工程结论,但只支持一个有条件的 AI 结论。

资料来源

  1. Wiz disclosure of the Snowflake workflow vulnerability
  2. Snowflake connector PR 1218 and public workflow history
  3. Snowflake workflow remediation commit
  4. GitHub documentation on script injections
  5. GitHub secure-use reference for Actions
  6. Zizmor template-injection audit documentation