范围与威胁模型

这是“资料研读与合成示例”,整理/更新于 。本文只讨论防守配置,不执行任何仓库工作流,也不对外部项目做测试。假设攻击者可以提交 fork pull request、修改构建脚本,或让被引用的第三方 Action 标签指向新提交;目标是防止这些输入获得不必要的写权限、Secrets 或云端部署身份。

GitHub 的 GITHUB_TOKEN 是按 job 创建且会过期的安装令牌,但默认权限仍应显式收窄。把 YAML 当成程序审查:事件是入口,表达式是输入,Action 和 shell 是依赖,Secrets/令牌是高价值资源。

先写最小 permissions,再逐 job 增权

在工作流顶层设只读默认值,避免新 job 意外继承写权限;需要发布、评论或上传 SARIF 时,在单个 job 上增加最小 scope。只要写了一个权限,未列出的权限就应视为 none,所以要和实际 API 调用逐项对照。

name: verify
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2;升级前核对上游提交
      - run: ./ci/test.sh

不要为了让某一步“先跑起来”直接改成 write-all。如果确实需要 security-events: write 或 packages: write,把它放在独立 job,限制触发事件和分支,并让审查者能看到权限扩张的理由。

第三方 Action 固定到完整 SHA

@v4、@main 和短 SHA 都可能随着上游变化而改变执行内容。GitHub 安全文档把完整 40 位 commit SHA 作为不可变引用的推荐方式,并提醒应确认该提交来自 Action 的上游仓库,而不是同名 fork。固定不是“永不更新”:使用 Dependabot 或人工审查定期把已验证的新 SHA 纳入变更。

审查时逐行问三件事:这个 Action 是否必要;SHA 是否能在上游仓库的 tag/release 中反查;它读取了哪些环境变量、工作区文件和 token。不要把未审查的下载脚本、动态 curl | bash 或来自 PR 的路径交给拥有 Secrets 的步骤。

可以在本地先做静态检查:

# 只检查文本,不执行 workflow
rg -n "uses:.*@(main|master|v[0-9]|[0-9a-f]{7,39})" .github/workflows
rg -n "permissions:|id-token:|pull_request_target" .github/workflows

正则只能找出待审行,不能证明 SHA 属于正确仓库;最终仍需打开上游提交、记录版本说明和变更评审链接。

pull_request_target 不是“更强的 pull_request”

pull_request 对 fork 默认使用只读令牌、不给 Secrets,并运行合并结果中的工作流;这适合编译和测试不可信变更。pull_request_target 则从基础分支读取工作流,通常带有基础仓库的令牌和 Secrets,适合标签、分类等只处理元数据的自动化。

危险组合是:在 pull_request_target 中 checkout github.event.pull_request.head.sha,随后执行 npm install、make 或测试脚本。此时 fork 可控制的文件会在高权限上下文中执行,形成常见的 pwn request。需要构建 PR 时,应拆成低权限的 pull_request job;需要写回标签时,再用不 checkout、不执行 PR 文件的独立 job 处理 API 元数据。

如果业务确实必须使用 target 事件,先证明 checkout 内容只被当作数据读取;不要把 fork 的 artifact、缓存或生成脚本直接交给特权 job。自托管 runner 还要做到一次性、隔离和无内部网络访问。

OIDC:把云端信任收窄到仓库、分支和环境

OIDC 可以让工作流换取短期云凭据,减少长期云 Secret,但 id-token: write 本身不是授权策略。云端角色必须要求至少一个条件,并同时约束 issuer、audience 和 subject。典型 subject 可以是 repo:Secx1/lockwatch:ref:refs/heads/main,或带环境的 repo:Secx1/lockwatch:environment:production;具体格式以当前仓库和云厂商配置为准。

deploy:
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  permissions:
    contents: read
    id-token: write
  environment: production
  steps:
    - name: Exchange OIDC token
      run: ./ci/deploy.sh

信任条件还可限制 repository_id、repository_visibility、工作流文件引用和环境审批。不要用通配符接受整个组织,也不要让来自 fork 的 pull request 获得生产 subject;把部署触发器固定为受保护分支或人工批准的 environment。

分支、环境与策略是最后一道门

对默认分支启用保护:要求拉取请求、至少一名合适审查者、必需状态检查和线性历史(按团队需要选择);禁止强制推送,并限制谁能修改工作流。生产 environment 配置必需审查者和分支限制,Actions 设置中限制允许的 Action 来源,并关闭不需要的“创建或批准 pull request”能力。

策略要和代码一起版本化:用 CODEOWNERS 要求安全负责人审查 .github/workflows/,在变更检查中拒绝未锁定的 Action 引用,定期检查 Secrets 和 runner 标签。这样才能防止“今天安全,下一次改 YAML 又恢复宽权限”。

还要检查表达式和日志:不要把 github.event.issue.title、分支名或 PR 评论直接拼进 shell;先当作数据传给有明确参数边界的程序。缓存键不应让不可信分支覆盖受信分支的构建产物,artifact 下载后也要校验来源、摘要和用途。安全审查应覆盖这些数据流,而不只是寻找一个过大的权限字段。

本地合成示例:先 lint,再提交变更

博客目录的 samples/supply-chain/secure-ci.yml 是不连接云端的样本,只包含低权限测试 job、完整 SHA 的 checkout 示例和一段注释式部署边界。可用下面的命令检查高风险形状;命令不会触发 GitHub Actions:

cd E:\moban2\secx-security-blog
rg -n "uses:|permissions:|pull_request_target|id-token:|checkout" samples\supply-chain\secure-ci.yml
rg -n "@(main|master|v[0-9]|[0-9a-f]{7,39})" samples\supply-chain\secure-ci.yml

第二条命令不应匹配短 SHA、分支或版本标签;示例中的完整 SHA 仍需在真正使用前从上游仓库核对。把本地样本当成审查练习,不要直接复制其中的 runner、环境名或云角色到生产。

一次实际变更可以按四步走:先列出事件、输入、Secrets 和写操作;再为每个 job 画出权限矩阵;接着用 fork PR、恶意标题和失败的 OIDC 条件做负向测试;最后把 workflow diff、上游 SHA、云策略和回滚方案放进同一份评审记录。这样审查者不仅能看到“现在配置了什么”,还能确认在异常输入下“不会发生什么”。

若检查失败,优先退回到只读权限并暂停部署,而不是在日志里泄露调试令牌。修复完成后重新运行同一组负向用例,确保安全门槛具有回归性,并记录责任人和复查日期,持续有效。

修复验证清单

检查面完成条件证据
Token顶层只读;写权限只在必要 job,且与事件/分支绑定workflow diff、权限矩阵
Actions第三方 Action 使用完整 SHA,SHA 可追溯到上游提交 URL、release note、更新 PR
不可信输入fork PR 不在特权上下文执行;target 不 checkout/run PR 文件事件图、负向测试、CodeQL 结果
云身份OIDC subject/audience/环境条件精确,生产需保护分支和审批云角色策略、environment 设置
回归分支保护、Action allow-list、CODEOWNERS 和 Secret 清理持续生效策略截图或 API 导出、审计记录

资料来源(官方文档)