OFFSET偏移
← 文章目录OFFSET / FIELD NOTESNO. 108

GitHub Actions 安全与排障

沿事件、权限、凭据、Runner 和供应链边界定位 CI 风险与失败。

所在专题GitHub Actions 与 CI ↗
约 31 分钟阅读开始阅读

本篇只处理 GitHub Actions CI 的安全边界、失败排查、可靠性和成本控制。Workflow、Event、Job、Runner、Matrix、Cache 等基础执行模型见 04:Actions 基础与执行模型;从本地检查设计 push/PR CI、Required Checks 和 Rulesets 见 05:Actions CI 配置方法。

CI 的危险不只来自“是否把密码写进 YAML”。一个 Pull Request 可以改变测试代码、构建脚本、依赖和 workflow;这些内容最终可能在 Runner 上执行。因此要同时审查四条边界:事件信任级别、GITHUB_TOKEN 权限、Secrets/外部凭据、Runner 与缓存的生命周期。

1. 先画出信任边界#

1.1 事件、代码与权限不是同一件事

对象 要先问的问题 默认安全方向
push 到受保护分支 提交者和分支是否经过仓库规则控制? 可作为受信任构建或填充缓存的入口,但仍需最小权限
pull_request PR 是否来自 Fork?工作流会执行哪些贡献者代码? 把 head、脚本和依赖视为不可信;通常不给 Fork PR Secrets 和写权限
pull_request_target 是否真的需要 base 仓库上下文、Secrets 或写入能力? 高风险特权事件;不要 checkout 并执行不可信 PR 代码
workflow_run 上一个 Workflow 上传的 artifact、输出和元数据是否可信? 可作权限隔离边界,但下游必须把上游产物当作不可信输入
workflow_dispatch 谁能手动触发?输入是否会进入 shell 或部署? 限制触发者和环境,仍然验证输入

pull_request 的检查目标是“验证一个不可信变更”,而不是让这个变更取得仓库控制权。不能因为检查需要安装依赖、运行构建脚本,就顺手给它写权限、部署 Secrets 或自托管 Runner。

1.2 GitHub-hosted 与 self-hosted Runner

  • GitHub-hosted Runner 由 GitHub 管理,标准 Job 通常在隔离的临时执行环境中运行;仍应把工作流和依赖当作可能执行任意代码的程序,不要把 Secrets 直接打印、写入 artifact 或缓存。
  • Self-hosted Runner 是自己管理的机器,可能保留工作目录、凭据、网络访问和系统状态;GitHub 不保证每个 Job 都使用一台干净机器。公开仓库的 Fork PR 可能把危险代码送到该机器,因此不应把公开仓库的不可信 PR 直接路由到有内网或生产访问权的 Runner。
  • 若确实需要 self-hosted,应限制 Runner group 可使用的仓库,隔离网络和权限,优先使用每次 Job 后销毁的 ephemeral Runner,并检查其清理、更新和离线恢复流程。

Runner 选择是安全设计,不只是性能和价格选择。GitHub-hosted 的分钟费用不能替代隔离;self-hosted 的“免费分钟”也不等于免费或安全。

2. GITHUB_TOKEN 与最小权限#

2.1 Token 是什么

GitHub 在每个 Workflow Job 开始前为该 Job 创建一个短期 GITHUB_TOKEN。它是安装在仓库上的 GitHub App installation token,作用域受限于包含该 Workflow 的仓库,Job 结束或 token 到期后失效。Action 即使没有显式收到 token 输入,也可能通过 github.token 上下文访问它,因此不能只审查 with: 里的参数。

最小权限的顺序是:先让 Workflow 不需要 Token;需要时在 Workflow 或 Job 层显式列出最小权限;只有某一个 Job 真正需要写入能力时,才在该 Job 放大权限。

2.2 permissions 的语义

一旦在 Workflow 或 Job 中显式写出 permissions,未列出的权限按 none 处理。不要把“未写出来”理解成“沿用一个安全的默认值”;要根据实际步骤逐项添加。Job 层可以为特定 Job 收紧或授予其所需权限,部署 Job 和普通测试 Job 不应共享一套宽权限。

只读 Nix 构建通常只需要 contents: read:

name: CI

on:
  pull_request:
  push:
    branches: [main]

# 显式声明:未列出的 GITHUB_TOKEN 权限为 none。
permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - name: Check out source
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - name: Run checks
        run: ./ci/check.sh

如果只有发布或部署 Job 需要额外权限,把它写在该 Job,而不是 Workflow 顶层:

