Automating SLSA Level 3 Compliance in GitHub Actions Pipelines

14 min read
Goh Ling Yong
Technology enthusiast and software architect specializing in AI-driven development tools and modern software engineering practices. Passionate about the intersection of artificial intelligence and human creativity in building tomorrow's digital solutions.

Deconstructing the Leap to SLSA Level 3

For senior engineers tasked with securing software delivery pipelines, the term 'SLSA' (Supply-chain Levels for Software Artifacts) has become unavoidable. While SLSA Levels 1 and 2 offer foundational improvements by requiring provenance and version control, they still leave a critical trust gap: the provenance itself can be forged or tampered with by a compromised build script. SLSA Level 3 closes this gap by introducing stringent requirements that shift the trust anchor from the user-controlled script to the build platform itself.

Understanding the nuances of the L2 to L3 transition is paramount for a successful implementation. It's not merely about adding more steps; it's about fundamentally changing who vouches for the build's integrity.

The Core Differentiators of SLSA Level 3

  • Ephemeral and Isolated Build Environment: The build must execute in a controlled environment that is provisioned specifically for that build and torn down afterward. This prevents state from one build from influencing another, mitigating risks like cache poisoning or credential leakage. GitHub-hosted runners satisfy this requirement out-of-the-box, as each job runs on a fresh virtual machine.
  • Service-Generated, Non-Forgeable Provenance: This is the crux of SLSA L3. The provenance document—a metadata file detailing how the software was built, including source commit, dependencies, and build commands—must be generated by the build service, not a user-controlled script within the build. More importantly, this provenance must be authenticated in a way that is cryptographically verifiable and cannot be spoofed. A malicious actor with commit access should not be able to generate a valid-looking provenance for a malicious artifact.
  • Authenticated Feed to External Services: The build platform must provide a strong, verifiable identity mechanism when interacting with external systems (like a signing service or artifact repository). This prevents a different build system from impersonating yours. This is where technologies like OpenID Connect (OIDC) become indispensable.
  • Achieving these requirements manually is complex and error-prone. Fortunately, the ecosystem around GitHub Actions and the Linux Foundation's Sigstore project has matured to a point where automation is not only possible but practical. This article will demonstrate the definitive pattern for achieving this.

    The Production Toolchain: GitHub Actions, OIDC, and Sigstore

    Our strategy hinges on a synergistic combination of three key technologies:

    * GitHub Actions: Provides the ephemeral build runners and, crucially, a built-in OIDC provider. This provider can issue short-lived JSON Web Tokens (JWTs) that are cryptographically signed by GitHub and contain verifiable claims about the specific workflow run (repository, commit_sha, job_workflow_ref, etc.).

    * Sigstore Project: An open-source suite of tools for signing and verifying software artifacts. We will use:

    * Fulcio: A root Certificate Authority that issues short-lived code-signing certificates based on OIDC tokens. This is the magic link: it transforms a verifiable statement about a CI/CD job into a cryptographic identity.

    * Rekor: A public, immutable transparency log. All signing events are recorded in Rekor, providing an auditable, timestamped history that prevents back-dating attacks and allows for discovery of compromised keys.

    * Cosign: A command-line tool that orchestrates the signing and verification process, interacting with Fulcio and Rekor.

    * SLSA Generic Generator: A trusted GitHub Action developed by the SLSA framework team. It acts as a wrapper around the build process, observing its inputs and outputs, and using the OIDC-to-Sigstore flow to generate and sign the final SLSA-compliant provenance attestation.

    This toolchain creates a verifiable chain of trust: a consumer can verify that an artifact was produced by a specific workflow in a specific repository at a specific commit, with the attestation signed by GitHub's trusted identity—not by a developer's GPG key checked into a secret.

    Implementation: A Multi-Stage Pipeline for a Go Application

    Let's build a production-grade workflow for a simple Go application. Our goal is to build a binary, generate SLSA L3 provenance for it, and then package both into a GitHub Release. The consumer can then download both and verify the binary's integrity.

    Our project structure:

    text
    . 
    ├── .github/
    │   └── workflows/
    │       └── release.yml
    ├── go.mod
    └── main.go

    main.go

    go
    package main
    
    import (
    	"fmt"
    	"runtime"
    )
    
    // Injected by the build process
    var version = "dev"
    var commit = "none"
    var date = "unknown"
    
    func main() {
    	fmt.Printf("App Version: %s\n", version)
    	fmt.Printf("Git Commit:  %s\n", commit)
    	fmt.Printf("Build Date:  %s\n", date)
    	fmt.Printf("Go Version:  %s\n", runtime.Version())
    	fmt.Printf("OS/Arch:     %s/%s\n", runtime.GOOS, runtime.GOARCH)
    }

    The GitHub Actions Workflow: `release.yml`

    This workflow is the heart of our implementation. It uses multiple jobs to separate concerns and ensure the provenance generation step is isolated and trusted.

    yaml
    name: SLSA L3 Release Pipeline
    
    on:
      push:
        tags:
          - 'v*'
    
    permissions:
      contents: read # Default to read-only permissions
    
    jobs:
      # Job 1: Build the Go binary
      build:
        runs-on: ubuntu-latest
        permissions:
          contents: read # This job only needs to read the source code
        outputs:
          binary-name: ${{ steps.get_name.outputs.name }}
        steps:
          - name: Checkout repository
            uses: actions/checkout@v4
    
          - name: Set up Go
            uses: actions/setup-go@v5
            with:
              go-version: '1.21'
    
          - name: Get build metadata
            id: metadata
            run: |
              echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
              echo "commit=${GITHUB_SHA}" >> $GITHUB_OUTPUT
              echo "date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
    
          - name: Build Go binary
            id: build
            env:
              CGO_ENABLED: 0
            run: |
              BINARY_NAME="my-app-${{ runner.os }}-${{ runner.arch }}"
              go build -trimpath -ldflags="-s -w -X main.version=${{ steps.metadata.outputs.version }} -X main.commit=${{ steps.metadata.outputs.commit }} -X main.date=${{ steps.metadata.outputs.date }}" -o "${BINARY_NAME}" main.go
              echo "name=${BINARY_NAME}" >> $GITHUB_OUTPUT
    
          - name: Upload artifact
            uses: actions/upload-artifact@v4
            with:
              name: ${{ steps.build.outputs.name }}
              path: ${{ steps.build.outputs.name }}
              retention-days: 1
    
      # Job 2: Generate SLSA Provenance
      provenance:
        needs: [build]
        permissions:
          actions: read # To read artifact produced by the build job
          id-token: write # CRITICAL: Required to authenticate to Sigstore's OIDC provider
          contents: write # To upload assets to a release
        uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
        with:
          base64-subjects: "${{ base64(needs.build.outputs.binary-name) }}"
          provenance-name: "${{ needs.build.outputs.binary-name }}.provenance.intoto.jsonl"
          upload-assets: true # Upload provenance to the release
    
      # Job 3: Create Release and Attach Binary
      release:
        needs: [build, provenance]
        runs-on: ubuntu-latest
        permissions:
          contents: write # To create the release and upload the binary
          actions: read # To download the binary artifact
        steps:
          - name: Download binary artifact
            uses: actions/download-artifact@v4
            with:
              name: ${{ needs.build.outputs.binary-name }}
    
          - name: Create GitHub Release
            env:
              GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
            run: |
              gh release upload ${{ github.ref_name }} ${{ needs.build.outputs.binary-name }} --clobber

    Analysis of the Workflow

    Let's break down the critical components:

  • Permissions Block: We follow the principle of least privilege. The top-level permissions are read-only. The provenance job is explicitly granted id-token: write, which allows it to request a JWT from GitHub's OIDC provider. This is the cornerstone of the entire process.
  • build Job: This is a standard build job. It checks out the code, builds the binary, and uploads it as a workflow artifact. Note the use of outputs to pass the generated binary name to downstream jobs. This avoids hardcoding and makes the workflow more robust.
  • provenance Job: This is where the SLSA L3 magic happens. Instead of running steps directly, we use a reusable workflow: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml. This is a deliberate design choice by the SLSA team. By calling a trusted, third-party workflow, you delegate the provenance generation to a context that your own build scripts cannot influence. This helps fulfill the "non-forgeable" requirement.
  • * uses: [email protected]: Pinning to a full-length commit SHA is even better for security, but pinning to a tag is a common and acceptable practice.

    * base64-subjects: This is how we tell the generator which artifact(s) to create provenance for. It expects a base64-encoded string containing the artifact names and their SHA256 digests. The slsa-github-generator has a simplified input where you can just pass the artifact name, and it will calculate the hash itself.

    * upload-assets: true: This convenient option tells the generator to automatically attach the generated provenance file to the GitHub Release created for the tag.

  • release Job: This job's primary responsibility is to attach the compiled binary to the same release. We use the official gh CLI for this. It runs after provenance generation, ensuring the release is only finalized once the attestation is complete.
  • Under the hood, the slsa-github-generator reusable workflow performs these steps:

  • Downloads the artifact specified in base64-subjects.
    • Calculates its SHA256 hash.
  • Requests an OIDC JWT from https://token.actions.githubusercontent.com.
    • Sends this JWT to the public Fulcio instance.
  • Fulcio validates the JWT, confirms it's from GitHub, and issues a short-lived signing certificate containing the workflow identity (e.g., https://github.com/my-org/my-repo/.github/workflows/release.yml@refs/tags/v1.0.0) in the Subject Alternative Name (SAN) field.
    • It generates the SLSA provenance predicate, detailing the builder ID, build steps, and artifact hashes.
    • It wraps this in an in-toto attestation.
  • It signs the attestation using the certificate from Fulcio and cosign.
    • It uploads the signature and certificate details to the Rekor transparency log.
  • It bundles the attestation and signature into the final .intoto.jsonl file.
  • Verification: Establishing the Chain of Trust

    Generating provenance is only half the battle. The true value lies in the consumer's ability to verify it. A user or an automated deployment system would perform the following steps after downloading the binary (my-app-linux-amd64) and its provenance (my-app-linux-amd64.provenance.intoto.jsonl) from the GitHub release.

    First, install the verifier:

    bash
    go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@latest

    Next, run the verification command:

    bash
    # Example for a release v1.0.0 from repository my-org/my-repo
    slsa-verifier verify-artifact \
      --provenance-path my-app-linux-amd64.provenance.intoto.jsonl \
      --source-uri github.com/my-org/my-repo \
      --source-tag v1.0.0 \
      my-app-linux-amd64

    If successful, the output will be:

    text
    Verified. OK

    What is slsa-verifier actually doing?

  • Signature Verification: It first checks the signature on the provenance file. It uses the public keys of Fulcio and Rekor to validate the signature chain. It queries the Rekor log by the signature's UUID to ensure the signing event is publicly recorded and timestamped.
  • Certificate Identity Check: It inspects the signing certificate (embedded in the Rekor entry). It verifies that:
  • * The OIDC issuer is https://token.actions.githubusercontent.com.

    * The SAN contains the expected builder workflow path: https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml.

  • Provenance Content Validation: Once the signature is trusted, it parses the provenance content itself.
  • * It calculates the SHA256 hash of the local binary file (my-app-linux-amd64) and ensures it matches the subject hash recorded in the provenance.

    * It checks that the source-uri and source-tag provided by the user match the repository and ref details recorded in the provenance. This prevents a valid provenance from one project from being used to endorse an artifact from another.

    If any of these steps fail, the verifier will exit with a non-zero status code and a detailed error message, preventing the artifact from being trusted.

    Advanced Edge Cases and Production Considerations

    While the above pattern is robust, senior engineers must consider several edge cases in real-world scenarios.

    Edge Case 1: Multi-Artifact and Container Builds

    What if your build produces multiple binaries (e.g., for different OS/architectures) or a Docker container image?

    * Multiple Binaries: The slsa-github-generator can handle this. You can pass a multi-line string of base64-encoded subjects. The best practice is often to have a matrix build job that produces all binaries, then a single downstream provenance job that attests to all of them at once.

    * Container Images: Attesting to container images is more complex because an image is a collection of layers (a manifest) rather than a single file. The SLSA team provides a separate reusable workflow for this: slsa-github-generator/.github/workflows/generator_container_slsa3.yml. This workflow integrates with tools like Docker BuildKit to capture layer digests and generate a provenance attestation for the final image manifest. The verification process is also slightly different, using cosign verify-attestation against the image tag in the registry.

    Edge Case 2: Self-Hosted Runners and Private Repositories

    The trust model described relies on the public, GitHub-hosted runners and the public Sigstore instance. This changes with self-hosted runners.

    Trust: When using self-hosted runners, the trust anchor is no longer GitHub's infrastructure, but your* runner management. A compromise of a self-hosted runner could allow an attacker to exfiltrate the OIDC token and sign malicious attestations. While still better than storing long-lived secrets, it weakens the SLSA guarantee. Achieving a high level of trust requires hardening the runners, using ephemeral runners (e.g., via the actions-runner-controller for Kubernetes), and tightly scoping their network access.

    * Verification Policy: Consumers of artifacts built on self-hosted runners must adjust their verification policy. They can no longer trust the default GitHub builder identity. Instead, they must configure their verifier to trust a specific builder ID corresponding to the self-hosted runner's workflow.

    Edge Case 3: Reproducible Builds as a Complementary Control

    SLSA L3 guarantees how an artifact was built, but it does not guarantee that the build is deterministic. Two builds from the same commit could produce bit-different binaries due to timestamps, random build IDs, or non-deterministic compiler behavior. This is where reproducible builds come in.

    By making your build process fully deterministic (e.g., using Go's -trimpath flag, zeroing out timestamps in archives), you enable a powerful secondary verification. Anyone can check out the same source code, run the build steps described in the SLSA provenance, and produce a bit-for-bit identical artifact. This provides the ultimate proof that the provenance is not only authentic but also accurate.

    Combining SLSA L3 non-forgeable provenance with a reproducible build process creates a formidable defense against supply chain attacks.

    Performance and Cost Considerations

    Introducing the provenance generation step adds a small overhead to the pipeline. The provenance job typically takes 30-60 seconds to complete, as it involves network calls to Fulcio and Rekor.

    * Latency: For most release pipelines, this additional minute is negligible compared to compilation, testing, and deployment times.

    * Cost: Since this runs on standard GitHub-hosted runners, the cost is simply the additional runner minutes consumed. For public repositories, this is free. For private repositories, the cost is minimal.

    The security benefits of a verifiable, non-forgeable build record far outweigh these minor performance and cost implications.

    Conclusion: From Theory to Production Reality

    Automating SLSA Level 3 compliance is no longer a theoretical exercise. By leveraging the native capabilities of GitHub Actions—specifically its OIDC provider—in concert with the public infrastructure offered by Sigstore, engineering teams can implement a robust, automated, and auditable system for generating non-forgeable build provenance.

    The pattern presented here—separating build and provenance generation into distinct jobs and using the official slsa-github-generator reusable workflow—is the industry-accepted best practice. It establishes a verifiable chain of trust from the source code to the final artifact, allowing consumers to cryptographically confirm that the software they are running is exactly what the original authors intended to build. For senior engineers responsible for the integrity of their software, mastering this pattern is an essential skill in the modern era of supply chain security.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles