Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Integrate an external project

This is the end-to-end version of the Quickstart: a whole project’s pipeline, from the flake output your team owns to the CI include that turns it into a signed, attested, scanned artifact. It also explains which jobs run where, and why the split matters.

The two things you provide

  1. A flake output that produces a hardened image. You build this with lib.${system}.mkHardenedImage (derivation-first) or mkSecureArtifact (source-first). This is pure Nix — it produces the image and SBOM and nothing else.
  2. A CI include that runs the parts a Nix function cannot: the offline SLSA L3 build, and then signing, attestation, and the vulnerability gate on a networked runner.

The flake output

{
  description = "my-service, packaged and hardened";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    hardened-builds.url = "git+https://gitlab.com/gitlab-org/software-supply-chain-security/hardened-builds?ref=main";
    hardened-builds.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs =
    { self, nixpkgs, hardened-builds }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs { inherit system; };
      hb = hardened-builds.lib.${system};

      myService = pkgs.buildGoModule {
        pname = "my-service";
        version = "1.4.0";
        src = ./.;
        vendorHash = "sha256-…";
      };
    in
    {
      packages.${system}.my-service-image = hb.mkHardenedImage {
        name = "my-service";
        version = "1.4.0";
        package = myService;
        cmd = [ "/bin/my-service" ];
      };
    };
}

The CI component references this output by name, so the output name (my-service-image) and the flake-target input must match.

Use the git+https URL for the hardened-builds input, not a gitlab: one, and while hardened-builds is private, set up your local credentials; the Quickstart explains both.

The .gitlab-ci.yml include

The component comes in two files, one per build tier (ADR-0044). Include exactly one.

FileBuild tierUse it when
/ci/components/hardened-artifact-slsa-l1.ymlslsa-l1You want the hardened build, SBOM and scan gate, but don’t require SLSA L2 or L3 conformance.
/ci/components/hardened-artifact-slsa-l3.ymlslsa-l3Your project has access to this project’s offline SLSA L3 runners and needs signed provenance.

Both run the same build, SBOM and scan steps. slsa-l3 builds on the offline VM and adds signing; slsa-l1 runs on any available GitLab runner, but does not provide signed builds and only meets SLSA L1 build level. Switching tiers means changing file: reference and the tier-specific inputs; your flake and build do not change.

slsa-l1

include:
  - project: "gitlab-org/software-supply-chain-security/hardened-builds"
    # Pin to an immutable ref. A commit SHA is the only fully immutable
    # option; a release tag is acceptable where you trust the maintainer.
    # Never track a branch: whatever lands on it executes in your pipeline.
    ref: main # TODO: switch this to a release tag once this project is released
    file: "/ci/components/hardened-artifact-slsa-l1.yml"
    inputs:
      flake-target: "my-service-image"
      artifact-name: "my-service"
      sbom: true
      scan: true
      fips: false
      hardened-builds-via-ci-job-token: true
      artifact-expiry: "1 month"
      nix-image: "nixos/nix:2.35.2"
  • flake-target - the flake output to build, i.e. .#<flake-target>. Required. Letters, digits and _.+- only.
  • artifact-name - the name used in output filenames. Required. Letters, digits and _.- only.
  • sbom (default true) - build the CycloneDX SBOM from the image’s passthru.sbom. See SBOMs.
  • scan (default true) - Grype-scan the SBOM, then gate on Critical findings. Requires sbom. See VEX & vulnerability scanning.
  • fips (default false) - before publishing anything, assert the image carries a validated FIPS label. See FIPS.
  • hardened-builds-via-ci-job-token (default true) - fetch your flake’s hardened-builds input with CI_JOB_TOKEN, since hardened-builds is currently private. See Fetching hardened-builds in CI below.
  • artifact-expiry (default 1 month) - how long the build job’s artifacts are kept. That includes the SLSA provenance, so keep what you release, for example by attaching it to a GitLab release.
  • nix-image (default nixos/nix:2.35.2) - the image providing the nix CLI. It needs Nix 2.26 or newer: older Nix refuses to fetch hardened-builds as a flake input, because its flake.lock contains a relative path input.
JobRunnerWhat it does
hardened:builduntaggedBuilds the image, publishes it as a job artifact, and exports its Nix closure to the CI cache. No secrets.
hardened:sbomuntaggedBuilds the SBOM, substituting from the build’s cached closure where it can.
hardened:scanuntaggedGrype gate against the SBOM.

The build job sets RUNNER_GENERATE_ARTIFACTS_METADATA, so GitLab Runner writes SLSA provenance (artifacts-metadata.json) listing the build job’s artifacts, including the image, by digest. The SBOM comes from a separate job, as in slsa-l3, so it is not in that provenance. The provenance is unsigned: SLSA Build L1. The artifacts exist before the scan runs, so release only from pipelines that succeeded.

What the provenance records

artifacts-metadata.json is an in-toto Statement with a SLSA Provenance v1 predicate. See GitLab’s field reference. For the build job it records:

  • subject - each artifact file (the image JSON and the cache public key) by SHA-256 digest.
  • externalParameters - the project (source), the job name (entryPoint), and the names of the job’s CI/CD variables. The values are always empty strings, by design, so secrets never reach the provenance.
  • resolvedDependencies - the project and the commit that was built.
  • internalParameters and runDetails - the runner, executor, architecture, job ID, and start and finish times.