jobs:
  test:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - run: ./ci/test.sh

  publish:
    permissions:
      contents: read
      packages: write
    runs-on: ubuntu-latest
    steps:
      - run: ./ci/publish.sh

packages: write 只是示例,不能据此推断所有发布工具的权限需求。每次增加权限都应写在 PR 描述中,说明哪个步骤需要它、触发事件是否可信、如何撤销。

2.3 Fork PR 的默认边界

公开仓库的 Fork PR 通常不会收到仓库、组织或环境 Secrets,GITHUB_TOKEN 通常只有只读权限;首次贡献者的 Workflow 还可能需要维护者批准。Dependabot PR 也适用类似的 Fork 限制。私有仓库可以在 Settings 中配置更宽的 Fork 策略,但那是显式的风险决定,不应当当作默认前提。

所以,遇到 Fork PR 的 403、缺少 Secret 或不能创建检查,不要为了“让 CI 变绿”把 pull_request 改成 pull_request_target,也不要打开发送写 Token/Secrets 的策略。先确认这一步是否本来就不应有写权限。

3. Secrets、Variables 与 Environments#

3.1 三种配置的边界

类型 用途 可否视为机密
Repository/Organization Secret Token、API key、签名材料等敏感值 是;按最小范围授予,并定期轮换
Environment Secret 某个部署环境专属的敏感值 是;可用 Required reviewers、分支规则和等待时间保护
Variable(vars) 非敏感配置,如区域名、开关、版本标签 否;日志和工作流代码可见,不要放凭据

Secrets 应通过 ${{ secrets.NAME }} 传入需要它的步骤,且只在受信任 Job 中使用:

jobs:
  deploy:
    # production 环境需在仓库 Settings 中配置保护规则和 Secret。
    environment:
      name: production
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
        run: ./ci/deploy.sh

示例中的 DEPLOY_KEY 只是占位名称,不代表仓库中应存在这个 Secret;不要把真实值替换进文档或提交。不要用 echo、set -x、调试打印、artifact、cache 或临时文件输出 Secret。GitHub 的自动脱敏不是安全边界,因为 Secret 可能被编码、切片、拼接或经过工具转换后不再匹配原值。

3.2 Environment 是审批边界,不是沙箱

Job 引用 Environment 后,保护规则通过前不会开始执行;Environment Secrets 在 Job 获准并启动后才可访问。可以限制允许部署的分支或标签、要求人工审查、设置等待时间或接入保护规则。

但 Environment 不会把不可信代码变成可信代码,也不会把 self-hosted Runner 变成隔离容器。若不可信代码最终能在同一 Runner 上执行,仍可能读取环境、工作目录或其他可见资源。将“测试不可信 PR”和“使用生产 Secrets 的部署”拆成不同的 Workflow/Job,并让后者只响应受控事件。

4. OIDC 与短期云凭据#

OpenID Connect(OIDC)允许云提供商根据 GitHub 签发的短期身份令牌换取短期云访问令牌,减少长期云密钥存放在 GitHub Secrets 中的需要。它不是自动授权:云提供商仍必须配置可信主体、Audience、仓库/分支/Environment 等条件,并把云角色本身限制到最小操作。

请求 OIDC JWT 必须授予 id-token: write。这个名称容易误导:它只允许 Workflow 向 GitHub OIDC provider 请求身份令牌,不等于获得云资源写权限,也不等于获得 GitHub 仓库写权限。示例只展示安全的权限和环境边界,不放真实云凭据或可直接运行的提供商 Secret:

jobs:
  deploy:
    environment:
      name: production
    permissions:
      contents: read
      id-token: write
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - name: Exchange OIDC token with the cloud provider
        # 用提供商的官方 Action,并固定到经过审查的完整 commit SHA。
        uses: provider/official-login-action@<40-hex-commit-sha>
        with:
          audience: example-audience
      - run: ./ci/deploy.sh

配置云端信任策略时至少核对:

  1. sub/subject 是否限定到预期仓库、分支或 Environment,而不是接受任意仓库或任意分支。
  2. aud/audience 是否和提供商配置一致,避免把令牌转给另一个用途。
  3. Fork PR、pull_request_target、workflow_run 是否可能满足该信任条件;不可信事件不应得到生产角色。
  4. 云角色是否只允许必要 API 操作,并有独立的撤销和审计路径。

本文主题项目教学示例只做 Nix CI,没有云部署需求;不要为了“展示 OIDC”给它增加 id-token: write。

