Enforcing GitHub Artifact Attestations for SLSA Build Provenance

Enforcing GitHub Artifact Attestations for SLSA Build Provenance

Problem

Build provenance — a cryptographically verifiable record of where an artifact came from and how it was built — has been a supply chain security goal for years. The SLSA (Supply Chain Levels for Software Artifacts) framework formalized the requirements, but adoption remained low because generating and verifying attestations required significant infrastructure: Sigstore signing tooling, Fulcio certificate authority, Rekor transparency logs, custom CI steps, and verification tooling in deployment pipelines.

GitHub Artifact Attestations, generally available since May 2024, reduce the generation side to two lines of YAML in a GitHub Actions workflow. The attestation is generated using the workflow’s OIDC token, signed by Sigstore’s Fulcio CA, and stored on the GitHub attestations API alongside the artifact. Verification is equally simple via the gh CLI. Despite this accessibility, most teams have not adopted the feature, for two reasons.

First, generation is not automatic. Existing workflows continue to build and push container images and binary artifacts with no change in behavior unless a team explicitly adds the attestation step. The gap between “I could enable this” and “this is enforced” requires someone to drive the rollout.

Second, generation without enforcement is theater. An attacker who compromises a CI account or inserts a malicious workflow can still push an unattested artifact to the same registry. Without a policy layer at deploy time that requires a valid attestation from the expected repository, the attestation system is a best-effort paper trail rather than an active control.

The structural failure is at the enforcement layer. Most teams that do generate attestations verify them manually, if at all. Kubernetes admission controllers, deployment gates, and container registry policies rarely incorporate attestation checks. The result is a provenance record that exists in Sigstore’s transparency log but is never consulted.

This article covers: enabling attestation generation in GitHub Actions, verifying attestations in deployment workflows, and enforcing attestation presence via Kyverno admission policy in Kubernetes — creating a continuous control that blocks any unsigned or unattested image from entering the cluster.

Target systems: GitHub Actions (Actions runners, both GitHub-hosted and self-hosted); container images pushed to GitHub Container Registry (ghcr.io), Docker Hub, or AWS ECR; Kubernetes 1.26+ with Kyverno 1.11+ or OPA Gatekeeper 3.14+.


Threat Model

Adversary 1 — Compromised developer account. Access level: push access to a repository and ability to trigger GitHub Actions. Objective: push a malicious container image to the registry without going through the standard CI pipeline, bypassing code review and security scanning. Without attestation enforcement, the image is indistinguishable from a legitimately built one. With enforcement, the image lacks a valid attestation from the CI workflow and is blocked at deploy time.

Adversary 2 — Supply chain compromise of CI secrets. Access level: stolen GITHUB_TOKEN or registry push credential. Objective: push a backdoored image directly to the container registry, bypassing the CI pipeline entirely. Attestations cannot be forged without the OIDC token from an actual GitHub Actions job — direct registry pushes produce no attestation.

Adversary 3 — Malicious Helm chart or GitOps update. Access level: control over a GitOps repository or Helm chart that references an image. Objective: change the image tag to point to a malicious build from a different repository or workflow. Attestation enforcement validates that the image was built by the expected workflow in the expected repository, catching cross-repository substitution.

Adversary 4 — Internal namespace with relaxed policy. Access level: developer with kubectl apply access to a non-production namespace. Objective: deploy an unverified image in a “test” namespace that is later promoted or that has access to shared secrets. Namespace-scoped attestation enforcement prevents policy bypass via lower-privilege namespaces.

Without enforcement: any image, regardless of origin, can be deployed to Kubernetes. With enforcement: images lacking a valid GitHub-signed attestation from the registered source workflow are rejected by the admission controller before the pod is scheduled.


Configuration / Implementation

Step 1 — Add attestation generation to a GitHub Actions workflow

# .github/workflows/build-and-push.yml
name: Build and Push Container Image

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

permissions:
  contents: read
  packages: write
  id-token: write     # Required for OIDC token (used by attestation signing)
  attestations: write # Required to create attestations via the API

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-digest: ${{ steps.push.outputs.digest }}

    steps:
    - uses: actions/checkout@v4

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

    - name: Log in to GitHub Container Registry
      uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

    - name: Build and push image
      id: push
      uses: docker/build-push-action@v5
      with:
        context: .
        push: ${{ github.event_name != 'pull_request' }}
        tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
        # Ensure the digest is captured for attestation
        outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true

    - name: Generate artifact attestation
      uses: actions/attest-build-provenance@v1
      with:
        subject-name: ghcr.io/${{ github.repository }}
        subject-digest: ${{ steps.push.outputs.digest }}
        # push-to-registry: true attaches the attestation to the registry as well as GitHub's API
        push-to-registry: true

Key points:

  • id-token: write is mandatory — attestation signing uses the workflow’s OIDC token to prove the build ran in this specific repository and workflow.
  • attestations: write grants permission to persist the attestation via GitHub’s attestations API.
  • push-to-registry: true attaches the attestation to the OCI image in the registry, enabling registry-level verification.

Step 2 — Verify attestations locally with the gh CLI

# Install or update gh CLI
gh version  # Requires 2.49.0+

# Verify an image attestation
gh attestation verify \
  oci://ghcr.io/your-org/your-repo@sha256:abc123... \
  --owner your-org

# Expected output:
# Loaded digest sha256:abc123... for oci://ghcr.io/your-org/your-repo
# Loaded 1 attestation from GitHub API
# ✓ Verification succeeded!
#
#  Statement: <predicate-type> https://slsa.dev/provenance/v1
#  Extensions:
#    GitHub Actions workflow: .github/workflows/build-and-push.yml@refs/heads/main
#    GitHub Actions workflow SHA: a1b2c3d4e5f6...
#    Source repository: your-org/your-repo
#    Source repository ref: refs/heads/main
#    Runner environment: github-hosted

# Verify with source repository constraint (reject images from forks)
gh attestation verify \
  oci://ghcr.io/your-org/your-repo@sha256:abc123... \
  --owner your-org \
  --repo your-org/your-repo

Step 3 — Add verification to deployment workflows

Gate deployment workflows on a successful attestation check:

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  workflow_dispatch:
    inputs:
      image-digest:
        description: "Image digest to deploy (sha256:...)"
        required: true

permissions:
  id-token: write
  contents: read

jobs:
  verify-and-deploy:
    runs-on: ubuntu-latest
    steps:
    - name: Verify build attestation
      env:
        IMAGE_DIGEST: ${{ github.event.inputs.image-digest }}
      run: |
        gh attestation verify \
          "oci://ghcr.io/${{ github.repository }}@${IMAGE_DIGEST}" \
          --owner "${{ github.repository_owner }}" \
          --repo "${{ github.repository }}"
        echo "Attestation verified — proceeding with deployment"
      env:
        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    - name: Deploy
      # Only runs if attestation verification succeeds
      run: |
        echo "Deploying ${{ github.event.inputs.image-digest }}..."
        # Your deploy commands here

Step 4 — Enforce attestation in Kubernetes via Kyverno

Install Kyverno and configure a ClusterPolicy that requires GitHub attestations:

helm repo add kyverno https://kyverno.github.io/kyverno/
helm install kyverno kyverno/kyverno -n kyverno --create-namespace \
  --set admissionController.replicas=3

Create a ClusterPolicy requiring GitHub attestations:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-github-attestation
  annotations:
    policies.kyverno.io/title: Require GitHub Build Provenance Attestation
    policies.kyverno.io/description: >
      All container images must have a valid GitHub artifact attestation
      linking the image to a build in the approved GitHub organization.
spec:
  validationFailureAction: Enforce
  background: false
  webhookTimeoutSeconds: 30
  rules:
  - name: check-build-provenance
    match:
      any:
      - resources:
          kinds: [Pod]
          namespaces:
          - production
          - staging
    verifyImages:
    - imageReferences:
      - "ghcr.io/your-org/*"
      attestors:
      - count: 1
        entries:
        - keyless:
            # Fulcio root CA — Sigstore public good instance
            roots: |-
              -----BEGIN CERTIFICATE-----
              MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMw
              ...
              -----END CERTIFICATE-----
            subject: "https://github.com/your-org/*"
            issuer: "https://token.actions.githubusercontent.com"
      attestations:
      - predicateType: "https://slsa.dev/provenance/v1"
        attestors:
        - count: 1
          entries:
          - keyless:
              subject: "https://github.com/your-org/your-repo/.github/workflows/build-and-push.yml@refs/heads/main"
              issuer: "https://token.actions.githubusercontent.com"

