把 GitHub Actions 当供应链代码审:用 zizmor 在合并前拦住模板注入、危险权限与不可靠依赖
GitHub Actions 往往被放在仓库边缘:它只是几份 YAML,能跑通构建就很少再被认真审查。但工作流实际拥有读取仓库、写入 release、发布包、访问密钥乃至调用云端 API 的能力。把来自 PR 标题、Issue 文本或其他事件字段直接拼进 shell;让第三方 Action 永远跟随一个可变的 tag;或者把默认权限留得过宽,都会让“配置问题”变成供应链攻击面。
zizmor 是面向 GitHub Actions、复合 Action 与 Dependabot 配置的静态分析器。项目使用 Rust 实现,GitHub 仓库当前标注为 MIT License。它的定位并不是替代通用 YAML 校验器,而是检查 CI/CD 语义:表达式进入命令后是否可能形成模板注入、工作流权限是否过宽、引用是否难以复现、凭据是否可能在 runner 上残留,以及缓存和危险触发器的组合是否需要额外审视。
本文只讨论一个落地目标:把 zizmor 放进现有仓库,让每个改动 .github/ 的 PR 都先经过可复现的安全检查;发现问题时再回到工作流语义本身修复,而不是盲目接受自动改写。
为什么 CI YAML 值得像应用代码一样审
以常见的 shell 步骤为例,github.event.pull_request.title 是外部输入。若把它直接嵌入 ${{ ... }} 并置入 run:,值会在 shell 执行前参与模板展开。风险不取决于变量名字看起来是否“只是标题”,而取决于数据是否跨越了事件输入、表达式、shell 三个边界,以及是否有正确的转义和传递方式。
第二类问题更隐蔽。Action 写成版本 tag 便于更新,却也让同一份工作流未来可能拉到不同提交;权限没有在工作流或 job 层收紧时,某个原本只需读取仓库的步骤可能获得更多 GITHUB_TOKEN 能力。缓存、发布、pull_request_target 等场景还会把不可信代码、凭据与写入权限放到相邻步骤中。人工 review 能发现其中一部分,但很难长期记住每一种 GitHub Actions 语义。
zizmor 的价值在于把这些模式变成可在本地和 CI 中重复执行的审计。它报告规则名、定位行号、置信度和规则文档链接;因此它应被视为“需要解释的安全测试”,而不是一条无需判断的格式化建议。
先以锁定依赖的方式安装
官方安装文档给出了 Cargo 安装方式,并建议使用 --locked,避免构建时依赖解析偏离项目锁定的依赖集合:
cargo install --locked zizmor
如果团队不希望在开发机安装 Rust 工具链,也可以使用官方容器镜像。下面的命令只拉取镜像;真正扫描时仍应把仓库以只读方式挂载,避免审计过程意外改动工作区。
docker pull ghcr.io/zizmorcore/zizmor:latest
开始接入前,先对一个仓库目录做基线扫描。--collect=workflows 的含义很明确:只收集 GitHub Actions workflow,而不是把目录中其他潜在输入类型一并纳入本次基线。
zizmor --collect=workflows ./my-service
首次结果不应立刻被“清零”。先按风险路径分类:哪些是外部输入进入命令、哪些是 Action 引用和权限问题、哪些是团队有意接受但应留下例外说明。这样能避免把安全工具变成只会制造噪声的门禁。
用离线模式复现一个高风险工作流
审计未必需要网络。对于已经检出的单个工作流,--offline 会限制 zizmor 不去访问网络、抓取远程仓库或运行需要联网的审计;这很适合在受限 CI 环境中先做确定性检查。
zizmor --offline .github/workflows/release.yml
假设扫描结果指向 template-injection,正确的后续动作不是仅把规则忽略掉,而是沿数据流检查:外部事件字段是否被直接写进 run: 或 github-script;能否改成把值作为环境变量传给脚本;脚本是否以参数化接口处理输入。这里的关键不是记住某个字符串替换技巧,而是不要让不可信文本在表达式展开后成为 shell 程序的一部分。
如果需要给 CI 平台或内部质量面板消费结果,zizmor 可以输出 JSON。示例中用 jq 只查看数组的第一条 finding;生产系统则可按规则名、路径和严重度保存或标注 PR。
zizmor --format=json . | jq '.[0]'
注意扫描发现问题时退出码非零通常正是门禁生效的信号。不要把它和“扫描器崩溃”混为一谈;CI 应保留原始输出,并让维护者能从规则文档回到 YAML 的具体行。
先把基线变成一张可处理的清单
第一次扫描往往会同时报出历史遗留、正在开发的工作流和真正紧急的风险。比较稳妥的做法是把输出按“数据来源—执行位置—权限”三列整理,而不是按告警数量排序。外部事件字段进入 run:、github-script 或动态生成的脚本,优先级最高;发布 job、使用云端凭据的 job,以及带写权限的 job 次之;纯读取、无密钥的测试 job 可在团队确认后排期处理。
每条确认的 finding 都应该留下可审计的处置记录:修复了什么输入边界、为何某个 Action 引用可以暂时保留、某项权限对哪一步是必要的。这样,后续更新 workflow 时,reviewer 不必依赖记忆猜测“上次为什么忽略”,而能检查例外是否仍成立。不要用宽泛的全局忽略掩盖告警;例外越靠近对应工作流、理由越具体,日后越容易撤销。
对于变更频繁的仓库,可以在本地提交前先执行离线扫描,在 PR 中再运行同一条命令。两处命令一致能减少“本机通过、CI 却因收集范围不同失败”的差异。若 CI 还要扫描远程引用或做额外策略检查,应把网络相关部分显式分成独立步骤,并记录其失败是网络问题还是安全 finding,避免把暂时的网络抖动误解为规则已通过。
最后,要把 zizmor 的输出与 GitHub 自身的权限策略配合使用:扫描器指出危险模式,仓库层的最小权限、受保护环境和 required review 决定它们实际能造成多大影响。两者不是替代关系。一个没有写权限的测试 job 即使出现问题,爆炸半径也较小;一个可发布包的 job 即使只有一处外部输入拼接,也应按高优先级处理。
把它接进 PR,而不是只在事故后运行
一个务实的接入方式是分两步。第一步只在修改 .github/ 时运行扫描并保留报告,先建立基线;第二步在修复高置信度问题、为合理例外写出团队说明后,再把非零 finding 设为合并阻断条件。这样不会因为历史遗留工作流过多而让工具在第一天就被关闭。
需要更严格的仓库收集时,可以加入 --strict-collection。根据官方使用文档,它会把收集到的文件无法解析这一类情况从 warning 提升为失败。对于“工作流不能被静默跳过”的关键仓库,这比只扫描成功解析的部分更可靠:解析失败本身也应成为待处理的配置问题。
zizmor --strict-collection --collect=workflows .
zizmor 也提供 --fix,但它会就地修改本地输入。自动修复适合在独立分支或临时工作区中生成候选变更,再由人审阅 diff;不适合在拥有发布权限的 runner 上直接修改主分支工作区。尤其是权限、事件触发器和凭据边界,语法上可修复不等于业务上一定正确。
它能覆盖什么,又不能替你决定什么
zizmor 适合回答“这份 GitHub Actions 配置是否触发了已知的危险模式”。它不能替代仓库的威胁建模:某个 job 是否本就不该获得写权限、某个发布密钥应不应该存在于该环境、第三方 Action 的维护策略是否符合组织要求,仍需要团队决定。
因此,最有价值的实践不是追求一次扫描零告警,而是把 CI 配置纳入常规代码评审:固定 Action 引用的来源和更新流程;为每个 job 写出最小权限;把不可信输入与 shell 执行隔离;对缓存、发布和特权触发器建立单独的 review 清单。zizmor 提供了持续、可定位的检查层,而安全边界最终仍由工作流设计和审查纪律共同形成。