5. 第三方 Action、Reusable Workflow 与供应链#

5.1 固定不可变 SHA

标签(如 @v4)便于升级,但标签可以移动;对需要可审计和可复现的 Workflow,第三方 Action 应固定到完整 40 位 commit SHA,并在注释中保留人类可读版本:

steps:
  - name: Check out source
    uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  - name: Install Nix
    uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1

SHA pinning 只能防止标签在未来悄悄指向另一提交,不能证明该提交本身安全。使用前仍要检查仓库所有者、Release/Tag 对应关系、源码、权限和维护状态;对关键 Action 可限制仓库 Settings 中允许的 Actions/Reusable Workflows,配合 Dependabot 或定期人工更新。

Reusable Workflow 也属于供应链边界:调用方要固定 org/repo/.github/workflows/reuse.yml@<SHA>,审查被调用 Workflow 的权限、继承 Secrets、输入和输出,不要因为它“看起来像本仓库的模板”就默认可信。调用者需要的权限应显式写出,避免把调用方的宽权限无意间传入共享流程。

5.2 输入、脚本与命令注入

Issue 标题、PR 标题/正文、分支名、提交消息、路径和手动输入都可能由贡献者控制。不要把这些上下文直接插入 run: 字符串:

# 不安全:输入先被插入生成的 shell 脚本。
# run: echo "${{ github.event.pull_request.title }}"

- name: Print untrusted title safely
  if: github.event_name == 'pull_request'
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    printf '%s\n' "$PR_TITLE"

把值通过 env 传给脚本,脚本内使用双引号变量展开;需要把路径交给 Git 或其他工具时使用 -- 分隔选项与路径,并拒绝不符合预期的格式。更复杂的解析优先使用经过审查的 Action,把上下文作为参数而不是拼接成 shell。

6. pull_request_target、workflow_run 与不可信代码#

6.1 pull_request 与 pull_request_target

pull_request 适合运行不可信代码的只读检查,代价是 Fork PR 通常不能使用 Secrets 或写权限。pull_request_target 使用 base 仓库默认分支的上下文运行(按当前官方事件参考),可能获得 base 的 Workflow、Secrets 和更高的 GITHUB_TOKEN 权限;因此它不是“让 Fork CI 正常工作的安全替代写法”。

高风险模式是:在 pull_request_target 中 checkout Fork 的 head,然后执行其构建脚本、测试或依赖安装。这样不可信代码就在高权限上下文中运行,可能读取凭据或修改仓库。不要将这类构建或依赖安装放在特权事件中;通常将只读测试留在 pull_request,将受控发布拆到合并后的 push 或经过审批的部署 Job。

6.2 workflow_run 的权限隔离不是信任传递

workflow_run 可以让低权限测试 Workflow 完成后,再启动拥有不同权限的后续 Workflow。这有助于隔离“测试”和“发布”,但上游的 artifact、缓存、输出和文件名仍可能由不可信提交影响。下游不应直接 checkout 或执行上游上传的脚本,不应把未验证的输出当作命令、路径或身份条件。

可采用的边界是:上游只上传明确格式的结果;下游重新验证 artifact 的来源、哈希、结构和允许的文件集合;只有满足受保护分支、审批和检查状态后,才使用 OIDC 或 Environment 凭据。若不需要跨 Workflow 传递数据,最简单的安全方案是不要引入 workflow_run。

6.3 Cache poisoning

Cache 按分支或标签等范围共享,不按 Workflow/Job 身份独立隔离;可读取某个 Cache 的运行会原样恢复其中内容。不要把 Secret、签名材料、认证目录或依赖于信任边界的可执行文件放进 Cache。恶意或被污染的缓存可能让受信任 Workflow 后续执行攻击者写入的文件。

Fork PR 和 pull_request_target 受 GitHub 的缓存访问限制,但这不能代替审查:把可执行构建结果从不可信 Cache 恢复到受信任 Job 仍然危险。需要填充缓存时,优先在受信任的 push Workflow 中生成;每次恢复都把内容当作不可信输入,使用锁文件、哈希键和可重建设计。

7. 失败排查与安全调试#

7.1 先确定失败属于哪一层

