GitHub Actions 供应链安全:SLSA 等级与构建证明

GitHub Actions 供应链安全实战:依赖投毒与构建环境劫持威胁模型、SLSA 1 到 4 级逐级解读、actions/attest-build-provenance 原生构建证明、cosign 签名与 verify-attestation、Syft 与 CycloneDX 生成 SBOM、dependency-review-action 依赖审查、第三方 action 固定到 commit SHA、GITHUB_TOKEN 最小权限与准入控制器验证


一、供应链威胁模型

1.1 四类攻击面

1) 依赖投毒
   抢注相似包名、劫持已废弃的包、在合法包的新版本中植入后门。
   影响:构建产物中混入恶意代码,且看起来一切正常。

2) 构建环境劫持
   CI runner 上执行了被污染的脚本,或第三方 action 被改写。
   典型:pull_request_target 下 checkout 了 fork 的代码,
        导致 fork 中的脚本在持有 secret 的环境中执行。

3) 制品篡改
   镜像推送到 registry 之后被替换,或 tag 被重新指向另一个镜像。

4) 凭证泄露
   workflow 日志、artifact、缓存中意外带出 token。

1.2 信任链的断裂点

源码  ──►  依赖安装  ──►  构建  ──►  制品  ──►  分发  ──►  部署
  ▲           ▲            ▲          ▲          ▲          ▲
分支保护    锁文件        runner     签名       registry   准入控制
CODEOWNERS  lockfile     隔离       证明      不可变     admission

每一段都需要证据。SLSA 与构建证明要解决的核心问题就是:如何让下游在不信任上游的前提下,验证制品确实由某段源码、在某次构建中、用某个构建配置产出。

1.3 一个真实的攻击链

步骤 1  攻击者给某开源项目提了一个 PR,修改测试脚本
步骤 2  维护者用了 pull_request_target 触发 CI 并 checkout 了 PR 代码
步骤 3  测试脚本读取环境变量,把 GITHUB_TOKEN 外发
步骤 4  攻击者用 token 推了一个带后门的新版本 tag
步骤 5  下游项目自动拉取该 tag,后门进入生产环境

对应防线
  步骤 2  → 第四章:事件选择与权限最小化
  步骤 3  → 第三章:GITHUB_TOKEN 权限与 OIDC
  步骤 4  → 第二章:分支保护与 SLSA 等级要求
  步骤 5  → 第七、八章:验证侧消费证明

二、SLSA 等级逐级解读

2.1 四个等级的要求

SLSA Build L1  构建过程有文档化的证明
  要求:构建产出 provenance,记录源码、构建器、参数
  防住:无。L1 只是「有记录」,provenance 可以伪造

SLSA Build L2  构建服务提供已签名的证明
  要求:由托管构建服务生成并签名 provenance,防止构建后被篡改
  防住:构建完成后的制品替换

SLSA Build L3  构建环境强隔离且防篡改
  要求:构建在隔离环境中执行,构建平台自身难以被篡改
  防住:构建过程中的环境劫持、跨构建污染

SLSA Build L4  双人审查与可复现构建
  要求:所有构建步骤需要两人审查,构建过程完全可复现
  防住:内部人员作恶
  现状:业界实践极少,多数团队的目标是 L3

2.2 对照 GitHub Actions 的现状

能力                                    等级贡献
actions/attest-build-provenance          L2(签名 provenance)
GitHub 托管 runner(非自托管)            L3(隔离的托管构建环境)
environment 必需审批人                    L4 的部分能力(双人审查)
reusable workflow + CODEOWNERS           L4 的部分能力
可复现构建(固定时间戳、锁依赖版本)        L4 的必要条件

用 GitHub 托管 runner 加 attest-build-provenance,基本可以声称达到 SLSA Build L3(视具体审计口径而定)。自托管 runner 会拉低等级,因为构建环境不由平台保证隔离。

2.3 常见误解

误解 1:用了 SLSA 工具链就等于达到某个等级
  实际:等级是审计结论,工具只是提供证据

误解 2:SLSA 保证代码没有漏洞
  实际:SLSA 只保证制品与源码的对应关系未被破坏

误解 3:L1 没意义
  实际:L1 的 provenance 已经能回答「这个镜像对应哪个 commit」

误解 4:只要签名就够了
  实际:签名只证明是谁签的,provenance 才证明是怎么来的

三、GitHub 原生构建证明

3.1 前置权限

permissions:
  contents: read
  id-token: write        # 必需,用于 OIDC 换取签名身份
  attestations: write    # 必需,用于写入证明
  packages: write        # 推送镜像时需要

3.2 为镜像生成构建证明

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
      attestations: write
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/my-org/app:${{ github.ref_name }}

      - name: Attest build provenance
        uses: actions/attest-build-provenance@v2
        with:
          subject-name: ghcr.io/my-org/app
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true

subject-digest 必须是 sha256: 开头的摘要,不能用 tag。这是整个证明体系的基石:tag 可变,digest 不可变。

为二进制文件生成证明时改用 subject-path:

- name: Build and attest binary
  run: |
    go build -trimpath -ldflags="-s -w" -o dist/app ./cmd/app
    sha256sum dist/app > dist/app.sha256

- uses: actions/attest-build-provenance@v2
  with:
    subject-path: dist/app

3.3 SBOM 证明

- name: Generate SBOM
  uses: anchore/sbom-action@v0
  with:
    image: ghcr.io/my-org/app@${{ steps.push.outputs.digest }}
    format: cyclonedx-json
    output-file: sbom.cdx.json
    artifact-name: sbom.cdx.json

- name: Attest SBOM
  uses: actions/attest-sbom@v2
  with:
    subject-name: ghcr.io/my-org/app
    subject-digest: ${{ steps.push.outputs.digest }}
    sbom-path: sbom.cdx.json
    push-to-registry: true

3.4 查看与验证

gh attestation verify oci://ghcr.io/my-org/app:sha-abc1234 --owner my-org
gh attestation verify oci://ghcr.io/my-org/app:sha-abc1234 --owner my-org --format json > attestation.json
gh attestation download oci://ghcr.io/my-org/app:sha-abc1234 --owner my-org
验证输出中的关键字段
  predicateType   https://slsa.dev/provenance/v1 或 sbom 类型
  subject         制品名称与 sha256 摘要
  buildDefinition 构建类型、外部参数、依赖的源码引用
  runDetails      构建器 ID、构建时间、调用来源

四、事件选择与权限最小化

4.1 pull_request 与 pull_request_target

# 安全:默认的 pull_request 不向 fork 下发任何 secret
on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test
# 危险:pull_request_target 在基础仓库上下文中执行且持有 secret
on:
  pull_request_target:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # 灾难:checkout 了 fork 代码
      - run: npm ci && npm run build                      # 恶意脚本在此执行
正确用法(确实需要 pull_request_target 时)
  1) 绝不 checkout PR 的 head
  2) 只做打标签、评论、状态回写等不执行代码的动作
  3) 必须显式声明 permissions,默认收紧到 contents: read
  4) 若确需执行 PR 代码,用 workflow_run 两段式

4.2 GITHUB_TOKEN 最小权限

permissions:
  contents: read        # 顶层默认只读

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write   # 仅该 job 需要写
      id-token: write
    steps:
      - uses: actions/checkout@v4
推荐做法
  1) 组织级 Settings 把默认权限设为 read-only
  2) 每个 workflow 顶层声明 permissions: contents: read
  3) job 级按需提权,绝不写 permissions: write-all
  4) 关闭 Allow GitHub Actions to create and approve pull requests

4.3 fork PR 的两段式隔离

# 第一段:在无 secret 的环境中构建
name: Build from PR
on: pull_request

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: pr-build
          path: dist/
# 第二段:workflow_run 触发,有 secret 但不执行 PR 的代码
name: Deploy after build
on:
  workflow_run:
    workflows: ["Build from PR"]
    types: [completed]

jobs:
  deploy:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: pr-build
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - run: ./scripts/deploy.sh   # 只消费制品,不执行 PR 脚本
两段式的关键约束
  1) workflow_run 触发的 job 拿不到 fork 的 GITHUB_TOKEN,
     需要显式传 github-token 才能下载制品
  2) 下载的制品必须做完整性校验(摘要比对)
  3) 部署脚本必须来自基础仓库,不能来自制品

五、第三方 action 的固定策略

5.1 固定到 commit SHA

# 不安全:tag 可被移动,@master 可被随时改写
- uses: actions/checkout@v4
- uses: some-org/some-action@master

# 安全:固定到完整 commit SHA,tag 只作为注释
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
# 获取某个 tag 对应的 commit SHA
gh api repos/actions/checkout/git/ref/tags/v4.2.2 --jq '.object.sha'
# 若返回的是 tag 对象(type 为 tag),再解引用一次
gh api repos/actions/checkout/git/tags/<sha> --jq '.object.sha'

5.2 用 Dependabot 维护固定值

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    groups:
      actions:
        patterns: ["*"]
    commit-message:
      prefix: "chore(actions)"

没有这一步,固定 SHA 会迅速变成「永不升级」,长期停留在有漏洞的旧版本。

5.3 组织级白名单与自动检查

Settings → Actions → General → Actions permissions

