Pipeline Security Hardening with SLSA#

Software supply chain attacks exploit the gap between source code and deployed artifact. The SLSA framework (Supply-chain Levels for Software Artifacts) defines concrete requirements for closing that gap. It is not a tool you install – it is a set of verifiable properties your build process must satisfy.

SLSA Levels#

SLSA defines four levels of increasing assurance:

Level 0: No guarantees. Most pipelines start here.

Level 1: The build process is documented. Provenance metadata exists but is not signed. This is the “at least you wrote it down” level.

Level 2: Provenance is generated by a hosted build service and is signed. The build platform authenticates the provenance, so consumers can verify that an artifact was built by a specific CI system from a specific source commit. This is the practical minimum for production systems.

Level 3: The build platform is hardened. Build definitions come from source control, build steps cannot be modified by developers during execution, and provenance is non-forgeable. GitHub Actions with reusable workflows satisfies most Level 3 requirements.

Level 4: Two-party review plus hermetic, reproducible builds. Almost nobody reaches this level today.

Provenance Generation with GitHub Actions#

Provenance answers: who built this, from what source, using what build process, and when? GitHub Actions can generate SLSA provenance natively using the slsa-github-generator project:

name: Build and Attest
on:
  push:
    tags: ['v*']

permissions:
  id-token: write
  contents: read
  packages: write
  attestations: write

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - name: Build and push image
        id: build
        run: |
          IMAGE=ghcr.io/${{ github.repository }}:${GITHUB_REF_NAME}
          docker build -t $IMAGE .
          docker push $IMAGE
          DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' $IMAGE | cut -d@ -f2)
          echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"

  provenance:
    needs: build
    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
    with:
      image: ghcr.io/${{ github.repository }}
      digest: ${{ needs.build.outputs.digest }}
    secrets:
      registry-username: ${{ github.actor }}
      registry-password: ${{ secrets.GITHUB_TOKEN }}

The slsa-github-generator runs as a reusable workflow on an isolated runner. It generates and signs provenance without exposing signing keys to your build job. This separation is what gets you to SLSA Level 3 – the provenance generation is decoupled from the build itself.

GitHub Artifact Attestations#

GitHub’s built-in attestation feature provides a simpler path to signed provenance. It uses Sigstore under the hood:

- name: Generate artifact attestation
  uses: actions/attest-build-provenance@v2
  with:
    subject-name: ghcr.io/${{ github.repository }}
    subject-digest: ${{ steps.build.outputs.digest }}
    push-to-registry: true

Verify the attestation locally:

gh attestation verify oci://ghcr.io/myorg/myapp:v1.2.0 \
  --owner myorg

This is the fastest way to get signed provenance on GitHub. It satisfies SLSA Level 2. For Level 3, use the slsa-github-generator reusable workflow instead.

Keyless Signing with Cosign and Fulcio#

Traditional code signing requires managing long-lived private keys – generating them, storing them securely, rotating them. Keyless signing eliminates this burden. Sigstore’s Fulcio acts as a certificate authority that issues short-lived signing certificates based on OIDC identity tokens.

In CI, the OIDC identity is the CI platform itself. GitHub Actions provides a token that Fulcio trusts:

- name: Sign container image
  uses: sigstore/cosign-installer@v3

- name: Sign with keyless
  run: |
    cosign sign --yes \
      ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
  env:
    COSIGN_EXPERIMENTAL: "0"

No keys to manage. Cosign requests a short-lived certificate from Fulcio using the GitHub OIDC token, signs the image digest, and records the signing event in Rekor. The certificate expires in minutes, but the Rekor entry provides permanent proof that the signature was valid at signing time.

Verify a keyless signature:

cosign verify \
  --certificate-identity-regexp "https://github.com/myorg/myapp/" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/myapp@sha256:abc123...

The --certificate-identity-regexp flag restricts verification to signatures created by workflows in your repository. Without it, any GitHub Actions workflow could have signed the image.

Rekor Transparency Log#

Every cosign keyless signature is recorded in Rekor, a public, immutable transparency log. This provides a tamper-evident record of all signing events. You can query it:

rekor-cli search --sha sha256:abc123def456...
rekor-cli get --uuid 24296fb24b8ad77a...

Rekor entries prove that a signature existed at a specific point in time. Even if a signing certificate has expired, the Rekor entry verifies that the signature was created while the certificate was valid. This is what makes keyless signing work without long-lived keys.

SBOM Generation in CI#

A Software Bill of Materials (SBOM) lists every dependency in your artifact. Generating SBOMs in CI and attaching them to images creates an auditable inventory for vulnerability management.

Use Syft to generate an SBOM and attach it with cosign:

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

- name: Attach SBOM to image
  run: |
    cosign attach sbom \
      --sbom sbom.spdx.json \
      ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}

- name: Sign the SBOM attestation
  run: |
    cosign attest --yes \
      --predicate sbom.spdx.json \
      --type spdxjson \
      ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}

This produces a signed attestation that binds the SBOM to the specific image digest. Consumers can verify both the image signature and the SBOM attestation.

SPDX and CycloneDX are the two standard SBOM formats. SPDX is more widely adopted for compliance (it is an ISO standard). CycloneDX is popular in the security tooling ecosystem. Pick one and stick with it.

Admission Enforcement#

Generating provenance and signing images is useless without enforcement. Use Kubernetes admission controllers to reject unsigned or unattested images:

# Kyverno policy: require cosign signature
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-signed-images
spec:
  validationFailureAction: Enforce
  rules:
    - name: verify-signature
      match:
        any:
          - resources:
              kinds: ["Pod"]
      verifyImages:
        - imageReferences: ["ghcr.io/myorg/*"]
          attestors:
            - entries:
                - keyless:
                    issuer: "https://token.actions.githubusercontent.com"
                    subject: "https://github.com/myorg/*"

With this policy, any pod using an image from ghcr.io/myorg/* that lacks a valid keyless signature from your GitHub Actions workflows is rejected at admission time.

Practical Path to SLSA Level 2-3#

Step 1 (Level 1): Add provenance metadata to your build. Even a JSON file recording the git SHA, build timestamp, and builder version is a start. Commit the provenance schema to your repository.

Step 2 (Level 2): Use actions/attest-build-provenance to generate signed provenance. Enable keyless cosign signing on all container images. This takes less than an hour to implement.

Step 3 (Level 3): Move your build logic into a reusable workflow. Use slsa-github-generator for provenance generation on an isolated runner. Pin all action references to specific SHAs (not tags, which can be moved). Enable branch protection rules requiring PR reviews for workflow changes.

Step 4: Add SBOM generation and signing. Deploy admission policies to enforce signatures in your clusters. Set up Rekor log monitoring to detect unexpected signing events.

Common Mistakes#

  1. Signing with long-lived keys stored as CI secrets. If the secret leaks, every past signature is compromised. Keyless signing with Fulcio avoids this entirely.
  2. Generating provenance in the same job that builds the artifact. A compromised build step can forge the provenance. Use a separate, isolated job or reusable workflow.
  3. Pinning actions by tag instead of SHA. Tags are mutable. actions/checkout@v4 can change without notice. Pin to the full SHA: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11.
  4. Signing images but not enforcing verification. Signatures without admission policies are security theater. Deploy Kyverno or Gatekeeper policies alongside your signing pipeline.