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

GitLab Hardened Builds

This project builds GitLab’s components from source, hermetically, with Nix — and attaches the supply-chain evidence that a secure release needs: a complete SBOM, SLSA provenance, Sigstore signatures, vulnerability scans with VEX exceptions, and (where it applies) FIPS-validated crypto.

You don’t have to be GitLab-the-monorepo to use it. If you have a gitlab-org/* project, you can hand this project a derivation — or just your source tree — and get back a hardened, signed, attested OCI image with a signed SBOM.

What you get

  • A hardened image. No shell in production images, non-root by default, minimal layers, standard OCI labels — built with the same buildGitlabImage builder GitLab’s own service images use.
  • A complete SBOM. CycloneDX, covering the Nix runtime closure and the language-ecosystem dependencies (Go modules, Rust crates, Ruby gems, Python wheels, npm/yarn packages) that a closure-only walk misses.
  • Signatures and provenance. Keyless Sigstore signatures over the artifact, the SBOM, and a SLSA v1.0 provenance statement.
  • A vulnerability gate. Grype against the SBOM, with OpenVEX exceptions so a known-not-affected CVE doesn’t block your release.
  • SLSA Level 3 isolation. The from-source build runs on an ephemeral, offline VM with no secrets and no network — the same tier this project builds its own artifacts on.
  • FIPS, optionally. Build under the FIPS package set and your image carries truthful FIPS labels backed by a validated module.

The two halves

The system splits cleanly, and it’s worth understanding why:

  1. A pure-Nix API you import from this flake (lib.${system}.mkHardenedImage / mkSecureArtifact). It produces the image and the SBOM. It’s deterministic, IFD-free, and needs no secrets — so it runs anywhere your nix build runs.

  2. A reusable CI component you include in your pipeline. It does the parts that can’t be a pure function: signing, attestation, the offline SLSA L3 build, and the scan gate. Those need this project’s signing key, its Sigstore identity, and its offline runners — so they live in CI, not in a Nix function you import.

This split is deliberate. An imported Nix function can’t hold a signing key or run an offline VM, so it doesn’t pretend to. See Signing & provenance and SLSA Level 3 builds for the details.

Where to start

  • New here? Read the Quickstart.
  • Need to check a term’s definition? See the Glossary
  • Integrating a whole project’s pipeline? See Integrate an external project.
  • Want the function signatures? The API reference is generated from the source doc-comments.
  • Looking for the live build evidence for a release? When the pipeline has build-matrix results, the Build matrix chapter (listed after the API reference) shows every image’s SBOM, signature, provenance, findings, and applied overrides.

This documentation describes the API defined in ADR-0039 and the requirements FR-091..FR-104 in the implementation spec. Where a capability is still being wired, the relevant page says so plainly rather than implying it’s finished.

Quickstart: request a hardened image

The fastest path is: import this flake, wrap a derivation you already build with mkHardenedImage, nix build it, and then add the CI include to get signing, attestation, and the scan gate.

1. A minimal consumer flake

You need a derivation for whatever you ship. It can be anything — pkgs.hello stands in for your real service below. The public API lives at hardened-builds.lib.${system} and is Linux-only (x86_64-linux, aarch64-linux).

{
  description = "My service, packaged as a hardened image";

  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};

      # Your real package goes here (buildGoModule / buildRustPackage / …).
      myService = pkgs.hello;
    in
    {
      packages.${system}.my-service-image = hb.mkHardenedImage {
        name = "my-service";
        version = "0.1.0";
        package = myService;
        cmd = [ "/bin/hello" ];
      };
    };
}

Use the git+https URL as shown. Don’t use a gitlab: URL: Nix’s gitlab: fetcher resolves a project in a subgroup to the wrong project, even with the %2F encoding the Nix manual suggests (NixOS/nix#6435).

hardened-builds is currently a private GitLab project, so nix needs your GitLab credentials to fetch it. If you already clone from GitLab over SSH, this makes git fetch this one repository over SSH, without changing the URL in your flake.nix or flake.lock:

$ git config --global \
    url."ssh://git@gitlab.com/gitlab-org/software-supply-chain-security/hardened-builds".insteadOf \
    "https://gitlab.com/gitlab-org/software-supply-chain-security/hardened-builds"

In CI the slsa-l1 component handles this for you; see Fetching hardened-builds in CI.

2. Build it

$ nix build .#my-service-image

That gives you the OCI image. mkHardenedImage also attaches a CycloneDX SBOM by default; it hangs off passthru.sbom:

$ nix build .#my-service-image.passthru.sbom

That is everything the pure-Nix half produces: the image and its SBOM. It does not sign, attest, scan, or run an SLSA L3 build — those need this project’s signing key, Sigstore identity, and offline runners, which a Nix function you import cannot hold. See Signing & provenance and SLSA Level 3 builds.

3. Add the CI component

Drop this into your .gitlab-ci.yml. It builds .#my-service-image, generates its SBOM and scans it on your project’s own runners, with SLSA Build L1 provenance written by GitLab Runner. For signed provenance on the offline SLSA L3 tier, include /ci/components/hardened-artifact-slsa-l3.yml instead:

include:
  - project: "gitlab-org/software-supply-chain-security/hardened-builds"
    ref: main # pin to a release tag in real use
    file: "/ci/components/hardened-artifact-slsa-l1.yml"
    inputs:
      flake-target: "my-service-image"
      artifact-name: "my-service"

flake-target is your flake package output (.#my-service-image). The rest of the inputs (sbom, scan, fips, …) default sensibly; Integrate an external project walks through each one, both build tiers, and which job runs where.

Scaffold instead of hand-writing

If you’d rather start from a working skeleton:

$ nix flake init -t 'git+https://gitlab.com/gitlab-org/software-supply-chain-security/hardened-builds?ref=main#default'

That drops a flake.nix and a starter .gitlab-ci.yml already wired to the component. Adapt the package and the flake-target/artifact-name inputs.

Where to next

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.

Go

mkSecureArtifact detects a Go project by the presence of a go.mod file at the root of src. It then builds the module with buildGoModule and hands the result to mkHardenedImage.

The marker

Detection is file-presence based: a go.mod at the top of src means “go”. If a .goreleaser.yml / .goreleaser.yaml is also present, detect records it as the buildConfigFile, but it does not act on it automatically — you opt into the goreleaser path explicitly (below).

Required and optional ecosystemArgs

  • vendorHash (required) — the buildGoModule vendor hash. There is no way around this; a missing vendorHash throws at eval time.
  • subPackages (optional) — which packages to build (default [ "." ]).
  • goreleaser (optional) — a parsed goreleaser config; when present, the build args (subPackages, ldflags, CGO_ENABLED) come from it instead of subPackages.
  • goVersion, buildId (optional) — only meaningful on the goreleaser path.

Plain buildGoModule (subPackages)

hb.mkSecureArtifact {
  name = "my-tool";
  version = "1.2.0";
  src = ./.;
  ecosystemArgs = {
    vendorHash = "sha256-…";
    subPackages = [ "cmd/my-tool" ];
  };
  image.cmd = [ "/bin/my-tool" ];
}

Anything under image.* is forwarded to mkHardenedImage — that is how you set cmd, entrypoint, env, and so on.

The goreleaser path

Many gitlab-org/* Go tools drive their release builds with goreleaser. Parse the config and pass it through so the resulting binary carries goreleaser’s ldflags (version stamping, CGO_ENABLED, the right main package):

let
  gorel = hardened-builds.lib.${system}.parseGoreleaser ./.goreleaser.yml; # see note
in
hb.mkSecureArtifact {
  name = "glab";
  version = "1.40.0";
  src = ./.;
  rev = self.rev or "0000000000000000000000000000000000000000";
  ecosystemArgs = {
    vendorHash = "sha256-…";
    goreleaser = gorel;
    goVersion = "1.22.3";
  };
}

The emitter resolves goreleaser’s {{.Version}}, {{.ShortCommit}}, and {{.Env.GO_VERSION}} template placeholders by plain string substitution against version, rev, and goVersion. rev must be the real full commit SHA — {{.ShortCommit}} is the first 7 characters of it — even if you fetch the source by tag elsewhere. Any other unresolved {{…}} placeholder throws at eval time rather than baking a literal template string into your binary.

Note: the goreleaser parser lives in lib/ecosystem/goreleaser.nix. Wire it into your consumer flake the same way you wire the rest of the API; the exact attribute name is in the API reference.

Derivation-first alternative

If the source-first dispatch doesn’t fit — a monorepo with several binaries, an unusual build, or you already have a package — build the buildGoModule derivation yourself and pass it straight to mkHardenedImage:

let
  myTool = pkgs.buildGoModule {
    pname = "my-tool";
    version = "1.2.0";
    src = ./.;
    vendorHash = "sha256-…";
    subPackages = [ "cmd/my-tool" ];
  };
in
hb.mkHardenedImage {
  name = "my-tool";
  version = "1.2.0";
  package = myTool;
  cmd = [ "/bin/my-tool" ];
}

You get the same image and SBOM either way; mkSecureArtifact is just the convenience wrapper around detect-then-build-then-image.

SBOM

The Go module set and the Go stdlib are folded into the CycloneDX SBOM automatically (the SBOM builder reads them off the finished buildGoModule derivation’s goModules). No extra configuration needed. See SBOMs.

FIPS

Go’s FIPS story is BoringCrypto (ADR-0026). Pass fips = true only if you built the package under the FIPS package set — the flag applies FIPS labels but does not convert a non-FIPS build. See FIPS.

Python

mkSecureArtifact detects a Python project by a pyproject.toml at the root of src. It resolves dependencies from a Poetry lock file or a uv lock file (see uv projects), builds the application, and hands the result to mkHardenedImage.

The marker

The primary marker is pyproject.toml. A poetry.lock or uv.lock alongside it is the strongest signal for a hermetic build and is recorded as the buildConfigFile.

Required and optional ecosystemArgs

  • lock (required) — a vendored relative path to the Poetry lock file, e.g. ./poetry.lock. It must be a path in your repository, not a read out of the fetched src. Reading a lock file out of a fetched derivation is import-from-derivation, which nix flake check --no-build cannot evaluate and the repo’s no-ifd-lockfile lint forbids.
  • python (optional) — the interpreter (default python312).
  • overrides (optional) — { "package-name" = derivation; } to replace a generated dependency entirely. Use it for packages with native (maturin / C) build steps, non-PyPI sources, or private sources.
  • exclude (optional) — a list of exact lock names to skip (Windows-only, wheel-only-with-no-linux-source, or intentionally omitted packages).
  • format (optional) — the Python build format (default "pyproject").

Worked example

Vendor the lock file into your repo first, then:

hb.mkSecureArtifact {
  name = "my-api";
  version = "2.0.1";
  src = ./.;
  ecosystemArgs = {
    lock = ./poetry.lock; # vendored, relative
    python = pkgs.python312;
    exclude = [ "pywin32" "pyroscope-io" ];
    overrides = {
      "some-rust-pkg" = mySomeRustPkgDrv;
    };
  };
  image.cmd = [ "/bin/my-api" ];
}

Under the hood, the emitter reads poetry.lock at eval time with builtins.fromTOML (Poetry locks carry per-package sha256 hashes, so no build-time conversion and no IFD), builds every dependency that nixpkgs does not already provide via buildPoetryDeps, and wires them in as propagatedBuildInputs. Packages already present in python.pkgs are reused, not rebuilt.

Generated dependency derivations skip their upstream test suites (doCheck = false) — those virtually always need network, live services, or dev-only deps not in the lock. If you need an import check on a specific package, supply it through overrides.

uv projects

When lock points at a uv.lock, the emitter builds the project with uv2nix instead of buildPoetryDeps. uv2nix reads uv.lock at eval time with builtins.fromTOML (no IFD), and build-system-pkgs supplies the build backends (setuptools, flit-core, hatchling, …) that uv.lock doesn’t record per package.

Unlike the Poetry path, every package comes from the lock at its locked version, including packages nixpkgs also carries, as the Go, Rust and Ruby emitters do. The result is a virtual environment with the project and its default dependencies installed (no optional extras), so the project’s console scripts end up in its bin/.

By default every package in uv.lock is built from its source distribution, and a dependency that only publishes wheels fails the evaluation. uv.lock doesn’t record build backends, so each source-built dependency needs its backend added through overrides (or tool.uv.extra-build-dependencies). The backends themselves come from build-system-pkgs, which installs uv-build (the uv_build backend uv init sets up) from a prebuilt PyPI wheel. It’s only used during the build and doesn’t end up in the image. Dependencies from git sources, and wheels without a hash, aren’t supported, since uv2nix fetches them without a hash check. Any such entry anywhere in uv.lock fails the evaluation, including ones in dev groups or for other platforms.

  • lock (required) — a vendored relative path to uv.lock, e.g. ./uv.lock, with a copy of the project’s pyproject.toml next to it (and of each workspace member’s, at the same relative path). uv2nix reads them at eval time; the project itself is built from src.
  • python (optional) — the interpreter (default python312). It must satisfy the project’s requires-python.
  • sourcePreference (optional) — "sdist" (default) or "wheel". "wheel" installs the locked, hash-pinned PyPI wheels where they exist, so dependencies don’t need their build backends added. AGENTS.md prefers building from source, so state the reason next to it in the project’s flake. Compiled wheels can bundle native libraries (e.g. OpenSSL in cryptography’s) that the SBOM’s closure walk doesn’t list.
  • overrides (optional) — { "package-name" = derivation; } to replace a package, or an overlay final: prev: { ... } to patch one, e.g. to point a package at a nixpkgs library.

format and exclude don’t apply to uv projects: the build format comes from each package’s metadata, and uv2nix evaluates each dependency’s environment markers, so platform-specific packages (e.g. Windows-only) are skipped on their own.

hb.mkSecureArtifact {
  name = "my-api";
  version = "2.0.1";
  src = ./.;
  ecosystemArgs = {
    lock = ./uv.lock; # vendored, relative
    python = pkgs.python313;
    overrides = final: prev: {
      some-pkg = prev.some-pkg.overrideAttrs (old: {
        buildInputs = (old.buildInputs or [ ]) ++ [ pkgs.zlib ];
      });
    };
  };
  image.cmd = [ "/bin/my-api" ];
}

Derivation-first alternative

If your build doesn’t fit the Poetry-lock shape, build the application yourself and image it directly:

let
  myApi = pkgs.python312.pkgs.buildPythonApplication {
    pname = "my-api";
    version = "2.0.1";
    src = ./.;
    format = "pyproject";
    propagatedBuildInputs = with pkgs.python312.pkgs; [ fastapi uvicorn ];
  };
in
hb.mkHardenedImage {
  name = "my-api";
  version = "2.0.1";
  package = myApi;
  cmd = [ "/bin/my-api" ];
}

SBOM

Python wheels/sdists in the closure are captured in the CycloneDX SBOM. See SBOMs for how ecosystem dependencies are folded in and how to override auto-detection when needed.

Ruby

mkSecureArtifact detects a Ruby project by a Gemfile.lock at the root of src. It builds a bundlerEnv from an enriched gemset and hands the result to mkHardenedImage.

The marker

The primary marker is Gemfile.lock. A Gemfile alongside it is recorded as the buildConfigFile.

Required and optional ecosystemArgs

  • gemset (required) — a vendored relative path to a gemset.nix, e.g. ./gemset.nix. Unlike Poetry’s lock, a Gemfile.lock does not carry content hashes, so gemset.nix (produced by bundix as an offline pre-step) is what supplies them. It must be a vendored path in your repo — never a read out of the fetched src (that is import-from-derivation, forbidden by the no-ifd-lockfile lint).
  • ruby (optional) — the interpreter (default ruby_3_3, falling back to ruby).
  • gemdir (optional) — the directory holding the Gemfile/Gemfile.lock (default src).

Worked example

Generate gemset.nix with bundix, vendor it into your repo, then:

hb.mkSecureArtifact {
  name = "my-rails-app";
  version = "3.1.0";
  src = ./.;
  ecosystemArgs = {
    gemset = ./gemset.nix; # vendored, relative
    ruby = pkgs.ruby_3_3;
  };
  image.cmd = [ "/bin/rails" "server" ];
}

The emitter imports the gemset and runs it through enrichGemset, which substitutes gems already built in nixpkgs for their pre-built store paths so bundlerEnv only source-fetches the gems nixpkgs doesn’t carry. The default match mode is exact (substitute only on an exact version match), which keeps Bundler’s strict lockfile-version enforcement happy. enrichGemset also guards against a substitution dragging a conflicting version of a monkey-patched gem (e.g. net-http) onto GEM_PATH.

Derivation-first alternative

If you need finer control over the bundlerEnv (custom matchMode, extra nativeBuildInputs, explicit nixpkgsGems), build it yourself and image it:

let
  gems = pkgs.bundlerEnv {
    name = "my-rails-app-gems";
    ruby = pkgs.ruby_3_3;
    gemdir = ./.;
  };
  app = pkgs.stdenv.mkDerivation {
    pname = "my-rails-app";
    version = "3.1.0";
    src = ./.;
    nativeBuildInputs = [ gems gems.wrappedRuby ];
    # …install phase…
  };
in
hb.mkHardenedImage {
  name = "my-rails-app";
  version = "3.1.0";
  package = app;
  cmd = [ "/bin/rails" "server" ];
}

SBOM

Ruby gems cannot be inferred from the built derivation the way Go modules or Rust crates can — their inventory lives in the gemset.nix. So the SBOM builder needs you to point at the vendored gemset explicitly with sbomEcosystems:

hb.mkSecureArtifact {
  name = "my-rails-app";
  version = "3.1.0";
  src = ./.;
  ecosystemArgs.gemset = ./gemset.nix;
  sbomEcosystems = [ { kind = "ruby"; gemset = ./gemset.nix; } ];
}

That vendored-path rule is the same no-IFD discipline as everywhere else — see SBOMs.

Rust

mkSecureArtifact detects a Rust project by a Cargo.toml at the root of src. It builds the crate with rustPlatform.buildRustPackage from a vendored Cargo.lock and hands the result to mkHardenedImage.

The marker

The primary marker is Cargo.toml. A rust-toolchain.toml alongside it is read for the toolchain channel and recorded as the buildConfigFile.

Required and optional ecosystemArgs

  • cargoLock (required) — a vendored relative path to Cargo.lock, written exactly as ./Cargo.lock.
  • outputHashes (optional) — cargoLock.outputHashes for git dependencies. Git deps have no crates.io checksum, so they need an explicit "<crate>-<version>" -> sha256 entry. Omit it entirely when the lock has no git dependencies.

The vendored Cargo.lock / no-IFD rule

This one bites people, so it’s worth stating plainly: cargoLock must be a vendored relative path, never "${src}/Cargo.lock". Reading the lock out of the fetched source is an import-from-derivation — nix flake check --no-build can’t evaluate it, and the repo’s no-ifd-lockfile lint rejects it. The emitter even asserts against a /nix/store/ string sneaking in and throws with a pointer to this rule. Copy Cargo.lock into your repo and reference it relatively.

hb.mkSecureArtifact {
  name = "my-cli";
  version = "0.9.0";
  src = ./.;
  ecosystemArgs = {
    cargoLock = ./Cargo.lock; # vendored — NOT "${src}/Cargo.lock"
    # outputHashes = { "some-git-crate-1.2.3" = "sha256-…"; };
  };
  image.cmd = [ "/bin/my-cli" ];
}

Derivation-first alternative

If your crate needs build inputs, features, or flags the emitter doesn’t take, build it yourself and image it directly:

let
  myCli = pkgs.rustPlatform.buildRustPackage {
    pname = "my-cli";
    version = "0.9.0";
    src = ./.;
    cargoLock.lockFile = ./Cargo.lock; # still vendored, still relative
    buildInputs = [ pkgs.openssl ];
    nativeBuildInputs = [ pkgs.pkg-config ];
  };
in
hb.mkHardenedImage {
  name = "my-cli";
  version = "0.9.0";
  package = myCli;
  cmd = [ "/bin/my-cli" ];
}

FIPS composition (aws-lc-rs, ADR-0028)

Rust’s FIPS path is aws-lc-rs in FIPS mode (ADR-0028), not a flag you flip on an ordinary build. To ship a FIPS Rust artifact you build the crate under the FIPS package set — which composes the aws-lc-rs FIPS module — and only then pass fips = true so the image carries truthful FIPS labels. As everywhere, fips = true is a truthfulness contract: it labels; it does not convert a non-FIPS build. See FIPS.

SBOM

Rust crates are folded into the CycloneDX SBOM automatically — the SBOM builder reads the Cargo.lock inside the vendored cargoDeps on the finished derivation, so no extra configuration is needed. See SBOMs.

Bring your own derivation

Ecosystem detection covers Go, Rust, Python, and Ruby. When your project isn’t one of those — an unsupported ecosystem, a C/C++ build, a mixed build, or anything you’d rather assemble by hand — you skip detection entirely. There are two ways.

Option 1: mkHardenedImage directly

If you already have (or can build) the derivation, this is the simplest path. mkHardenedImage wraps an already-built derivation into a hardened image and attaches its SBOM. It does no detection at all.

let
  myThing = pkgs.stdenv.mkDerivation {
    pname = "my-thing";
    version = "1.0.0";
    src = ./.;
    nativeBuildInputs = [ pkgs.cmake ];
    # …your build…
  };
in
hb.mkHardenedImage {
  name = "my-thing";
  version = "1.0.0";
  package = myThing;
  cmd = [ "/bin/my-thing" ];
}

This is the headline entry point — mkSecureArtifact funnels into it once it has a derivation. If you’re not using the ecosystem dispatch, prefer this over mkSecureArtifact; there’s no reason to route through detection you don’t want.

Option 2: mkSecureArtifact with overrideDerivation

If you’re inside a pipeline that’s already shaped around mkSecureArtifact and you want to keep that shape but bypass detect-and-build for one artifact, pass overrideDerivation. It short-circuits detection and building entirely and images the derivation you hand it:

hb.mkSecureArtifact {
  name = "my-thing";
  version = "1.0.0";
  overrideDerivation = myThing; # skip detect + build, image THIS
  image.cmd = [ "/bin/my-thing" ];
}

When you use overrideDerivation, you don’t need src — the throw that mkSecureArtifact raises for an undetectable src doesn’t fire, because detection is skipped. The result’s passthru.ecosystem is { ecosystem = "custom"; }, a marker that this artifact came from an override rather than a detected ecosystem.

Which one?

They produce the same image and SBOM. Use mkHardenedImage when you’re building images directly, and overrideDerivation only when you want a single uniform mkSecureArtifact call site across a mix of detected and custom artifacts.

Everything else still applies

However you produce the derivation, the rest is unchanged: the CI component signs, attests, and scans it exactly the same way (see Integrate an external project), the SBOM covers its Nix closure (see SBOMs), and fips = true remains a truthfulness contract you must back with a real FIPS build (see FIPS).

SBOMs

Every hardened image comes with a CycloneDX SBOM. This page covers what it covers, how to get it, and the one rule you have to follow to keep it honest.

What it covers

The SBOM builder (lib/sbom, ADR-0036) wraps bombon to produce a complete inventory:

  • The Nix runtime closure — every store path the image needs at runtime, the same walk sbomnix did (ADR-0015).
  • The language-ecosystem dependencies that Nix vendors into a single store path and that a closure-only walk therefore misses: Go modules plus the Go stdlib, Rust crates, Ruby gems, Python wheels/sdists, and yarn/npm packages.

That second half is the part that matters for vulnerability matching. A CVE against a specific gem, crate, module, or wheel only shows up if that dependency is named in the SBOM — a closure-only SBOM can’t match it, so the scan gate would silently miss it. See VEX & vulnerability scanning.

How to get it

In pure Nix, the SBOM hangs off the image’s passthru:

$ nix build .#my-service-image.passthru.sbom

mkHardenedImage (and therefore mkSecureArtifact) attaches it by default (sbom = true). Set sbom = false to skip it, in which case passthru.sbom is absent.

In CI, the component’s hardened:sbom job builds passthru.sbom. Under slsa-l1 it substitutes from the build job’s cached closure where it can, and builds or fetches anything missing. Under slsa-l3 it runs on the shared runner with --max-jobs 0 (substitute-only — the closure comes from the offline build’s Attic push, never a rebuild), copies out the *.cdx.json, and — when sign = true — signs it. See Integrate an external project.

Auto-detection vs the sbomEcosystems override

The builder auto-detects go and rust from robust markers on the finished derivation (goModules, cargoDeps) and consumes them without rebuilding.

Ruby and yarn/npm can’t be auto-detected — their inventory lives in a gemset.nix or a yarn.lock, not in anything readable off the built derivation. For those you declare the ecosystem explicitly with sbomEcosystems, which is threaded onto the package before imaging:

hb.mkHardenedImage {
  name = "my-rails-app";
  version = "3.1.0";
  package = myRailsApp;
  sbomEcosystems = [
    { kind = "ruby"; gemset = ./gemset.nix; }
    { kind = "yarn"; yarnLock = [ ./yarn.lock ]; }
  ];
}

The no-IFD vendored-path rule

Every lockfile, gemset, or yarn.lock path in sbomEcosystems must be a vendored relative path (./gemset.nix, ./yarn.lock) — never an interpolated read out of a fetched source like "${src}/yarn.lock". Reading a lock file out of a fetched derivation is an import-from-derivation, which nix flake check cannot evaluate and the repo’s no-ifd-lockfile lint rejects. Copy the lock/gemset into your repository and reference it relatively.

This is the same discipline the Rust cargoLock, Python lock, and Ruby gemset args follow throughout the ecosystem emitters — see those pages for the per-ecosystem detail.

Reference

The design and rationale (why bombon, why our own consuming emitters rather than bombon’s rebuild-the-package emitters) are in ADR-0036.

Signing & provenance

Signing and provenance are delivered by the CI component, not the Nix API. This page explains what gets signed, why it can’t be pure Nix, where it runs, and how to verify it.

Of the two CI components, only slsa-l3 (hardened-artifact-slsa-l3.yml) signs. The slsa-l1 component signs nothing; its SLSA provenance is the unsigned artifacts-metadata.json GitLab Runner writes for the build job. See Integrate an external project.

What gets signed

The component’s hardened:sign job produces three keyless Sigstore signatures (ADR-0015):

  1. The artifact — cosign sign-blob over the built image path.
  2. The SBOM — when sbom = true, the CycloneDX *.cdx.json.
  3. A SLSA v1.0 provenance statement — when attest = true, an in-toto Statement with a https://slsa.dev/provenance/v1 predicate naming the flake target, the builder, and the artifact digest, itself signed.

Each produces a .sigstore.bundle artifact. Signing is keyless: the job requests a Sigstore OIDC token (SIGSTORE_ID_TOKEN, aud: sigstore) and cosign records the signature in the transparency log against that CI identity.

Why it can’t be pure Nix

mkHardenedImage / mkSecureArtifact produce the image and SBOM as deterministic, IFD-free Nix — no secrets, no network. Signing is the opposite: it needs an OIDC identity, network access to the Sigstore infrastructure, and the transparency-log upload. A Nix function you import into your own flake can’t hold any of that, so it doesn’t pretend to. The honest boundary is: pure Nix makes the thing; CI signs the thing.

Where it runs, and why it’s a boundary

Signing runs only on the networked nix-attic-shared runner, never on the offline SLSA L3 build VM (ADR-0035). The build VM has no key, no network, and no secrets by construction — that’s what makes its build trustworthy. Adding a signing step, a registry login, or any secret to the build job would break that invariant, so the component never does. The signing job resolves the already-built image with nix build … --max-jobs 0 (substitute-only, so it signs exactly what the offline VM built) and signs it there.

See SLSA Level 3 builds for the full isolation model.

Verifying

With cosign, verify a bundle against the CI identity that produced it:

$ cosign verify-blob \
    --bundle my-service.sigstore.bundle \
    --certificate-identity-regexp 'https://gitlab\.com/.*' \
    --certificate-oidc-issuer https://gitlab.com \
    "$IMAGE_PATH"

Use the SBOM bundle to verify the SBOM the same way, and the provenance bundle to verify provenance.json. Match --certificate-identity / --certificate-oidc-issuer to the project and issuer that ran the pipeline.

Reference

Sigstore-over-Nix and the SBOM/provenance shape are ADR-0015; the offline-build / signing-on-shared-runner boundary is ADR-0035.

SLSA Level 3 builds

The from-source build runs on an ephemeral, offline VM with no secrets and no network. Everything downstream substitutes that build’s output rather than rebuilding it. That combination is what earns SLSA Level 3, and it’s a security boundary the component enforces (ADR-0035).

Choosing this tier

Include /ci/components/hardened-artifact-slsa-l3.yml. This requires access to specific tagged runners, including an offline builder runner. The alternative, hardened-artifact-slsa-l1.yml, runs the same build, SBOM and scan steps on any available runner, but does not provide offline isolation or signing. See Integrate an external project.

The two runner roles

CI uses two runner roles, and which one a job lands on is a security decision:

  • nix-slsa-l3-amd64 / nix-slsa-l3-arm64 — ephemeral, offline VMs that realise derivations from upstream source under isolation. No network egress, no Attic client, no secrets, no signing key. The runner-manager powers the VM off after the build and extracts the outputs out-of-band. This is the only place a from-source build runs.
  • nix-attic-shared — the persistent, networked host: shared Nix store, Attic substituter/push, network, and the secrets (Sigstore OIDC, registry creds, signing key). Everything that is not a from-source build runs here.

How the component uses them

In hardened-artifact-slsa-l3.yml, the hardened:build job carries the slsa-build-tag (nix-slsa-l3-amd64 by default) and does exactly one thing:

script:
  - nix build ".#$[[ inputs.flake-target ]]" --print-out-paths --no-link

It runs offline. Nothing secret happens. The runner-manager extracts the closure and pushes it to the shared Attic afterward.

Every downstream job — SBOM, scan, sign, attest — runs on nix-attic-shared and resolves the artifact with:

- nix build ".#$[[ inputs.flake-target ]]" --print-out-paths --no-link --max-jobs 0

--max-jobs 0 means “do not build anything; get it from a substituter.” A cache miss fails the job rather than quietly rebuilding on the shared runner. So the SBOM’d, scanned, and signed artifact is provably the one the offline VM produced — no off-VM rebuild can slip in.

Why this is SLSA L3

SLSA Level 3 asks for a hardened, isolated build platform whose provenance is non-forgeable. Here:

  • the build is hermetic (every input is a pinned, hash-checked fetcher);
  • it runs on an ephemeral, isolated VM with no network and no secrets;
  • the VM cannot sign anything, so provenance is generated afterward on a separate, credentialed host over the exact substituted output.

The security invariant

Signing, secrets, and network must not touch the offline VM. The component never adds a signing step, an Attic push, a registry login, or a secret to hardened:build. Signing happens post-build on nix-attic-shared, where the key lives and the transparency-log upload can reach the network. See Signing & provenance.

Reference

The runner-role model, the offline-build invariant, and the substitute-only downstream discipline are ADR-0035.

VEX & vulnerability scanning

The component scans the SBOM with Grype, applies OpenVEX exceptions so a known-not-affected CVE doesn’t block your release, and gates on Critical findings. This page explains the scan, the VEX layer, and why scanning and gating are split.

The scan

hardened:scan runs when scan = true. It scans the CycloneDX SBOM produced by hardened:sbom — not a re-derived inventory — so the crates, modules, gems, and wheels folded into the SBOM (see SBOMs) are all in scope:

$ grype "sbom:sbom-<artifact-name>.cdx.json" -o json > grype.json

Scanning the SBOM (rather than the image) is what makes ecosystem-dependency CVEs matchable at all — a closure-only scan would miss them.

OpenVEX exceptions

Not every reported CVE actually affects your build. A vulnerability might be in a code path you don’t compile, or already mitigated. OpenVEX (Vulnerability Exploitability eXchange) lets you record, as signed data, that a specific CVE is not_affected for this artifact — so the gate doesn’t block on it (ADR-0014).

You ship your exceptions in your own .openvex/ directory, the same way this repo does. Each exception is a deliberate, reviewed statement, not a blanket mute — it names the CVE and the justification. A CVE with no exception is treated as live.

The scan → gate split

Scanning and gating are deliberately two concerns (ADR-0033):

  • Scan produces the full Grype report (grype.json, kept as an artifact when: always) — the complete picture, exceptions and all.
  • Gate is the pass/fail decision: after VEX exceptions are applied, if any Critical finding remains, the job exits non-zero and the pipeline fails.

Keeping them separate means you always have the full report to triage from, even on a failed pipeline, and the gate’s yes/no is a clean, auditable step rather than buried in scan output.

CRIT=$(jq '[.matches[] | select(.vulnerability.severity=="Critical")] | length' grype.json)
# CRIT > 0  ⇒  gate FAILS

Adding an exception

When the gate flags a Critical you’ve determined is not applicable, add an OpenVEX statement to your .openvex/ directory with the CVE ID and a justification, and re-run. The scan still reports it; the gate no longer blocks on it. Treat this as a reviewed decision — an exception is an assertion you’re standing behind, per this project’s allowlisting discipline.

Reference

OpenVEX-for-Nix-backported-CVEs is ADR-0014; the scan/gate split (and the provenance/dashboard split alongside it) is ADR-0033.

FIPS

fips = true is a truthfulness contract, not a switch that makes a build FIPS. This page is short on purpose, because the honesty of the flag matters more than anything else about it.

What fips = true actually does

Passing fips = true to mkHardenedImage / mkSecureArtifact builds the wrapping image under the FIPS package set, which flips buildGitlabImage’s FIPS marker so the image carries FIPS OCI labels (e.g. com.gitlab.image.fips = "true").

That is all it does. It applies labels. It does not convert a non-FIPS build into a FIPS one.

The contract

The flag is a promise you are making: the caller MUST have actually built package under the FIPS package set. If you label a non-FIPS build as FIPS, you’ve shipped a lie — an image that claims validated crypto it doesn’t have.

So the correct sequence is always:

  1. Build your derivation under the FIPS package set, so it composes a validated FIPS module:
    • Go: BoringCrypto (ADR-0026).
    • Rust: aws-lc-rs in FIPS mode (ADR-0028).
    • The base crypto libraries follow the FIPS composition (ADR-0002).
  2. Then pass fips = true so the labels tell the truth.

For source-first builds via mkSecureArtifact, fips = true also selects the FIPS package set for the ecosystem build itself, so the derivation and the labels stay consistent.

The component asserts it

Because the flag is only a label, the CI component adds a runtime check so a mislabelled image can’t slip through. When you pass fips: true to the component, it refuses to go further unless the image genuinely carries the FIPS label: under slsa-l3, hardened:sign refuses to sign; under slsa-l1, hardened:build fails before publishing any artifact. Both components run the same check:

$ nix eval ".#<flake-target>.passthru.labels" --raw \
    --apply 'l: l."com.gitlab.image.fips" or "false"'
# anything but "true" (including a failed eval) ⇒ refuse a mislabelled image

This asserts the label is present. It’s the last line of defence, not a substitute for actually building under the FIPS set — a build that isn’t FIPS but is wrongly labelled would still pass the label check. The truthfulness starts with you building under the FIPS package set; the assertion just stops an unlabelled-but-claimed-FIPS image from being signed.

Reference

Go FIPS (BoringCrypto) is ADR-0026, Rust FIPS (aws-lc-rs) is ADR-0028, and the crypto-library FIPS composition is ADR-0002.

The public hardened-artifact API (ADR-0039), generated from the doc-comments in lib/api.nix.

lib.api.mkHardenedImage

Derivation-first core (ADR-0039, FR-092..095): wrap an ALREADY-BUILT derivation in a hardened OCI image (via buildGitlabImage) and, by default, attach + build its CycloneDX SBOM (via lib/sbom). This is the headline “I have a derivation, give me a hardened image + SBOM” entry point, and the one mkSecureArtifact funnels into.

Inputs (attrset):

  • name (string, required) — image name, e.g. “my-service”
  • version (string, required) — image tag, e.g. “1.2.3”
  • package (derivation, required) — the built artifact to bundle
  • entrypoint / cmd (list|null) — OCI entrypoint/cmd (default null)
  • env (attrset) — extra env vars (default {})
  • extraDeps (list) / extraLabels (attrset) — extra runtime deps / labels
  • user / workDir / debug — passthrough to buildGitlabImage (debug = false ⇒ no shell in the image, FR-002)
  • fips (bool, default false) — build the image under the FIPS package set so it carries FIPS labels. TRUTHFULNESS CONTRACT (FR-095): the caller MUST have built package under the FIPS set; this flag does NOT convert a non-FIPS build into a FIPS one.
  • sbom (bool, default true) — attach + build a CycloneDX SBOM
  • sbomEcosystems (list|null) — override lib/sbom auto-detection (e.g. ruby/yarn lock paths that cannot be inferred from the derivation)

Returns the nix2container image derivation, with passthru: sbom (only when sbom = true), package, labels, fips.

Type

mkHardenedImage :: AttrSet -> Derivation

Example

lib.${system}.mkHardenedImage {
  name = "my-service";
  version = "1.0.0";
  package = self.packages.${system}.my-service;
  cmd = [ "/bin/my-service" ];
}

lib.api.mkSecureArtifact

Src-first umbrella (ADR-0039, FR-096..098): detect → build → mkHardenedImage, every stage overridable. Point it at a source tree; it detects the ecosystem (unless forced), builds a derivation with the matching emitter, and hands that to mkHardenedImage.

Inputs (attrset):

  • name, version (required)
  • src (derivation/path, required unless overrideDerivation) — the source
  • rev (string) — full commit SHA (used for Go ldflags etc.)
  • ecosystem (string|null) — force “go”|“python”|“ruby”|“rust”; null ⇒ detect from src (FR-097)
  • ecosystemArgs (attrset) — per-ecosystem knobs: go { vendorHash; goVersion?; goreleaser?; subPackages?; }, rust { cargoLock; outputHashes?; }, ruby { gemset; ruby?; gemdir?; }, python { lock; python?; overrides?; exclude?; format?; } for a ./poetry.lock, or { lock; python?; overrides?; sourcePreference?; } for a ./uv.lock
  • overrideDerivation (derivation|null) — bypass detect+build entirely and image THIS derivation (FR-097)
  • image (attrset) — extra args forwarded to mkHardenedImage
  • fips, sbom, sbomEcosystems — as in mkHardenedImage

Returns the mkHardenedImage result, with an additional passthru.ecosystem = the detected/forced IR (or { ecosystem = "custom"; } when overrideDerivation is used).

If src matches no known marker and neither ecosystem nor overrideDerivation is given, detect throws at eval time naming the markers searched and the escape hatches (FR-098) — no silent empty image.

Type

mkSecureArtifact :: AttrSet -> Derivation

Example

lib.${system}.mkSecureArtifact {
  name = "my-service";
  version = "1.0.0";
  src = ./.;
  rev = self.rev or "0000000000000000000000000000000000000000";
  ecosystemArgs = { vendorHash = "sha256-…"; };   # Go
}

lib.api.buildEcosystemDerivation

Internal dispatch from a detected/forced ecosystem to its emitter, producing the build derivation mkSecureArtifact then images. Exposed (rather than hidden in a let) so tests and docs can exercise a single ecosystem’s build-arg assembly directly.

Takes { name; version; src; rev; ecosystem; ecosystemArgs; buildPkgs; } where ecosystem is one of “go” | “rust” | “ruby” | “python” and buildPkgs is the plain or FIPS package set. Throws for an unsupported ecosystem.

Type

buildEcosystemDerivation :: AttrSet -> Derivation

Ecosystem detector (lib/ecosystem/detect.nix).

lib.detect.detect

Detect a source tree’s ecosystem by file-presence and return the normalized IR:

{
  ecosystem        = "go" | "rust" | "python" | "ruby";
  toolchainVersion = string | null;   # best-effort, from the marker
  primaryMarker    = string;          # e.g. "go.mod"
  buildConfigFile  = string | null;   # e.g. ".goreleaser.yml", relative
}

Throws (rather than returning a sentinel) when no marker is present, so a misconfigured mkSecureArtifact fails at eval time with an actionable message instead of silently producing an empty derivation.

Type

detect :: Path -> AttrSet

Example

detect ./my-go-service
# => { ecosystem = "go"; toolchainVersion = "1.22.3"; primaryMarker = "go.mod"; buildConfigFile = null; }

lib.detect.tryDetect

Like detect, but returns null instead of throwing when no ecosystem marker is present.

Use this for introspection — deciding whether a tree is buildable at all, or branching on the result — where a throw would be inconvenient. When you already know the tree has a marker, call detect directly so a missing one surfaces as an error rather than a silent null.

Type

tryDetect :: Path -> AttrSet | null

Example

tryDetect ./maybe-empty-dir   # => null
tryDetect ./my-go-service     # => { ecosystem = "go"; … }

lib.detect.markers

The marker table that drives detection, in priority order.

Each entry pairs an ecosystem with its primary marker file (the presence test) and the buildConfig files worth noting when that ecosystem wins. The first ecosystem whose primary marker exists takes precedence, so order only matters for a tree that carries several (say, a Go tool vendoring a small Ruby helper) — detection picks the top-level build, which is unambiguous for the gitlab-org projects this serves. Exposed for the docs site and tests.

Type

markers :: [ { ecosystem :: String; primary :: String; buildConfig :: [ String ]; } ]

Ruby / Bundler gemset enrichment (lib/ecosystem/ruby.nix).

lib.ruby.enrichGemset

Enrich a bundix gemset by swapping gems for their nixpkgs pre-built equivalents where that’s safe, so bundlerEnv fetches and builds fewer gems from rubygems.org.

For each gem in the gemset, this checks whether nixpkgs already ships it and, depending on matchMode, substitutes the entry’s source to point at the nixpkgs store path. The gem stays in the gemset either way, so bundlerEnv’s dependency-graph resolution is unaffected — only the fetch/build source changes.

matchMode controls how careful the substitution is:

  • "exact" (default) substitutes only when the nixpkgs version equals the gemset-pinned version. Bundler enforces lockfile versions strictly, so a mismatch (nixpkgs 7.2.3 vs a pinned 7.2.3.1) would break the build; exact matching can’t introduce that.
  • "name-only" substitutes whenever nixpkgs has a gem by that name and rewrites the entry’s version to the nixpkgs one. This reuses more pre-built closure but can pull in a different version, so reach for it when you want maximum reuse and the test suite covers the gems in play.

exclude is a list of gem names to never substitute, even when nixpkgs has them — the escape hatch for a name-only build where one gem’s nixpkgs version diverges from the lockfile in a way Bundler rejects.

Returns the enriched gemset plus introspection: gemset (the enriched map, the one to hand to bundlerEnv), enrichedGemset (the same value), the substituted nixpkgsGems, and stats (total, fromNixpkgs, fromBundlerEnv, matchMode).

Type

enrichGemset :: { gemset :: AttrSet; ruby :: Derivation; matchMode ? String; exclude ? [ String ]; } -> AttrSet

Example

enriched = rubyEcosystemLib.enrichGemset {
  inherit gemset ruby;
  matchMode = "exact";
};
gems = bundlerEnv { gemset = enriched.gemset; inherit ruby; };

CycloneDX SBOM helpers (lib/sbom).

lib.sbom.detect

Detect which language ecosystems a finished derivation vendors, from reliable build-system markers.

Reads goModules (set by buildGoModule) and cargoDeps (set by buildRustPackage / rustPlatform) on the built package and returns one entry per marker present. Both are safe to auto-detect because the emitters consume the finished output rather than rebuilding, so emitting Rust components for a package that only carries cargoDeps incidentally (nixpkgs ruby bundles YJIT crates) is correct, not a false positive. Ruby and yarn aren’t auto-detected here; their inventory lives in a gemset.nix or yarn.lock that isn’t discoverable from the built drv, so they need an explicit sbomEcosystems entry.

Type

detect :: Derivation -> [ { kind :: String; } ]

Example

detect goPackage   # => [ { kind = "go"; } ]

lib.sbom.ecosystemsFor

Resolve the ecosystem list for a package: an explicit passthru.sbomEcosystems if the package sets one, otherwise the auto-detected list from detect.

An explicit list wins outright, so a package can both add path-bearing ecosystems (ruby’s gemset, yarn’s lockfiles) and suppress a false-positive auto-detection in the same declaration.

Type

ecosystemsFor :: Derivation -> [ AttrSet ]

Example

ecosystemsFor rails
# => [ { kind = "ruby"; gemset = ./gemset.nix; } { kind = "yarn"; yarnLock = [ ./yarn.lock ]; } ]

lib.sbom.attach

Attach an aggregated vendored SBOM to a package as its bombonVendoredSbom passthru, covering every detected or declared ecosystem.

bombon’s transformer reads every *.cdx.json under that derivation’s $out, so combining ecosystems is just collecting their files into one directory (prefixed by kind so a package with more than one ecosystem doesn’t get identically-named files clobbered on copy). Meant for stdenv packages (rails, workhorse); it uses overrideAttrs to add the passthru, keeping pkg a real derivation, which matters because buildBom hands attached packages to bombon as extraPaths that go through exportReferencesGraph and must be derivations. Since the emitters consume pkg rather than rebuilding it, the package’s own output hash is unchanged. Passing a package with no ecosystems returns it untouched; passing a non-derivation throws rather than silently yielding a plain attrset that fails far from the cause.

Type

attach :: Derivation -> Derivation

Example

attach workhorse   # => workhorse with passthru.bombonVendoredSbom

lib.sbom.buildBom

Build the ecosystem-complete CycloneDX SBOM derivation for a package.

Folds every vendored ecosystem SBOM into the Nix-closure SBOM so gem, npm, Go module, and Rust crate components surface for CVE matching. For our images, the bundled app package and its runtime deps are surfaced as gitlabSbomPackages; a nix2container image references those as copied store paths rather than derivation inputs, so bombon’s dependency walk sees bare paths and can’t read their bombonVendoredSbom. This attaches each surfaced package (safe, since the emitters consume rather than rebuild) and adds the ones that gain a vendored SBOM to extraPaths, entering them into the graph as real derivations the transformer reads from. Without it an image BOM would list only the Nix closure. extraPaths, includeBuildtimeDependencies, and excludes pass straight through to bombon.

Type

buildBom :: Derivation -> { extraPaths :: [ Derivation ]; includeBuildtimeDependencies :: Bool; excludes :: [ ... ]; } -> Derivation

Example

buildBom gitlabImage { }   # => the image's CycloneDX SBOM derivation

OCI image layer construction (lib/oci-layers.nix).

lib.ociLayers.appMaxLayers

The layer budget for the one heavy application group in an image.

Set to 80, which leaves about 45 layers of headroom under the ~125-layer OCI ceiling for the base, /etc, and extra groups (one layer each) plus any future group, even across the largest closures. Only the application group uses this split; every other group stays a single layer.

Type

appMaxLayers :: Int

lib.ociLayers.foldImageLayers

Build an image’s layers from an ordered list of group specs, deduplicating shared store paths across the groups.

Each spec is an attrset passed to nix2container.buildLayer ({ deps ? []; copyToRoot ? null; maxLayers ? … }). The specs fold in order, and each built layer excludes paths already present in the layers before it, so a store path lands in exactly one layer. A spec that omits maxLayers gets maxLayers = 1 (the whole group in one layer); only a group that sets a higher maxLayers (typically the application group, via appMaxLayers) is popularity-split. That keeps the total layer count across all groups well under the OCI ceiling.

Type

foldImageLayers :: [ AttrSet ] -> [ Layer ]

Example

foldImageLayers [
  { deps = [ glibc cacert ]; }          # base, one layer
  { deps = [ app ]; maxLayers = appMaxLayers; }  # split for cache reuse
  { copyToRoot = etcFiles; }            # /etc at the image root
]

GitLab release version registry (lib/gitlab-versions.nix).

lib.gitlabVersions.releases

The latest patch of each maintained GitLab minor, keyed by MAJOR.MINOR.

Tracks the release train’s active window (N, N-1, N-2). Each key holds the full component set for that series (gitlab-rails, gitaly, workhorse, and the rest) with real harvested source and vendor hashes, so a build pinned to a minor is fully reproducible. Refresh with the gitlab-versions Rust CLI, which realigns every release to CNG and re-harvests hashes; lib/gitlab-release.nix resolves a selector against this set.

Type

releases :: AttrSet   # { "19.2" = { gitlab-rails = { … }; … }; … }

lib.gitlabVersions.shared

Components pinned once and shared across every GitLab release.

These don’t follow the GitLab release train (the auth services iam / glaz / sts, orbit-knowledge-graph, glab-cli, ai-assist, and friends), so they live outside the per-minor releases set and get merged in by the resolver. Pin values here and every release picks them up.

Type

shared :: AttrSet   # { iam = { … }; glaz = { … }; … }

lib.gitlabVersions.latest

The default GitLab minor when a caller selects no release.

A MAJOR.MINOR key into releases (the newest tracked series). The resolver falls back to this when handed null.

Type

latest :: String   # e.g. "19.2"

GitLab release resolution helpers (lib/gitlab-release.nix).

lib.gitlabRelease.toMinorKey

Normalise a user-supplied release selector to a registry key (MAJOR.MINOR).

Accepts null (resolves to the registry’s latest tracked release), a bare "19.1", a full patch like "19.1.3" (reduced to its minor series), and tolerates a leading v or trailing -ee. It only trims to the minor; the patch component isn’t validated against the pin, so "19.1.0" resolves to whatever 19.1 patch is tracked. Callers needing an exact-patch guarantee should compare against patchVersion themselves.

Type

toMinorKey :: Registry -> (String | null) -> String

Example

toMinorKey registry "v19.1.3-ee"   # => "19.1"
toMinorKey registry null           # => registry.latest

lib.gitlabRelease.releaseKeys

List every tracked release key (MAJOR.MINOR) in the registry.

Order isn’t guaranteed, so sort if you need one; the flake uses this to enumerate the per-release alias outputs.

Type

releaseKeys :: Registry -> [ String ]

Example

releaseKeys registry   # => [ "19.2" "19.1" "19.0" ]

lib.gitlabRelease.patchVersion

The exact patch version string for a resolved release, taken from that series’ gitlab-rails pin.

Used to build alias suffixes; resolves the selector through toMinorKey first, so a null or partial selector works the same way resolve does.

Type

patchVersion :: Registry -> (String | null) -> String

Example

patchVersion registry "19.1"   # => "19.1.3"

lib.gitlabRelease.resolve

Resolve a release selector to the flat component attrset every package derivation consumes (gitlabVersions.<component>).

Picks the release for the selector’s minor key, then merges the shared, release-independent components under the release-specific ones (the two sets are disjoint by construction, so it’s really a union). Throws with the list of known keys when the selector names a release that isn’t tracked.

Type

resolve :: Registry -> (String | null) -> AttrSet

Example

(resolve registry "19.1").gitaly.version   # => "19.1.3"

FIPS toolchain and mode configuration (lib/fips.nix).

lib.fips.modes

BoringSSL FIPS mode definitions, keyed by mode name.

Each mode fixes a BoringSSL branch, the exact commit and its sha256, the NIST CMVP certificate number, and whether it’s validated. legacy is the fully validated Certificate #5104; update-stream tracks a real pending CMVP submission (FedRAMP FR-R7); main is development-only and unpinned. These pins must track the certificate, never “latest”.

Type

modes :: AttrSet   # { legacy = { branch; commit; sha256; certificateNumber; validated; systems; sbomSystems; }; … }

lib.fips.toolchainVersions

The exact build-toolchain versions NIST CMVP Certificate #5104 requires.

Any deviation invalidates FIPS certification, so this is a permanent requirement rather than a floor; fipsAssertToolchainVersions enforces it at build time. Covers clang, go, cmake, and ninja.

Type

toolchainVersions :: { clang :: String; go :: String; cmake :: String; ninja :: String; }

lib.fips.integrityHashes

Expected FIPS integrity hash checksums (from bcm.o), per architecture.

amd64 holds the BORINGSSL_bcm_text_hash a validated build must reproduce; arm64 is null because ARM64 isn’t validated.

Type

integrityHashes :: { amd64 :: String; arm64 :: String | null; }

lib.fips.getArch

Map a Nix system string to the FIPS architecture label.

Returns “amd64”, “arm64”, “amd64-darwin”, “arm64-darwin”, or “unknown” for anything unrecognised. This is the label used in image tags and provenance, distinct from whether the arch is actually validated (see isFipsValidated).

Type

getArch :: String -> String

Example

getArch "aarch64-linux"   # => "arm64"

lib.fips.isFipsValidated

Whether a Nix system is FIPS 140-3 validated.

Only x86_64-linux is covered by Certificate #5104, so this is true there and false everywhere else. Use it to gate FIPS-only build paths.

Type

isFipsValidated :: String -> Bool

Example

isFipsValidated "x86_64-linux"   # => true

lib.fips.generateConstantsJson

Write the FIPS constants to a JSON file for non-Nix tools (Python, shell).

Keeps a single source of truth: it emits the toolchain versions and per-mode branch, commit, certificate number, and validated flag, omitting the Nix-specific sha256. Takes writeText explicitly so the pure config file stays free of a pkgs dependency.

Type

generateConstantsJson :: { writeText :: ... } -> Derivation

Example

generateConstantsJson { inherit (pkgs) writeText; }

lib.fips.getShortCommit

The first 12 characters of a mode’s pinned commit.

Used as the short-commit suffix in image tags, SBOM and provenance version strings, and SPDX document namespaces; centralised here so every lateral builder derives it the same way.

Type

getShortCommit :: { commit :: String; … } -> String

Example

getShortCommit fipsLib.modes.legacy   # => "85897d07196b"

lib.fips.getVersionString

The {mode}-{shortCommit} version string shared across all four lateral builders.

Used identically as the image tag, the SBOM and provenance version, and the SPDX versionInfo. Centralised so a change to the format updates every builder’s output together; otherwise one build’s version strings would stop matching across its own SBOM, provenance, and tag.

Type

getVersionString :: String -> { commit :: String; … } -> String

Example

getVersionString "legacy" fipsLib.modes.legacy   # => "legacy-85897d07196b"

lib.fips.rustFips

Rust FIPS configuration for the aws-lc-rs / AWS-LC module (ADR-0028).

Separate from the BoringSSL module the Go services use: aws-lc-rs with the fips feature builds AWS-LC from a pinned commit and branch, which carry their own NIST CMVP certificates (dynamic #5298, static #5314). Holds the pinned commit, branch, certificate numbers, module name, cargo feature flag, the build-time Go minimum, and the validated architecture scope.

Type

rustFips :: AttrSet

lib.fips.getModeConfig

Look up the configuration for a named FIPS mode.

Returns the mode’s entry from modes, or throws when the name isn’t one of legacy, update-stream, or main; throwing keeps a typo from silently selecting a non-compliant build.

Type

getModeConfig :: String -> AttrSet

Example

(getModeConfig "legacy").certificateNumber   # => "5104"

lib.fips.buildToolchain

Build the FIPS toolchain package set with every tool pinned to its certificate-required version.

Assembles Clang 17.0.6 (from the llvm-of-yore backport) plus Go 1.22.3, CMake 3.29.3, and Ninja 1.12.1 overridden to the exact sources Certificate #5104 requires, alongside the ordinary build tools. Takes llvm-of-yore and pkgs explicitly so this config file keeps no implicit pkgs dependency. Returns an attrset of the individual tools plus an allPackages list ready for environment.systemPackages.

Type

buildToolchain :: { llvm-of-yore :: AttrSet; pkgs :: AttrSet; } -> AttrSet

Example

(fipsLib.buildToolchain { inherit llvm-of-yore pkgs; }).go   # => go 1.22.3

lib.fips.getBoringSSLSource

Fetch the BoringSSL source pinned for a given FIPS mode.

Resolves the mode’s commit and sha256 from modes and fetches from google/boringssl; throws for an unknown mode or one with no pinned hash (main). The commit is the FIPS-validated source for the certificate, so it must track the certificate rather than a moving branch. Takes fetchFromGitHub explicitly to stay pkgs-free.

Type

getBoringSSLSource :: { mode :: String; fetchFromGitHub :: ...; } -> Derivation

Example

getBoringSSLSource { mode = "legacy"; inherit (pkgs) fetchFromGitHub; }

lib.fips.fipsAssertToolchainVersions

Assert that a toolchain matches the FIPS-required versions, failing the build on any mismatch.

Reads the version of each supplied clang, go, cmake, and ninja and compares against toolchainVersions. On a match it returns an empty attrset you can ignore; on a mismatch it throws with the offending tools and the expected versions, so a non-compliant toolchain fails loudly rather than producing a silently uncertified binary. Wire it into a derivation to gate the build.

Type

fipsAssertToolchainVersions :: { clang :: Derivation; go :: Derivation; cmake :: Derivation; ninja :: Derivation; } -> AttrSet

Example

fipsAssertToolchainVersions { inherit clang go cmake ninja; }   # => { } or throws

Build matrix

Provenance for every image this project’s pipeline built — its SBOM, signature/attestation status, vulnerability findings, and applied dependency overrides. Generated from the pipeline’s build-matrix results; see How it fits together and Signing & provenance.

GitLab release: 19.2.1
Image spec: linux/amd64 · OCI

Images (0)

No image build results were available when this site was generated.

VEX & vulnerability governance (0)

No VEX statements or allowlist entries for this release.

Dependency overrides (1)

PackageVersionReason
openssl—The pinned nixpkgs ships OpenSSL 3.6.2, which is Critical-vulnerable to CVE-2026-34182 (CMS AuthEnvelopedData input-validation bypass). The fix is released upstream in 3.6.3, so per AGENTS.md RULE 1 this is fixed (bumped to the 3.6.3 release tarball from source in overlays/overrides.nix), not allowlisted. FIPS artifacts use BoringSSL, not OpenSSL, and CVE-2026-34182 does not affect FIPS modules; the only OpenSSL in the FIPS closure is build-time tooling.

This page is generated at site-build time from the pipeline’s data.json; it is present only when build-matrix results were available.

How it fits together

This page is the map: the detect → emit → image pipeline, the pure-Nix / CI split, and how the whole thing composes builders that already existed in this repo instead of re-implementing them.

The pipeline

The public API (lib/api.nix, ADR-0039) is a thin composition. Nothing in it is new machinery — it wires together stages that already exist and are already tested:

src ──▶ detect ──▶ emit ──▶ mkHardenedImage ──▶ image + SBOM
        (IR)      (derivation)   (buildGitlabImage
                                  + lib/sbom)
  • detect (lib/ecosystem/detect.nix, ADR-0032) — file-presence detection. It looks for a small fixed set of marker files and returns a normalized IR ({ ecosystem; toolchainVersion; primaryMarker; buildConfigFile; }). The markers are go.mod → go, Cargo.toml → rust, pyproject.toml → python, Gemfile.lock → ruby. It is not a general project-type solver; it exists so the dispatch is uniform. If nothing matches, it throws at eval time naming the markers it searched and the escape hatches — no silent empty image.
  • emit (lib/ecosystem/{go,rust,python,ruby}.nix) — per-ecosystem emitters that assemble the arguments for the matching nixpkgs builder (buildGoModule, rustPlatform.buildRustPackage, buildPythonApplication + buildPoetryDeps, bundlerEnv + enrichGemset). They emit build args; they don’t re-implement dependency fetching.
  • mkHardenedImage — wraps the built derivation into a hardened OCI image via buildGitlabImage and attaches its CycloneDX SBOM via lib/sbom.

mkSecureArtifact is the umbrella that runs all three stages, each overridable: force the ecosystem with ecosystem, skip detection-and-build entirely with overrideDerivation, or forward image args through image. mkHardenedImage is the derivation-first core that mkSecureArtifact funnels into once it has a derivation.

Composition, not reimplementation

The API deliberately reuses existing, tested builders:

ConcernDelegated to
OCI imagelib/gitlab-container-builder.nix → pkgs.buildGitlabImage
SBOMlib/sbom (bombon-backed, ADR-0036)
Ecosystem detectionlib/ecosystem/detect.nix (ADR-0032)
Ecosystem build argslib/ecosystem/{go,rust,python,ruby}.nix

buildGitlabImage is the same builder GitLab’s own service images use — no shell in production images, non-root by default, minimal layers, standard OCI labels. Building the image under the FIPS package set is what flips its FIPS marker so FIPS labels appear (see FIPS).

The pure-Nix / CI split

Everything above is pure Nix: deterministic, IFD-free (a consumer’s nix flake check --no-build evaluates cleanly), and free of secrets. It produces the image and the SBOM and nothing more.

The rest — signing, attestation, the offline SLSA L3 build, the scan gate — cannot be a pure function: it needs this project’s signing key, its Sigstore OIDC identity, network, and offline runners. That half lives in the reusable CI components (ci/components/hardened-artifact-slsa-l3.yml, and hardened-artifact-slsa-l1.yml for the unsigned slsa-l1 tier). The slsa-l3 component builds the pure-Nix output on the offline SLSA L3 tier and then signs/attests/scans it on the networked shared runner. The security boundary between those two runner roles is authoritative (ADR-0035):

offline SLSA L3 VM              nix-attic-shared (networked)
──────────────────             ─────────────────────────────
build .#target from source ──▶ substitute closure (--max-jobs 0)
  no secrets, no network         └─ sbom · scan+gate · sign · attest
  no signing key                    key + Sigstore identity live here

A cache miss on the shared runner fails rather than rebuilding off-VM, so the signed artifact is provably the offline VM’s output. Signing never touches the offline VM by construction.

IFD-free discipline

The whole surface is import-from-derivation-free. Lockfiles and gemsets must be vendored relative paths (./Cargo.lock, ./poetry.lock, ./gemset.nix), never read out of a fetched src. That’s what keeps nix flake check --no-build evaluable for consumers, and it’s enforced by the repo’s no-ifd-lockfile lint.

Where to read more

  • The public API and its two-halves rationale: ADR-0039.
  • The auto-ecosystem derivation library (detect + emitters): ADR-0032.
  • The offline SLSA build VMs and shared Attic runner: ADR-0035.
  • The ecosystem-aware SBOM: ADR-0036.
  • Function signatures: the API reference.

Nix resources

This project is built entirely with Nix. If you are new to Nix — or find the ecosystem confusing — this page is the orientation. It also collects the external resources worth bookmarking.

Nix is not NixOS. Nix is a build tool and package manager that runs on Linux, macOS, and WSL. It builds packages reproducibly and manages dependencies without touching the rest of your system. NixOS is a Linux distribution that uses Nix as its system configuration layer. You do not need NixOS to use Nix, and you do not need NixOS to use this project.


What Nix actually is

Nix is three things layered on top of each other:

  1. A language. A purely functional, lazily evaluated expression language for describing how to build software. Every package is a function from inputs to outputs.
  2. A store. /nix/store/ — an immutable, content-addressed directory where every built output lives at a path derived from a cryptographic hash of everything that went into building it.
  3. A package manager / build system. The nix CLI that evaluates expressions, realises derivations, and manages the store.

Because every output path encodes its full dependency graph, two builds of the same derivation — on any machine — produce the same path and the same bits (given the same inputs). That is the property this project leans on for SLSA L3 attestation.


nixpkgs and NixOS

nixpkgs is the community package repository — a large Nix expression that provides tens of thousands of packages and their build recipes. When a Nix expression says nixpkgs#openssl, it is referring to a package defined in nixpkgs.

NixOS is a Linux distribution that extends nixpkgs with a module system for whole-system configuration — services, users, file systems, and so on. NixOS modules are a nixpkgs concept, not a core-Nix concept. You can use nixpkgs (and therefore all its packages and build helpers) without ever running NixOS.

This project pins a specific revision of nixpkgs via flake.lock so that every build uses the same exact package versions everywhere.


stdenv and build helpers

stdenv is nixpkgs’s standard build environment. It wraps the C toolchain, make, bash, and a set of shell hooks into a baseline that most packages build against. stdenv.mkDerivation is the lowest-level way to define a package build in nixpkgs.

On top of stdenv, nixpkgs provides language-specific build helpers:

HelperWhat it builds
buildGoModuleGo modules, vendoring go.sum
buildRustPackage / craneLibRust crates with cargo
bundlerEnvRuby gem environments
buildPythonPackagePython packages
buildNpmPackageNode / npm projects

This project uses these helpers in pkgs/ to build individual GitLab service binaries, and wraps the results in mkHardenedImage to produce OCI images.


Flakes

A flake is a directory with a flake.nix at the root. It declares:

  • inputs — other flakes or repositories this flake depends on.
  • outputs — the packages, apps, devShells, checks, and NixOS modules this flake provides.

A flake.lock file pins every input to an exact revision and content hash. This makes the whole dependency graph reproducible: anyone who checks out the same commit and runs nix build gets the same store paths.

Flakes replaced the older nix-channel / nix-env workflow. They are now the standard way to manage Nix projects.


Flakes, derivations, and source hashes: how they relate

The chain from a flake input to a built artifact involves three distinct reproducibility anchors.

Flake inputs (nixpkgs, git repos, other flakes) Flake flake.nix declares inputs and outputs flake.lock pins exact revisions + hashes Nix code (expressions and functions) Derivation (.drv) (build recipe and outputs) Derivation inputs (other derivations as build deps) Source + hash (fetched, verified by hash)

Flake inputs (nixpkgs, other flakes, plain git repos) are pinned into flake.lock — exact revisions and content hashes — for reproducibility.

flake.nix (declares inputs and outputs) and flake.lock together make up the flake: a self-contained, reproducible project unit. The flake’s outputs are Nix code: expressions and functions.

Evaluating that code produces a derivation (.drv) — the build recipe describing what inputs are needed and what output path to produce.

A derivation’s inputs split into two kinds:

  • Other derivations — build-time dependencies, chained recursively.
  • Source projects, fetched and hash-verified — via fetchurl, fetchGit, and similar. These are fixed-output derivations where the hash is the trust anchor, not the fetch method.

Hashes anchor trust at both layers: flake.lock pins the source revisions of inputs, and fixed-output derivations pin the content hash of fetched sources. Together they make the full build graph reproducible and auditable.


Learning resources

Official

  • nix.dev tutorials — the recommended starting point. Covers the Nix language, derivations, the store, and flakes with worked examples.
  • NixOS.org: Learn — the official hub for documentation, including the Nix manual, the nixpkgs manual, and NixOS options search.

Community

  • NixOS and Flakes Book — a well-structured community guide focused on the flakes workflow. Covers devShells, NixOS configuration, home-manager, and cross-compilation.

  • Nix Pills — a deep bottom-up walkthrough of how Nix works internally: the store, derivations, stdenv, and how nixpkgs is built up from first principles. Essential reading for understanding why things work the way they do, not just how to use them.

Reference

  • Nix manual — language reference, store semantics, and the nix CLI.
  • nixpkgs manual — stdenv, build helpers, and how to write a derivation for your language.
  • search.nixos.org — search every package in nixpkgs.

Glossary

Canonical vocabulary for GitLab Hardened Builds. When several words exist for one concept, this file picks one and lists the rest under Avoid.

Definitions only. Decisions belong in ADRs, requirements in the spec.

Where a term comes from SLSA, this file follows SLSA v1.2. Our own terms are mapped onto SLSA’s in the section below, because the two vocabularies overlap awkwardly and getting the mapping wrong changes what we are allowed to claim.

Project components

The project offers two halves. The hardened-artifact API is pure Nix and produces the artifact; the hardened-artifact CI component does the parts a Nix function cannot - signing, attestation, scanning, and choosing a runner tier. A consuming project normally uses both.

hardened-artifact API (lib.${system}): The pure-Nix half, at lib/api.nix. A consuming project adds this repository as a flake input and calls mkHardenedImage (derivation-first) or mkSecureArtifact (source-first) to turn a package into a hardened OCI image with a CycloneDX SBOM. Also exposes detect, the ecosystem detector. Linux only. It does not sign, attest, scan or run an L3 build - those need keys, network and runners. See ADR-0039. Avoid: “the Nix library”. This repository has several; name the one you mean.

hardened-artifact CI component: The reusable CI/CD configuration this project publishes at ci/components/hardened-artifact.yml. A consuming project adds it to its own pipeline with include: and passes it a few inputs; GitLab then merges its jobs into that pipeline, where they build the artifact, produce an SBOM, and scan it. Under slsa-l3 they also sign it. Avoid: “the component” on its own, as GitLab’s components/slsa is also relevant. Say which one.

SBOM library (lib.sbom.${system}): Ecosystem-aware CycloneDX SBOM generation, built on a fork of bombon. It itemises the vendored language dependencies that a pkg:nix/-only SBOM misses. Used by the API’s passthru.sbom and available to consumers directly. See ADR-0036.

Overlays (overlays.default, overlays.overrides): nixpkgs overlays exported so a consuming project can apply them to its own nixpkgs. default adds this project’s builders and libraries - the image builders, fipsLib, the per-ecosystem emitters and the version registry. overrides carries security overrides, which rebuild a package’s reverse dependency closure from source. A project that builds its own packages uses the package set instead, which also carries the pin and the nixpkgs config.

Package set (legacyPackages.${system}): The curated nixpkgs package set: a pinned nixpkgs with this project’s additions, overrides and nixpkgs configuration, the same set this project’s own outputs build with. A project that builds its own software builds it on this set instead of its own nixpkgs. legacyPackages is the output name nixpkgs uses for a set too large for packages; it does not mean deprecated. See ADR-0039. Avoid: “the overlaid nixpkgs”, or pkgs on its own.

Flake template (templates.default, alias templates.hardened): Scaffolding run with nix flake init -t. Produces a starter consumer flake that imports the API.

Terms that are easy to get wrong

These three are ambiguous in ordinary usage and have burned us. Use the canonical term.

Consuming project: An external GitLab project that includes the hardened-artifact component into its own pipeline to get a hardened artifact built. It is where the source and the pipeline definition live; the team behind it is SLSA’s producer. Avoid: consumer. SLSA reserves that for “a party who uses software provided by a producer”, which is the far end of the supply chain from this.

Build tier: The assurance level a build runs at, chosen by the consuming project. One of slsa-l3 or slsa-l1. A build tier is not a runner tier: one build tier can span two runner tiers, which is exactly what slsa-l3 does. Avoid: using “tier” unqualified.

Runner tier: A class of CI runner identified by a tag (or by being untagged). See the three named below. Avoid: using “tier” unqualified.

Build tiers

slsa-l3 tier: The build tier that realises the derivation on the offline SLSA L3 VM and runs everything else on the shared Attic runner. Achieves SLSA Build L3 isolation. See ADR-0035.

slsa-l1 tier: The build tier that runs every job on any untagged runner, and takes its SLSA provenance from Runner-generated provenance, and signs nothing. Achieves SLSA Build L1. Decided in ADR-0044.

Runner tiers

Offline build VM (nix-slsa-l3-amd64 / nix-slsa-l3-arm64): An ephemeral GCE VM booted per job from a pre-baked offline NixOS image, with zero network egress, no secrets and no signing key. The only place the slsa-l3 tier realises a derivation from upstream source.

Shared Attic runner (nix-attic-shared): The persistent, networked runner-manager host carrying the shared Nix store, the Attic cache, and the secrets (Sigstore OIDC, registry credentials, the Nix signing key). Everything in the slsa-l3 tier that is not a from-source build runs here. Avoid: “shared runner” on its own. It collides with GitLab.com’s shared runner fleet, which is a completely different trust posture.

Untagged runner: Whichever runner picks up an untagged job in the consuming project: a GitLab.com hosted runner, an instance runner or a project runner. The slsa-l1 tier runs all its jobs on one. Avoid: shared runner, SaaS runner.

SLSA model terms

Quoted verbatim and in full from SLSA v1.2 - terminology and tracks - each followed by what it maps to here. Definitions are reproduced complete, never abridged. Where our usage and SLSA’s could drift, the SLSA wording wins.

Tracks

Build track: SLSA: “The SLSA build track describes increasing levels of trustworthiness and completeness in a package artifact’s provenance. Provenance describes what entity built the artifact, what process they used, and what the inputs were. The lowest level only requires the provenance to exist, while higher levels provide increasing protection against tampering of the build, the provenance, or the artifact.

The primary purpose of the build track is to enable verification that the artifact was built as expected. Consumers have some way of knowing what the expected provenance should look like for a given package and then compare each package artifact’s actual provenance to those expectations. Doing so prevents several classes of supply chain threats.

Each ecosystem (for open source) or organization (for closed source) defines exactly how this is implemented, including: means of defining expectations, what provenance format is accepted, whether reproducible builds are used, how provenance is distributed, when verification happens, and what happens on failure. […]“

hardened-builds: The only track this project addresses. The slsa-l1 and slsa-l3 build tiers are Build track levels, and wherever these docs say “SLSA L1” or “SLSA L3” unqualified they mean Build L1 or Build L3.

Source track: SLSA: “The SLSA source track provides producers and consumers with increasing levels of trust in the source code they produce and consume. It describes increasing levels of trustworthiness and completeness of how a source revision was created.

The expected process for creating a new revision is determined solely by that repository’s owner (the organization) who also determines the intent of the software in the repository and administers technical controls to enforce the process.

Consumers can review attestations to verify whether a particular revision meets their standards.“

hardened-builds: Out of scope. This project addresses the Build track only, and makes no claim about how a consuming project’s source was authored, reviewed or protected.

Build Track: Roles

Producer: SLSA: “A party who creates software and provides it to others. Producers are often also consumers.”

hardened-builds: The team behind a consuming project.

Verifier: SLSA: “A party who inspect an artifact’s provenance to determine the artifact’s authenticity.”

hardened-builds: The downstream party our attestations have to convince.

Avoid: relying party. Note a consumer and a verifier are distinct SLSA roles - a consumer may verify, or delegate verifying to someone else.

Consumer: SLSA: “A party who uses software provided by a producer. The consumer may verify provenance for software they consume or delegate that responsibility to a separate verifier.”

hardened-builds: Whoever runs the hardened image downstream - not the project that includes our component.

Infrastructure provider: SLSA: “A party who provides software or services to other roles.”

hardened-builds: GitLab, or the operator of a self-managed instance, for both build tiers. Under slsa-l3 this project is one too, since it operates the runner tiers.

Build Track: Build model

Platform: SLSA: “System that allows tenants to run builds. Technically, it is the transitive closure of software and services that must be trusted to faithfully execute the build. It includes software, hardware, people, and organizations.”

hardened-builds: GitLab’s control plane and the runner fleet, the hardware they run on, and the organization operating them. Which organization that is depends on where the instance runs: GitLab on GitLab.com, the operator of a self-managed instance otherwise. Under slsa-l3 this project is part of the platform too, because it operates the runner tiers - as runner operator.

Neither the consuming project nor the contents of this repository are part of it: the CI component is Steps, the Nix libraries are Dependencies.

Avoid: builder. SLSA lists it as ambiguous: “Usually means build platform, but might be used for build environment, the user who invoked the build, or a build tool from dependencies. To avoid confusion, we always use ‘build platform’. The only exception is in the provenance, where builder is used as a more concise field name.” That exception is why builder.id below keeps the name.

Admin: SLSA: “A privileged user with administrative access to the platform, potentially allowing them to tamper with builds or the control plane.”

hardened-builds: On GitLab.com, GitLab’s own operators. On a self-managed instance, the organization running it.

Avoid: reading this as a GitLab project administrator. A project Owner or Maintainer has administrative rights over a project, not over the build platform - in SLSA terms they are a tenant, however privileged they are in GitLab’s own permission model. A SLSA admin can tamper with the control plane itself; a project Owner cannot. Avoid: treating admin and tenant as the same party because they work for the same organization. SLSA’s roles are defined by what someone controls, not by who employs them. Control plane is required to be outside the tenant’s control, not outside the producing organization’s - so a self-managed instance whose administrators are distinct from its project developers satisfies that, and the spec separately accepts that a malicious platform operator is out of scope at every level.

Tenant: SLSA: “An untrusted user that builds an artifact on the platform. The tenant defines the build steps and external parameters.”

hardened-builds: A user, not a project. “Untrusted” describes the platform’s posture toward them, not a judgement about the team.

Control plane: SLSA: “Build platform component that orchestrates each independent build execution and produces provenance. The control plane is managed by an admin and trusted to be outside the tenant’s control.”

hardened-builds: GitLab’s scheduling services, gitlab runner, gitlab-runner-helper - the GitLab components that decides what runs, sets up the container, and writes artifact metadata. It is not the job script, which is tenant territory.

Steps: SLSA: “The set of actions that comprise a build, defined by the tenant.”

hardened-builds: the .gitlab-ci.yml in the consuming project, plus the hardened-artifact CI component from this repository (assuming the consuming project imports it).

Build environment: SLSA: “The independent execution context in which the build runs, initialized by the control plane. In the case of a distributed build, this is the collection of all such machines/containers/VMs that run steps.”

hardened-builds: The runner’s job container.

External parameters: SLSA: “The set of top-level, independent inputs to the build, specified by a tenant and used by the control plane to initialize the build.”

hardened-builds: Our component inputs - flake-target and the rest. SLSA specifies that these should be recorded completely at Build L2+.

Avoid: build recipe. SLSA lists it as ambiguous: “Could mean external parameters, but may include concrete steps of how to perform a build. To avoid implementation details, we don’t define this term, but always use ‘external parameters’ which is the interface to a build platform. Similar terms are build configuration source and build definition.” So avoid build definition too - say external parameters for the inputs and Steps for the actions.

Dependencies: SLSA: “Artifacts fetched during initialization or execution of the build process, such as configuration files, source artifacts, or build tools.”

hardened-builds: Everything the consumer’s flake pins - including this project’s own Nix libraries, which the consumer fetches as a flake input.

Outputs: SLSA: “Collection of artifacts produced by the build.”

hardened-builds: The hardened image, and the SBOM derivation.

Build caches: SLSA: “An intermediate artifact storage managed by the platform that maps intermediate artifacts to their explicit inputs. A build may share build caches with any subsequent build running on the platform.”

hardened-builds: Attic, under slsa-l3. The GitLab CI cache: that slsa-l1 uses to pass its closure to later jobs is not one in SLSA’s sense - it is tenant-writable rather than platform-managed.

Provenance: SLSA: “Attestation (metadata) describing how the outputs were produced, including identification of the platform and external parameters.”

hardened-builds: Differs by build tier. Under slsa-l1 it is Runner-generated provenance, unsigned. Under slsa-l3 it is a document the hardened-artifact CI component writes and signs; a Step writes it, so it is tenant-generated. Avoid: saying “the provenance” without saying which build tier you mean.

Build Track: Isolation Requirements

Hosted (SLSA requirement, Build L2 and up): SLSA: “All build steps ran using a hosted build platform on shared or dedicated infrastructure, not on an individual’s workstation.”

hardened-builds: Met by slsa-l3, whose runner tiers are hosted infrastructure. slsa-l1 makes no claim: an Untagged runner may be a project runner on an individual’s workstation.

Isolated (SLSA requirement, Build L3 only): SLSA: “The build platform ensured that the build steps ran in an isolated environment, free of unintended external influence. In other words, any external influence on the build was specifically requested by the build itself. This MUST hold true even between builds within the same tenant project.” Full definition: https://slsa.dev/spec/v1.2/build-requirements#isolation-strength

hardened-builds: This is the property the slsa-l3 tier’s offline VM provides and the slsa-l1 tier does not.

Build Track: Provenance Requirements

Provenance Exists (SLSA requirement, Build L1 and up): SLSA: “The build process MUST generate provenance that unambiguously identifies the output package by cryptographic digest and describes how that package was produced. The format MUST be acceptable to the package ecosystem and/or consumer. It is RECOMMENDED to use the SLSA Provenance format and associated suite because it is designed to be interoperable, universal, and unambiguous when used for SLSA. […]” SLSA, Completeness: “Best effort. The provenance at L1 SHOULD contain sufficient information to catch mistakes and simulate the user experience at higher levels in the absence of tampering.” SLSA, Authenticity: “No requirements.” SLSA, Accuracy: “No requirements.” Full definition: https://slsa.dev/spec/v1.2/build-requirements#provenance-exists

hardened-builds: Met by both build tiers. Each emits a SLSA Provenance predicate naming the artifact by SHA-256 digest - the Runner-generated provenance under slsa-l1, the component’s tenant-generated document under slsa-l3.

Neither tier uses the full RECOMMENDED suite, a DSSE envelope around an in-toto Statement carrying a SLSA Provenance predicate. Both use the Statement and predicate formats. slsa-l1 leaves the Statement unsigned. slsa-l3 signs it with cosign sign-blob, which is a detached signature over the file rather than a DSSE envelope.

Provenance is Authentic (SLSA requirement, Build L2 and up): SLSA, Authenticity: “Consumers MUST be able to validate the authenticity of the provenance attestation […]” SLSA, Accuracy: “The provenance MUST be generated by the control plane (i.e. within the trust boundary identified in the provenance) and not by a tenant of the build platform (i.e. outside the trust boundary), except as noted below.” SLSA, Completeness: “SHOULD be complete […]” Full definition: https://slsa.dev/spec/v1.2/build-requirements#provenance-authentic

hardened-builds: Neither build tier meets it today. Runner-generated provenance is generated by the control plane, which the Accuracy clause requires, but under slsa-l1 it is unsigned, so consumers have nothing to validate. Under slsa-l3 the document is tenant-generated.

Provenance is Unforgeable (SLSA requirement, Build L3 only): SLSA, Accuracy: “Provenance MUST be strongly resistant to forgery by tenants.” Secret material used to authenticate it “MUST NOT be accessible to the environment running the user-defined build steps”, and “every field in the provenance MUST be generated or verified by the build platform in a trusted control plane.” SLSA, Completeness: “SHOULD be complete […]” Full definition: https://slsa.dev/spec/v1.2/build-requirements#provenance-unforgeable

hardened-builds: The Build L3 form of the above. Neither build tier meets it today. slsa-l1 signs nothing. Under slsa-l3 the signing capability is accessible inside a job, exposing it to the untrusted build steps.

Build: Provenance format

builder.id: SLSA: “URI indicating the transitive closure of the trusted build platform. This is intended to be the sole determiner of the SLSA Build level. If a build platform has multiple modes of operations that have differing security attributes or SLSA Build levels, each mode MUST have a different builder.id and SHOULD have a different signer identity. This is to minimize the risk that a less secure mode compromises a more secure one. The builder.id URI SHOULD resolve to documentation explaining:

  • The scope of what this ID represents.
  • The claimed SLSA Build level.
  • The accuracy and completeness guarantees of the fields in the provenance.
  • Any fields that are generated by the tenant-controlled build process and not verified by the trusted control plane, except for the subject.
  • The interpretation of any extension fields. “ Full definition: https://slsa.dev/spec/v1.2/build-provenance#builder

hardened-builds: Not met today, on any of the three counts.

  • Names the platform: under slsa-l3 the component emits $CI_SERVER_URL/$CI_PROJECT_PATH, which is the consuming project rather than the build platform. Under slsa-l1 GitLab Runner emits https://<host>/<project>/-/runners/<runner-id>, also project-specific and varying per runner. That second format is GitLab’s, not this project’s.
  • Differs per mode: the two build tiers are modes with differing security attributes, so they require different values. Neither tier emits a stable identifier for its mode. The SHOULD on distinct signer identities does not apply to slsa-l1, which signs nothing; slsa-l3 signs with the consuming project’s OIDC identity.
  • Resolves to documentation: neither URI does.

GitLab features

Runner-generated provenance: A JSON document GitLab Runner writes describing what a job produced, switched on with RUNNER_GENERATE_ARTIFACTS_METADATA. GitLab’s own runner process writes it rather than the job’s script, and the runner hashes the artifact files itself. It is unsigned. It is the slsa-l1 tier’s SLSA provenance. GitLab docs: https://docs.gitlab.com/ci/runners/configure_runners/#artifact-provenance-metadata (field reference) and https://docs.gitlab.com/ci/pipeline_security/slsa/ (GitLab’s own account of the SLSA level it targets).

Mechanics

Nix substituter: A server Nix downloads finished build results from instead of building them locally. Before building anything, Nix asks each configured substituter whether it already has the result; if one does, Nix fetches it. Nix accepts a downloaded result only if it is signed by a key listed in trusted-public-keys, so a substituter is only as trustworthy as the key you configure for it.

Attic: The shared Nix binary cache that carries closures from the offline build VM to the jobs that consume them. Not used by the slsa-l1 tier.