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
- A flake output that produces a hardened image. You build this with
lib.${system}.mkHardenedImage(derivation-first) ormkSecureArtifact(source-first). This is pure Nix — it produces the image and SBOM and nothing else. - 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.
| File | Build tier | Use it when |
|---|---|---|
/ci/components/hardened-artifact-slsa-l1.yml | slsa-l1 | You want the hardened build, SBOM and scan gate, but don’t require SLSA L2 or L3 conformance. |
/ci/components/hardened-artifact-slsa-l3.yml | slsa-l3 | Your 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(defaulttrue) - build the CycloneDX SBOM from the image’spassthru.sbom. See SBOMs.scan(defaulttrue) - Grype-scan the SBOM, then gate on Critical findings. Requiressbom. See VEX & vulnerability scanning.fips(defaultfalse) - before publishing anything, assert the image carries a validated FIPS label. See FIPS.hardened-builds-via-ci-job-token(defaulttrue) - fetch your flake’shardened-buildsinput withCI_JOB_TOKEN, since hardened-builds is currently private. See Fetchinghardened-buildsin CI below.artifact-expiry(default1 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(defaultnixos/nix:2.35.2) - the image providing thenixCLI. It needs Nix 2.26 or newer: older Nix refuses to fetch hardened-builds as a flake input, because itsflake.lockcontains a relative path input.
| Job | Runner | What it does |
|---|---|---|
hardened:build | untagged | Builds the image, publishes it as a job artifact, and exports its Nix closure to the CI cache. No secrets. |
hardened:sbom | untagged | Builds the SBOM, substituting from the build’s cached closure where it can. |
hardened:scan | untagged | Grype 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.internalParametersandrunDetails- 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 bothhardened:buildandhardened:scan(optional) from.hardened-nix-store. If it sets its ownbefore_script:, re-add!reference [.hardened-nix-cache, before_script]and!reference [.hardened-builds-via-ci-job-token, script](!referencedoes not followextends:). - If your pipeline defines its own
stages:, that list replaces the component’s, so it must includebuild,sbomandscan(andsignforslsa-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(defaulttrue) - keyless Sigstore sign the artifact (and SBOM). See Signing & provenance.attest(defaulttrue) - 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):
| Job | Runner | Why |
|---|---|---|
hardened:build | offline SLSA L3 VM (slsa-build-tag) | Realises the derivation from source under isolation. No secrets, no network, no signing key. |
hardened:sbom | shared runner (attic-shared-tag) | Substitutes the closure the offline build pushed to Attic; does not rebuild. |
hardened:scan | shared runner | Grype gate against the substituted SBOM. |
hardened:sign | shared runner | Holds 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.