排查顺序应保持稳定,避免反复盲目重跑:

  1. 触发层:事件、分支、路径过滤、Workflow 文件是否在预期 ref、Fork 审批是否完成。
  2. 权限层:Workflow/Job permissions、Fork 只读边界、Environment 审批和 Secret 可见性。
  3. Runner 层:Runner 是否排队、离线、磁盘不足、网络/DNS 失败、工具版本变化。
  4. Action/依赖层:SHA 是否存在、Action 输入是否改变、上游下载是否失败、缓存是否命中异常。
  5. 项目检查层:Nix 求值、构建、测试或输出契约本身失败。
  6. 并发/成本层:是否被 concurrency 取消、超时、预算/分钟/存储限制阻断。

先看失败 Job 的第一条异常和失败前的上下文,再看完整日志;最后才决定是修复、重跑还是暂时性基础设施问题。重跑不会修复确定性的 YAML、权限、输入或代码错误。

7.2 GitHub CLI 的最小排查流

gh run list --workflow nix.yml --limit 10
gh run view RUN_ID
gh run view RUN_ID --log-failed
gh run watch RUN_ID
gh run rerun RUN_ID --failed

需要更多 Runner/Step 诊断时,可以在一次重跑中使用:

gh run rerun RUN_ID --failed --debug

也可以在仓库或组织设置中临时启用 ACTIONS_STEP_DEBUG=true、ACTIONS_RUNNER_DEBUG=true。调试日志可能包含更多路径、命令参数、依赖和环境信息;启用前检查是否有凭据进入命令行,结束后及时删除或关闭调试变量。无论界面还是 gh 输出,都不要把包含 Secret、OIDC token、私有 URL 或内部网络信息的日志复制到公开 Issue。

7.3 常见症状与下一步

症状 优先检查 不要先做的事
Workflow 没有运行 事件、分支/路径过滤、Workflow 所在 ref、Fork 审批和 Actions Settings 不要随意放宽触发器到所有分支
403 / Resource not accessible by integration Job 的 permissions、事件类型、Fork 只读边界和目标 API 不要直接改成全局 write-all
Secret 为空 Secret 所属层级、Environment 审批、Fork/Dependabot 限制 不要把 Secret 打印出来确认
Action 找不到或行为突变 SHA 是否完整、提交是否存在、Action 输入和 Runner 镜像 不要只换成漂移的 @main
同样提交偶发失败 下载服务、Runner 网络、缓存命中和并发取消 不要无限重跑隐藏确定性错误
Job 被取消或超时 concurrency、timeout-minutes、队列和成本限制 不要无限提高 timeout
self-hosted Runner 离线 Runner 服务、标签/组、网络和磁盘 不要把新仓库直接加入共享 Runner

失败的 PR 可以重跑原来的提交与事件;修复 Workflow 后再推一个新的提交,以便区分“配置修复”与“基础设施重试”。

8. 超时、并发与成本#

8.1 给 Job 设置上限

将 timeout-minutes 设为符合真实工作量的上限,避免死循环、失联下载或损坏的构建占用 Runner:

jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - run: nix flake check --all-systems --no-build

超时应当促使我们定位慢步骤,而不是替代缓存、拆分 Job 或修复失控脚本。给部署 Job 设置 timeout 时,还要明确“取消部署”是否会留下半完成状态以及如何回滚。

8.2 只取消过时的 CI

对同一个 PR 的旧检查可以使用 concurrency 节省分钟:

concurrency:
  group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

这个策略适合可重复、无外部副作用的 PR CI;不要直接套到发布、迁移或不可逆部署流程。生产部署更适合按 Environment 串行,并将取消策略改成经过审查的排队/互斥规则。

8.3 成本控制的优先级

  • 先用 branches、paths 和合理事件减少不必要运行,再考虑更复杂的矩阵或缓存。
  • 给矩阵设置清晰的轴和失败策略;不为每个平台复制一套永远不执行的检查。
  • 用 timeout 防止异常长 Job;设置 artifact/log 保留期,定期清理不再需要的产物。
  • Cache 只缓存可重建的依赖;缓存命中不是正确性的前提,也不是安全边界。为缓存省下的分钟不值得换来污染可信构建的风险。
  • GitHub-hosted 主要消耗 Actions 分钟、artifact 和 cache 存储;self-hosted 可能不计相同的托管分钟,但要承担机器、磁盘、网络、维护和安全隔离成本。

9. 示例:只读主题 CI 的边界#

