Hotdry.
systems-engineering

拆解 GitHub Actions 内建包管理器的设计缺陷与依赖解析陷阱

GitHub Actions 把 YAML 当包清单却缺乏锁机制,可变分支、共享缓存与默认写权限让依赖解析成为供应链攻击入口;给出可落地的 SHA+OIDC+一次性 Runner 规避方案。

GitHub Actions 的 “包管理器” 并不是常见意义上的 npm、Maven,而是一条围绕 YAML 展开的 “引用链”:把公开仓库当包,把 uses 语句当依赖,把 Runner 当安装器。官方文档反复强调 “把 action 固定到 commit SHA”,却回避了三个核心问题:子依赖无法锁定、job 间文件系统共享、以及默认写权限过大的 GITHUB_TOKEN。结果是整条链路看似 “CI/CD 最佳实践”,实则处处可变,供应链攻击者只需在任意环节插入一次写操作,就能把恶意产物推回生产注册表。

一、设计缺陷:YAML 即清单,但无锁文件

  1. 依赖图解析深度 = 1
    GitHub 把 .github/workflows/*.yml 识别为 “清单文件”,却只能解析 jobs[*].steps[*].uses 的一级引用;可重用 workflow 再往里 call 的其他仓库不会被展开。这意味着你固定了 actions/setup-node@e1 的 SHA,却看不到它内部又动态拉取了 some-org/toolkit@main

  2. 无 “子依赖” 概念
    与 npm 的 package-lock.json 不同,Actions 没有 “锁文件” 来冻结整套行动图。只要第三方 action 的作者把 tag 强制推送到新的 commit,你就可能在下次运行时拿到完全不同的代码。

  3. 默认 token 权限过宽
    GITHUB_TOKEN 对当前仓库默认具备写权限,任何一步执行 npm publishdocker push 都能直接把产物送进正式注册表。官方建议 “按需缩小权限”,但 90% 的示例 workflow 仍保留 contents: write

二、攻击路径:从 PR 污染到产物落地

  1. 污染锁文件
    官方 Runner 在 job 之间共享 $GITHUB_WORKSPACE。攻击者提交一个看似无害的 PR,修改 package-lock.json 中某个间接依赖的 resolved 字段,指向恶意 tarball;CI 第一步 npm ci 会校验 tarball hash,但校验通过(因为 hash 被一并篡改)。

  2. 缓存投毒
    自托管 Runner 的 /opt/hostedtoolcache~/.npm 在 job 间复用。攻击者在前置 job 里执行 npm config set cache /opt/hostedtoolcache/npm,然后 npm install evil-pkg,把恶意包塞进全局缓存;后续 job 的 npm ci 会优先命中缓存,跳过网络校验。

  3. 自动发布
    如果 workflow 里存在 npm publish 步骤,恶意包会被直接推到 registry;若仓库配置了 OIDC 信任,甚至可以用 GITHUB_TOKEN 签发云凭证,把容器镜像推送到生产仓库。

三、规避方案:把 “可变” 变成 “不可变”

  1. 强制 SHA + 私有镜像
    组织级策略要求所有 uses 必须指向长度为 40 的 commit SHA;同时在内网部署 actions-sync 镜像,把常用 action 克隆到私有仓库,阻断外部 force-push。

  2. 一次性 Runner
    采用 Ephemeral/Just-in-time Runner,每 job 启动干净 VM,job 结束即销毁,消除跨 job 文件系统与缓存污染;配合 --jitconfig 参数,确保 Runner 仅执行一次任务即注销。

  3. 最小权限令牌
    在 workflow 顶部显式声明

    permissions:
      contents: read      # 仅读代码
      packages: write     # 仅当需要推包
      id-token: write     # 仅当用 OIDC
    

    并通过 Environment Protection Rule 把 packages: write 限制在人工评审后的生产环境。

  4. 人工依赖评审
    启用 dependency-review-action,对 PR 中任何 .github/workflows/*.yml 的变更进行 diff,若发现 uses 行从 SHA 变为 tag、或新增外部仓库,则强制代码所有者 @security-team 评审。

  5. 缓存指纹校验
    在 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 风险向量》

查看归档