● Allow select actions and reusable workflows
    [x] Allow actions created by GitHub
    [x] Allow Marketplace actions by verified creators
    [x] Allow specified actions and reusable workflows
        actions/*
        docker/*
        aws-actions/*
        my-org/shared-workflows/.github/workflows/*@*

白名单粒度建议用 owner/* 而非 */*,把信任边界限定到具体的组织;对涉及发布、部署、密钥的高危 action 单独逐个固定到 SHA。

# 用脚本断言所有 uses 都带 40 位 SHA
set -euo pipefail
bad=$(grep -rEn '^\s*-?\s*uses:\s*[^#]+@(v[0-9]+|main|master)\s*$' .github/workflows/ || true)
if [ -n "$bad" ]; then
  echo "::error::以下 action 未固定到 commit SHA"
  echo "$bad"
  exit 1
fi

六、SBOM 与依赖审查

6.1 生成与解析 SBOM

- name: Generate SBOM with Syft
  uses: anchore/sbom-action@v0
  with:
    path: .
    format: spdx-json
    output-file: sbom.spdx.json
    artifact-name: sbom.spdx.json
    upload-artifact: true
syft . -o cyclonedx-json > sbom.cdx.json
syft ghcr.io/my-org/app:sha-abc1234 -o spdx-json > image.spdx.json
jq -r '.components[] | "\(.name) \(.version)"' sbom.cdx.json | sort -u
SBOM 格式选择
  SPDX         Linux 基金会主导,许可证信息表达力强
  CycloneDX    OWASP 主导,漏洞与 VEX 支持好,工具链更活跃
  两者都支持 JSON 与 XML,选一个统一即可,不必都生成

6.2 dependency-review-action

name: Dependency review
on:
  pull_request:

permissions:
  contents: read

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - uses: actions/dependency-review-action@v4
        with:
          fail-on-severity: high
          deny-licenses: GPL-3.0, AGPL-3.0
          comment-summary-in-pr: on-failure
能做什么
  1) 对比 PR 前后依赖清单,列出新增与升级
  2) 阻断引入已知高危漏洞的版本
  3) 按许可证白名单或黑名单阻断
  4) 在 PR 中直接评论结论

不能做什么
  1) 不检查已存在的历史依赖(只在 PR 增量上生效)
  2) 不替代 SCA 工具对全量依赖的扫描

6.3 锁文件与禁用安装脚本

语言        锁文件                安装命令
Node        package-lock.json     npm ci
Python      poetry.lock           poetry install --no-root
Go          go.sum                go mod verify
Rust        Cargo.lock            cargo build --locked
Ruby        Gemfile.lock          bundle install --frozen
npm ci --ignore-scripts

npm 的 postinstall 脚本是供应链攻击的高频入口。对不信任的依赖,先禁用脚本安装,再按需显式执行必要的构建步骤。


七、cosign 签名与证明验证

7.1 用 cosign 做密钥无关签名

- uses: sigstore/cosign-installer@v3
  with:
    cosign-release: v2.4.1

- name: Sign image with keyless
  run: |
    cosign sign --yes \
      ghcr.io/my-org/app@${{ steps.push.outputs.digest }}
keyless 签名的原理
  1) cosign 通过 OIDC 向 Fulcio 申请一张短期证书
  2) 证书把签名身份绑定到 workflow 的 sub 声明
  3) 签名记录写入 Rekor 透明日志,公开可审计
  4) 没有长期私钥需要保管,也就没有私钥泄露风险

7.2 验证证明

# 验证构建证明,限定签发者与来源仓库
cosign verify-attestation \
  --type slsaprovenance \
  --certificate-identity-regexp '^https://github.com/my-org/my-repo/.github/workflows/.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/my-org/app@sha256:9f2c1a...e3b
验证时必须限定的两项
  --certificate-identity-regexp  谁签的(必须是自己的仓库与 workflow)
  --certificate-oidc-issuer     由哪个 OIDC 提供者签发

漏掉这两项,任何人都能用自己的 GitHub 账号签一个包冒充,
因为 cosign 默认不校验身份来源。

7.3 把验证放进部署流水线

  verify-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: sigstore/cosign-installer@v3

      - name: Verify provenance before deploy
        run: |
          cosign verify-attestation \
            --type slsaprovenance \
            --certificate-identity-regexp "^https://github.com/${{ github.repository }}/" \
            --certificate-oidc-issuer https://token.actions.githubusercontent.com \
            ghcr.io/my-org/app@${{ inputs.digest }} > /dev/null

      - name: Deploy only after verification
        run: ./scripts/deploy.sh ghcr.io/my-org/app@${{ inputs.digest }}

八、验证侧消费证明

8.1 Kubernetes 准入控制

用 Sigstore Policy Controller 在准入阶段拒绝未签名的镜像:

apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-gha-provenance
spec:
  images:
    - glob: "ghcr.io/my-org/**"
  authorities:
    - keyless:
        identities:
          - issuer: https://token.actions.githubusercontent.com
            subjectRegExp: "^https://github.com/my-org/my-repo/.github/workflows/.*$"
        ctlog:
          url: https://rekor.sigstore.dev
      attestations:
        - name: require-provenance
          predicateType: https://slsa.dev/provenance/v1
启用后的效果
  1) 任何没有有效构建证明的镜像,Pod 创建直接被拒绝
  2) 证明中的源码仓库与 workflow 必须匹配策略
  3) 攻击者即便拿到 registry 写权限,也无法让未签名镜像上线

8.2 用 Kyverno 校验

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-provenance
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-provenance
      match:
        any:
          - resources:
              kinds: [Pod]
      verifyImages:
        - imageReferences:
            - "ghcr.io/my-org/*"
          attestations:
            - predicateType: https://slsa.dev/provenance/v1
              attestors:
                - entries:
                    - keyless:
                        subject: "https://github.com/my-org/my-repo/.github/workflows/release.yml@refs/heads/main"
                        issuer: "https://token.actions.githubusercontent.com"
准入控制落地建议
  1) 先用 Audit 模式跑一周,观察会拦下哪些镜像
  2) 确认业务镜像全部有证明后,再切到 Enforce
  3) 给基础镜像配置例外规则
  4) 把 policy 本身也纳入 GitOps 管理

8.3 消费证明的其他场景

1) 制品仓库网关
   在 registry 前挂一层代理,拉取时校验证明,未签名直接 403

2) 部署流水线
   部署前用 cosign verify-attestation 校验,失败则中止

3) 合规审计
   定期导出全部证明与 SBOM,形成「这个版本由谁在何时构建」的证据链

4) 事故响应
   用 provenance 中的源码引用反查 commit,快速定位线上镜像对应哪次提交

九、常见踩坑与最小清单

9.1 常见踩坑

1) id-token: write 忘记声明
   报错:无法获取 OIDC token。attest 与 cosign keyless 都需要它。

2) attestations: write 缺失
   attest 步骤 403,但错误信息不直观,先检查权限块。

3) 用 tag 而非 digest 作为 subject
   attest-build-provenance 会直接报错,subject 必须是摘要。

4) cosign 验证未限定 identity
   任何人都能签一个同名包通过验证,等于没验证。

5) 固定 SHA 之后不再升级
   缺少 Dependabot 会导致长期停留在有漏洞的旧版本。

6) 自托管 runner 上做 attest
   构建环境不由平台保证隔离,SLSA 等级会受影响。

7) SBOM 只生成不使用
   生成后不归档、不比对、不做漏洞扫描,等于多花了几十秒。

8) workflow_run 两段式忘记校验制品
   第一段的产物可被 PR 作者影响,第二段必须比对摘要。

9.2 最小可用清单

必做
  [ ] GITHUB_TOKEN 默认只读,job 级提权
  [ ] 第三方 action 固定到 commit SHA 并用 Dependabot 维护
  [ ] 镜像用 digest 引用,不用可变 tag
  [ ] attest-build-provenance 生成证明并推到 registry
  [ ] 部署前 cosign verify-attestation 且限定 identity 与 issuer
  [ ] 禁止 pull_request_target 与 fork 代码 checkout 的组合

建议
  [ ] SBOM 归档并接入漏洞扫描
  [ ] dependency-review-action 阻断高危新增依赖
  [ ] 准入控制器校验证明,先 Audit 后 Enforce
  [ ] 证明与 SBOM 定期导出留存,形成审计证据链
  [ ] 组织级 Actions 白名单,禁止 Allow all

总结

供应链安全的核心不是「相信上游」,而是「让下游可以验证上游」。SLSA 用 L1 到 L4 把「有多少证据」分成了可度量的等级,GitHub 托管 runner 加上 actions/attest-build-provenance 已经能覆盖到 L3 的绝大部分要求。工程上要抓住三条主线:一是让制品不可变,镜像一律用 digest 引用,subject 绝不使用 tag;二是让来源可验证,id-token: write 换取签名身份,cosign verify-attestation 时严格限定 certificate-identity-regexp 与 OIDC issuer,否则验证形同虚设;三是让执行环境最小化,pull_request_target 与 fork 代码的组合是最高频的翻车点,GITHUB_TOKEN 默认只读、第三方 action 固定到 commit SHA 并用 Dependabot 维持更新。最后把证明真正消费起来,在部署流水线里校验、在 Kubernetes 准入阶段强制,供应链才从「有文档」变成「有防线」。

延伸阅读:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「github-actions」更多文章

  1. 多云部署编排与基础设施漂移检测
  2. 文档站与静态站点发布流水线
  3. AI 代码审查与 PR 助手集成