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: writeis mandatory — attestation signing uses the workflow’s OIDC token to prove the build ran in this specific repository and workflow.attestations: writegrants permission to persist the attestation via GitHub’s attestations API.push-to-registry: trueattaches 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 |
Related Articles
- SLSA Build Provenance — the SLSA framework levels and what provenance claims mean in practice
- SLSA Attestation Admission Verification — verifying SLSA attestations at Kubernetes admission time with Kyverno
- Container Image Attestations — OCI attestation formats and the referrers API
- Sigstore Keyless Signing — the Fulcio/Rekor infrastructure that GitHub Artifact Attestations build on
- GitHub Actions Supply Chain Hardening — pinning actions by digest, restricting token permissions, and branch protection