It does not record the component’s inputs, such as flake-target or fips, or the hardened-builds ref your pipeline included. Recover those from the recorded commit: your .gitlab-ci.yml holds the include: ref and inputs, and your flake.lock pins every Nix input. That only works if the ref is immutable (a commit SHA, or a tag you trust), and if nothing overrides the component’s variables through project, group or pipeline CI/CD variables, whose values are not recorded either.

In both tiers the scan installs Grype with nix profile install --inputs-from . nixpkgs#grype, so its version comes from your flake’s flake.lock. Your flake must have an input named nixpkgs; otherwise Nix falls back to the unpinned global registry.

Fetching hardened-builds in CI

hardened-builds is currently a private GitLab project, so CI needs a credential to fetch your flake’s hardened-builds input. With hardened-builds-via-ci-job-token (default true), every job in this component, the scan included (it installs Grype with --inputs-from .), sends the job’s CI_JOB_TOKEN to git when it fetches the hardened-builds repository, and only that repository: other gitlab.com inputs don’t get the token. Your flake.lock is used unchanged, so this also works with a hardened-builds input pointed at a feature branch. A git+ssh input works too: git fetches it over HTTPS instead. Turn the input off only if you’ve already set up your own working credential for this input (e.g. a deploy key).

A job token can read a private project only when both of these hold, so check them before your first pipeline run, or hardened:build fails immediately:

  • Your project, or a group above it, is on hardened-builds’ CI/CD job token allowlist. Ask the hardened-builds maintainers to add it under their Settings > CI/CD > Job token permissions.
  • The user the pipeline runs as can read hardened-builds. That includes the owner of a scheduled pipeline and anyone who runs a pipeline on a merge request, so a contributor without access gets a failing pipeline even when your project is on the allowlist.

See GitLab’s job token documentation.

If you add your own job that evaluates your flake (see below), it needs the same credential. Jobs that extend .hardened-nix-store get it. Any other job can add !reference [.hardened-builds-via-ci-job-token, script] to its before_script: or the start of its script:.

This input goes away once hardened-builds is public.

Using the built image in your own jobs

A later job - a registry push, for example - can restore the image’s store paths from the build instead of rebuilding them, by extending .hardened-nix-store:

publish:
  extends: .hardened-nix-store
  image: nixos/nix:2.35.2
  script:
    - nix run .#my-service-image.copyTo -- --dest-creds "$REG_USER:$REG_PASS" docker://registry.example.com/my-service:latest

.hardened-nix-store restores the closure from the pipeline’s CI cache and trusts only store paths signed by the key hardened:build published as a job artifact. It waits for hardened:scan, so an image that failed the gate is not published, and runs in the .post stage by default.

  • If your job sets its own needs:, keep both hardened:build and hardened:scan (optional) from .hardened-nix-store. If it sets its own before_script:, re-add !reference [.hardened-nix-cache, before_script] and !reference [.hardened-builds-via-ci-job-token, script] (!reference does not follow extends:).
  • If your pipeline defines its own stages:, that list replaces the component’s, so it must include build, sbom and scan (and sign for slsa-l3).
  • The cache is shared between runners only when they have a distributed cache configured, as GitLab.com hosted runners do. Otherwise a job on a different runner restores nothing, and Nix builds or fetches the store paths itself.

slsa-l3

include:
  - project: "gitlab-org/software-supply-chain-security/hardened-builds"
    ref: main # TODO: switch this to a release tag once this project is released
    file: "/ci/components/hardened-artifact-slsa-l3.yml"
    inputs:
      flake-target: "my-service-image"
      artifact-name: "my-service"
      sbom: true
      sign: true
      attest: true
      scan: true
      fips: false
      slsa-build-tag: "nix-slsa-l3-amd64"
      attic-shared-tag: "nix-attic-shared"
      nix-image: "nixos/nix:2.35.2"

The inputs it shares with slsa-l1 behave the same. slsa-l3 has no hardened-builds-via-ci-job-token input. Its hardened:build runs on the offline SLSA L3 VM, which has no network and no secrets by design. Its hardened:sbom, hardened:scan and hardened:sign evaluate your flake on the shared runner, so while hardened-builds is private they need a credential for the hardened-builds input, which slsa-l3 does not set up yet. In addition:

  • sign (default true) - keyless Sigstore sign the artifact (and SBOM). See Signing & provenance.
  • attest (default true) - generate and sign a SLSA v1.0 provenance statement.
  • slsa-build-tag / attic-shared-tag - the runner tags for the offline build VM and the networked shared runner.

This is a security boundary, not just scheduling (ADR-0035):

JobRunnerWhy
hardened:buildoffline SLSA L3 VM (slsa-build-tag)Realises the derivation from source under isolation. No secrets, no network, no signing key.
hardened:sbomshared runner (attic-shared-tag)Substitutes the closure the offline build pushed to Attic; does not rebuild.
hardened:scanshared runnerGrype gate against the substituted SBOM.
hardened:signshared runnerHolds the Sigstore identity and network; signs the artifact + SBOM + provenance.

The build runs offline with no secrets. Every downstream job substitutes the closure with nix build … --max-jobs 0 — a cache miss fails rather than rebuilding off the VM, so the signed thing is provably the thing the offline VM built. Signing never touches the offline job: the VM has no key by construction, so it can’t. That’s the whole point of the split — read SLSA Level 3 builds for the isolation model.

Building from source instead

If your team would rather hand the pipeline a source tree and let hardened-builds detect the ecosystem and build it, swap mkHardenedImage for mkSecureArtifact. Start from the per-ecosystem guide: Go, Python, Ruby, Rust.