下面描述匿名化主题 CI 的教学配置,没有核验个人项目现状或报告真实运行:

  • 触发于 push 和 pull_request,没有在 YAML 中限制分支或路径。
  • Workflow 顶层已有 permissions: contents: read,未为构建 Job 增加写权限。
  • actions/checkout 固定到完整 SHA 3d3c42e5aac5ba805825da76410c181273ba90b1(注释版本 v7.0.1)。
  • cachix/install-nix-action 固定到完整 SHA 13d8dd58da0234aa297dedd986986ccb8e7f3e24(注释版本 v31.11.1)。
  • 示例文件没有 Secrets、OIDC、依赖 Cache 或 concurrency;不要把本篇的示例写成“示例已经具备”。
  • 工作流执行 nix flake check --all-systems --no-build,构建六种主题变体,并构建 checks.x86_64-linux.variants 输出契约。

因此,该示例 CI 的安全基线是“只读仓库内容、固定已采用的 Action、在 GitHub-hosted ubuntu-latest 上验证 Nix 输出”。它验证 Nix 求值、构建和输出契约,不代表 Fcitx5、Hyprland、niri、KWin 或 GNOME 的运行时行为已经在 CI 中验证;尤其不能把 Nix 构建通过写成 Fcitx5/Hyprland 运行时测试通过。

对这个只读构建示例,不需要为了安全清单而添加 Secret、OIDC 或 self-hosted Runner。若未来加入 Cache,应只缓存可重建的 Nix/依赖内容,并把从 Fork PR 恢复的内容当作不可信;若未来加入发布或云部署,应拆分 Job、保持测试 Job 的 contents: read,再为受保护 Environment 单独设计 OIDC 信任条件。concurrency 是可选的成本优化,不是示例 Workflow 已有的能力。

10. 安全与可靠性检查清单#

  • 每个 Workflow/Job 都有显式、最小的 permissions;未列权限按 none 检查。
  • 只在确实需要时使用 GITHUB_TOKEN,没有把 write 放在普通测试 Job。
  • Fork PR、Dependabot PR 和手动输入都按不可信输入处理。
  • 没有在 pull_request_target 或 workflow_run 中 checkout 并执行不可信 PR 代码。
  • PR 标题、正文、分支名、路径和 workflow_dispatch 输入没有直接拼接进 shell。
  • Secrets 没有进入日志、artifact、cache、提交、临时文件或错误消息;Variables 中没有凭据。
  • Environment Secrets 有分支限制、Required reviewers 或适合风险的保护规则。
  • OIDC 的 id-token: write 只放在需要的 Job,云端 subject/audience/角色权限均已限制。
  • 第三方 Action 和 Reusable Workflow 固定完整 commit SHA,版本注释与提交对应。
  • Cache 只包含可重建内容,缓存键和恢复路径不会让不可信内容进入受信任执行。
  • 公开仓库的不可信 PR 不使用有内网/生产访问权的 self-hosted Runner。
  • 失败排查先区分触发、权限、Runner、Action、项目检查和成本层,再决定是否重跑。
  • 一次性 debug logging 使用完即关闭,日志发布前已做凭据和内部信息检查。
  • 长 Job 有合理 timeout;可取消的 PR CI 才使用 cancel-in-progress。
  • Actions Settings、允许的 Action 范围、artifact/log 保留期和 Runner group 定期复核。

11. 官方资料#

完整 SHA 与版本注释于 2026-10-08 通过上游 tag API 对应核对:actions/checkout v7.0.1 与 cachix/install-nix-action v31.11.1。它们是本文示例采用的固定版本,不能据此声称最新或没有漏洞;cachix/install-nix-action 是第三方 Action。固定 SHA 后仍需跟踪上游发布与安全公告。

参考与延伸

  1. Secure use reference ↗
  2. GITHUB_TOKEN ↗
  3. Secrets reference ↗
  4. OpenID Connect reference ↗
  5. Deployment environments ↗
  6. Managing GitHub Actions settings for a repository ↗
  7. Dependency caching ↗
  8. Events that trigger workflows ↗
  9. Troubleshooting workflows ↗
  10. Enabling debug logging ↗
  11. Self-hosted runners ↗
  12. actions/checkout v7.0.1 ↗
  13. cachix/install-nix-action v31.11.1(第三方) ↗

根据技术笔记重新整理,示例使用通用名称。

沿专题继续阅读

GitHub Actions 与 CI

  1. 01GitHub Actions 执行模型
  2. 02GitHub Actions CI 配置
  3. 03GitHub Actions 安全与排障
  4. 04Nix CI:求值、构建与输出契约
CONTINUE READING / 下一篇Nix CI:求值、构建与输出契约

用六种主题输出的教学示例区分 Nix 求值、构建、Actions 记录和桌面运行证据。