GitHub Actions 的 “包管理器” 并不是常见意义上的 npm、Maven,而是一条围绕 YAML 展开的 “引用链”:把公开仓库当包,把 uses 语句当依赖,把 Runner 当安装器。官方文档反复强调 “把 action 固定到 commit SHA”,却回避了三个核心问题:子依赖无法锁定、job 间文件系统共享、以及默认写权限过大的 GITHUB_TOKEN。结果是整条链路看似 “CI/CD 最佳实践”,实则处处可变,供应链攻击者只需在任意环节插入一次写操作,就能把恶意产物推回生产注册表。
一、设计缺陷:YAML 即清单,但无锁文件
-
依赖图解析深度 = 1
GitHub 把.github/workflows/*.yml识别为 “清单文件”,却只能解析jobs[*].steps[*].uses的一级引用;可重用 workflow 再往里call的其他仓库不会被展开。这意味着你固定了actions/setup-node@e1的 SHA,却看不到它内部又动态拉取了some-org/toolkit@main。 -
无 “子依赖” 概念
与 npm 的 package-lock.json 不同,Actions 没有 “锁文件” 来冻结整套行动图。只要第三方 action 的作者把 tag 强制推送到新的 commit,你就可能在下次运行时拿到完全不同的代码。 -
默认 token 权限过宽
GITHUB_TOKEN对当前仓库默认具备写权限,任何一步执行npm publish或docker push都能直接把产物送进正式注册表。官方建议 “按需缩小权限”,但 90% 的示例 workflow 仍保留contents: write。
二、攻击路径:从 PR 污染到产物落地
-
污染锁文件
官方 Runner 在 job 之间共享$GITHUB_WORKSPACE。攻击者提交一个看似无害的 PR,修改package-lock.json中某个间接依赖的 resolved 字段,指向恶意 tarball;CI 第一步npm ci会校验 tarball hash,但校验通过(因为 hash 被一并篡改)。 -
缓存投毒
自托管 Runner 的/opt/hostedtoolcache与~/.npm在 job 间复用。攻击者在前置 job 里执行npm config set cache /opt/hostedtoolcache/npm,然后npm install evil-pkg,把恶意包塞进全局缓存;后续 job 的npm ci会优先命中缓存,跳过网络校验。 -
自动发布
如果 workflow 里存在npm publish步骤,恶意包会被直接推到 registry;若仓库配置了 OIDC 信任,甚至可以用GITHUB_TOKEN签发云凭证,把容器镜像推送到生产仓库。
三、规避方案:把 “可变” 变成 “不可变”
-
强制 SHA + 私有镜像
组织级策略要求所有uses必须指向长度为 40 的 commit SHA;同时在内网部署 actions-sync 镜像,把常用 action 克隆到私有仓库,阻断外部 force-push。 -
一次性 Runner
采用 Ephemeral/Just-in-time Runner,每 job 启动干净 VM,job 结束即销毁,消除跨 job 文件系统与缓存污染;配合--jitconfig参数,确保 Runner 仅执行一次任务即注销。 -
最小权限令牌
在 workflow 顶部显式声明permissions: contents: read # 仅读代码 packages: write # 仅当需要推包 id-token: write # 仅当用 OIDC并通过 Environment Protection Rule 把
packages: write限制在人工评审后的生产环境。 -
人工依赖评审
启用dependency-review-action,对 PR 中任何.github/workflows/*.yml的变更进行 diff,若发现uses行从 SHA 变为 tag、或新增外部仓库,则强制代码所有者 @security-team 评审。 -
缓存指纹校验
在 job 起始处打印缓存目录指纹:find ~/.npm /opt/hostedtoolcache -type f -exec sha256sum {} \; | sort | sha256sum,把结果写进 job summary;若两次运行指纹不一致,立即失败。
四、可落地参数速查表
| 控制点 | 推荐值 | 备注 |
|---|---|---|
| uses 引用 | owner/repo@<40位SHA> |
禁止 tag、branch |
| Runner 类型 | ephemeral: true |
用 JIT API 注册 |
| permissions | contents:read |
默认拒绝写 |
| OIDC 信任 | subject: repo:org/repo:environment:prod |
限定仓库 + 环境 |
| 缓存目录 | RUNNER_TOOL_CACHE=/tmp/$(uuid) |
每次随机路径 |
| Dependabot | .github/dependabot.yml |
仅监控语义化版本 |
| 评审规则 | CODEOWNERS: *.yml @security-team |
强制人工 review |
五、结语
GitHub Actions 把 “引用公开仓库” 设计成了零门槛的依赖管理,却忽略了锁文件、子依赖可见性与运行时隔离三大基础能力。只要平台侧不引入原生 SBOM 与强制锁机制,防御就只能靠 “外围补丁”:SHA 固定、私有镜像、一次性 Runner、最小权限。把这四件事做成组织级强制策略,才能把依赖解析从 “黑盒” 变 “白盒”,让供应链攻击者失去可写的落脚点。
参考资料
[1] GitHub Docs《使用 GitHub 的安全功能保护 Actions》
[2] OX Security《10 个被忽视的 GitHub 风险向量》