For simpler enforcement using the Sigstore bundle format attached to OCI images:

  verifyImages:
  - imageReferences:
    - "ghcr.io/your-org/*"
    required: true
    verifyDigest: true
    repository: "ghcr.io/your-org"
    attestors:
    - entries:
      - keyless:
          subject: "https://github.com/your-org/your-repo/*"
          issuer: "https://token.actions.githubusercontent.com"
          rekor:
            url: "https://rekor.sigstore.dev"

Apply and test:

kubectl apply -f require-github-attestation.yaml

# Test: attempt to deploy an image without attestation
kubectl run test-unattested \
  --image=docker.io/library/nginx:latest \
  --namespace=production
# Expected: Error from server: admission webhook denied the request

# Test: deploy an attested image
kubectl run test-attested \
  --image=ghcr.io/your-org/your-repo@sha256:abc123... \
  --namespace=production
# Expected: pod created

Step 5 — Handle exemptions for system namespaces

# Add exclusions for kube-system and kyverno namespaces
    match:
      any:
      - resources:
          kinds: [Pod]
    exclude:
      any:
      - resources:
          namespaces:
          - kube-system
          - kyverno
          - cert-manager
          - monitoring

Step 6 — Generate SBOMs alongside attestations

Pair build provenance with software bill of materials attestations for complete artifact traceability:

    - name: Generate SBOM
      uses: anchore/sbom-action@v0
      with:
        image: ghcr.io/${{ github.repository }}@${{ steps.push.outputs.digest }}
        format: spdx-json
        output-file: sbom.spdx.json

    - name: Attest SBOM
      uses: actions/attest-sbom@v1
      with:
        subject-name: ghcr.io/${{ github.repository }}
        subject-digest: ${{ steps.push.outputs.digest }}
        sbom-path: sbom.spdx.json
        push-to-registry: true

Expected Behaviour

Signal Before enforcement After enforcement
gh attestation verify oci://... No attestation exists Returns ✓ Verification succeeded! with workflow details
Unattested image deployment to production Succeeds Rejected by Kyverno: admission webhook denied
Attested image from wrong repository N/A Rejected — subject does not match allowed repository pattern
SLSA provenance visible in GitHub UI Not present Visible under repository → Actions → Attestations
Registry manifest includes attestation reference Not present oras discover ghcr.io/your-org/your-repo@digest shows referrer entry

Verification:

# Check attestation is attached to registry image
oras discover --platform linux/amd64 \
  ghcr.io/your-org/your-repo:latest \
  --format tree
# Should show a referrers entry with mediaType containing "attestation"

# List all attestations for a repo
gh api /repos/your-org/your-repo/attestations \
  --jq '.attestations[] | {bundle_url: .bundle.dsseEnvelope.payload, created_at}'

Trade-offs

Aspect Benefit Cost Mitigation
Kyverno enforcement in Enforce mode Hard block on unattested images Blocks all unattested images including legitimate base images and third-party tools Use Audit mode first; build exemption list for verified third-party images; switch to Enforce after 2-week baseline
Binding attestation to specific workflow path Prevents fork-sourced builds Breaks if workflow is renamed or moved Use wildcard your-org/your-repo/* to cover all workflows in the repo; document path constraint
push-to-registry: true in attestation action Attestation co-located with image; verifiable without GitHub API Slightly increases image manifest size Overhead is negligible (<1 KB); OCI spec supports referrers natively
Keyless signing via Sigstore public good No key management required Relies on public Sigstore infrastructure (Fulcio, Rekor) availability For high-compliance environments, deploy private Sigstore (sigstore-helm-operator); configure Rekor URL accordingly

Failure Modes

Failure Symptom Detection Recovery
OIDC token unavailable in forked PR workflow Attestation step fails: “Error: OIDC token request failed” CI job fails; no attestation generated This is expected and correct — forked PRs should not get write permissions. Don’t add attestations to fork-triggered workflows
Kyverno webhook timeout causes pod admission to hang Pods stay in Pending; events show webhook timeout kubectl describe pod shows admission webhook timeout Increase webhookTimeoutSeconds; ensure Kyverno replica count ≥ 3; add failurePolicy: Ignore for non-critical namespaces (not production)
Rekor transparency log unreachable Attestation verification fails during deployment Deploy workflow fails with Rekor connection error Implement retry logic; for air-gapped environments, run private Rekor instance
Image rebuilt with same tag but different digest Policy allows old tag but new build has no attestation GitOps diff shows digest change; Kyverno blocks new digest Always pin by digest in GitOps manifests; re-attest on every build, not just tagged releases