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
buildGitlabImagebuilder 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:
-
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 yournix buildruns. -
A reusable CI component you
includein 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
- Building from a source tree instead of a derivation? Pick your ecosystem: Go, Python, Ruby, Rust.
- Unsupported ecosystem or a custom build? Bring your own derivation.
- The exact signatures live in the API reference.
Integrate an external project
This is the end-to-end version of the Quickstart: a whole project’s pipeline, from the flake output your team owns to the CI include that turns it into a signed, attested, scanned artifact. It also explains which jobs run where, and why the split matters.
The two things you provide
- A flake output that produces a hardened image. You build this with
lib.${system}.mkHardenedImage(derivation-first) ormkSecureArtifact(source-first). This is pure Nix — it produces the image and SBOM and nothing else. - A CI include that runs the parts a Nix function cannot: the offline SLSA L3 build, and then signing, attestation, and the vulnerability gate on a networked runner.
The flake output
{
description = "my-service, packaged and hardened";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
hardened-builds.url = "git+https://gitlab.com/gitlab-org/software-supply-chain-security/hardened-builds?ref=main";
hardened-builds.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{ self, nixpkgs, hardened-builds }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
hb = hardened-builds.lib.${system};
myService = pkgs.buildGoModule {
pname = "my-service";
version = "1.4.0";
src = ./.;
vendorHash = "sha256-…";
};
in
{
packages.${system}.my-service-image = hb.mkHardenedImage {
name = "my-service";
version = "1.4.0";
package = myService;
cmd = [ "/bin/my-service" ];
};
};
}
The CI component references this output by name, so the output name
(my-service-image) and the flake-target input must match.
Use the git+https URL for the hardened-builds input, not a gitlab: one,
and while hardened-builds is private, set up your local credentials; the
Quickstart explains both.
The .gitlab-ci.yml include
The component comes in two files, one per build tier (ADR-0044). Include exactly one.
| File | Build tier | Use it when |
|---|---|---|
/ci/components/hardened-artifact-slsa-l1.yml | slsa-l1 | You want the hardened build, SBOM and scan gate, but don’t require SLSA L2 or L3 conformance. |
/ci/components/hardened-artifact-slsa-l3.yml | slsa-l3 | Your project has access to this project’s offline SLSA L3 runners and needs signed provenance. |
Both run the same build, SBOM and scan steps. slsa-l3 builds on the offline
VM and adds signing; slsa-l1 runs on any available GitLab runner, but does
not provide signed builds and only meets SLSA L1 build level.
Switching tiers means changing file: reference and the tier-specific inputs;
your flake and build do not change.
slsa-l1
include:
- project: "gitlab-org/software-supply-chain-security/hardened-builds"
# Pin to an immutable ref. A commit SHA is the only fully immutable
# option; a release tag is acceptable where you trust the maintainer.
# Never track a branch: whatever lands on it executes in your pipeline.
ref: main # TODO: switch this to a release tag once this project is released
file: "/ci/components/hardened-artifact-slsa-l1.yml"
inputs:
flake-target: "my-service-image"
artifact-name: "my-service"
sbom: true
scan: true
fips: false
hardened-builds-via-ci-job-token: true
artifact-expiry: "1 month"
nix-image: "nixos/nix:2.35.2"
flake-target- the flake output to build, i.e..#<flake-target>. Required. Letters, digits and_.+-only.artifact-name- the name used in output filenames. Required. Letters, digits and_.-only.sbom(defaulttrue) - build the CycloneDX SBOM from the image’spassthru.sbom. See SBOMs.scan(defaulttrue) - Grype-scan the SBOM, then gate on Critical findings. Requiressbom. See VEX & vulnerability scanning.fips(defaultfalse) - before publishing anything, assert the image carries a validated FIPS label. See FIPS.hardened-builds-via-ci-job-token(defaulttrue) - fetch your flake’shardened-buildsinput withCI_JOB_TOKEN, since hardened-builds is currently private. See Fetchinghardened-buildsin CI below.artifact-expiry(default1 month) - how long the build job’s artifacts are kept. That includes the SLSA provenance, so keep what you release, for example by attaching it to a GitLab release.nix-image(defaultnixos/nix:2.35.2) - the image providing thenixCLI. It needs Nix 2.26 or newer: older Nix refuses to fetch hardened-builds as a flake input, because itsflake.lockcontains a relative path input.
| Job | Runner | What it does |
|---|---|---|
hardened:build | untagged | Builds the image, publishes it as a job artifact, and exports its Nix closure to the CI cache. No secrets. |
hardened:sbom | untagged | Builds the SBOM, substituting from the build’s cached closure where it can. |
hardened:scan | untagged | Grype gate against the SBOM. |
The build job sets RUNNER_GENERATE_ARTIFACTS_METADATA, so GitLab Runner writes
SLSA provenance (artifacts-metadata.json) listing the build job’s artifacts,
including the image, by digest. The SBOM comes from a separate job, as in
slsa-l3, so it is not in that provenance. The provenance is unsigned: SLSA
Build L1. The artifacts exist before the scan runs,
so release only from pipelines that succeeded.
What the provenance records
artifacts-metadata.json is an in-toto Statement with a SLSA Provenance v1
predicate. See GitLab’s
field reference.
For the build job it records:
subject- each artifact file (the image JSON and the cache public key) by SHA-256 digest.externalParameters- the project (source), the job name (entryPoint), and the names of the job’s CI/CD variables. The values are always empty strings, by design, so secrets never reach the provenance.resolvedDependencies- the project and the commit that was built.internalParametersandrunDetails- the runner, executor, architecture, job ID, and start and finish times.
It does not record the component’s inputs, such as flake-target or fips, or
the hardened-builds ref your pipeline included. Recover those from the
recorded commit: your .gitlab-ci.yml holds the include: ref and inputs,
and your flake.lock pins every Nix input. That only works if the ref is
immutable (a commit SHA, or a tag you trust), and if nothing overrides the
component’s variables through project, group or pipeline CI/CD variables, whose
values are not recorded either.
In both tiers the scan installs Grype with nix profile install --inputs-from . nixpkgs#grype, so its version comes from your flake’s flake.lock. Your flake
must have an input named nixpkgs; otherwise Nix falls back to the unpinned
global registry.
Fetching hardened-builds in CI
hardened-builds is currently a private GitLab project, so CI needs a credential
to fetch your flake’s hardened-builds input. With
hardened-builds-via-ci-job-token (default true), every job in this
component, the scan included (it installs Grype with --inputs-from .), sends
the job’s CI_JOB_TOKEN to git when it fetches the hardened-builds repository,
and only that repository: other gitlab.com inputs don’t get the token. Your
flake.lock is used unchanged, so this also works with a hardened-builds
input pointed at a feature branch. A git+ssh input works too: git fetches it
over HTTPS instead. Turn the input off only if you’ve already set up your own
working credential for this input (e.g. a deploy key).
A job token can read a private project only when both of these hold, so check
them before your first pipeline run, or hardened:build fails immediately:
- Your project, or a group above it, is on hardened-builds’ CI/CD job token allowlist. Ask the hardened-builds maintainers to add it under their Settings > CI/CD > Job token permissions.
- The user the pipeline runs as can read hardened-builds. That includes the owner of a scheduled pipeline and anyone who runs a pipeline on a merge request, so a contributor without access gets a failing pipeline even when your project is on the allowlist.
See GitLab’s job token documentation.
If you add your own job that evaluates your flake (see below), it needs the
same credential. Jobs that extend .hardened-nix-store get it. Any other job
can add !reference [.hardened-builds-via-ci-job-token, script] to its
before_script: or the start of its script:.
This input goes away once hardened-builds is public.
Using the built image in your own jobs
A later job - a registry push, for example - can restore the image’s store paths
from the build instead of rebuilding them, by extending .hardened-nix-store:
publish:
extends: .hardened-nix-store
image: nixos/nix:2.35.2
script:
- nix run .#my-service-image.copyTo -- --dest-creds "$REG_USER:$REG_PASS" docker://registry.example.com/my-service:latest
.hardened-nix-store restores the closure from the pipeline’s CI cache and
trusts only store paths signed by the key hardened:build published as a job
artifact. It waits for hardened:scan, so an image that failed the gate is not
published, and runs in the .post stage by default.
- If your job sets its own
needs:, keep bothhardened:buildandhardened:scan(optional) from.hardened-nix-store. If it sets its ownbefore_script:, re-add!reference [.hardened-nix-cache, before_script]and!reference [.hardened-builds-via-ci-job-token, script](!referencedoes not followextends:). - If your pipeline defines its own
stages:, that list replaces the component’s, so it must includebuild,sbomandscan(andsignforslsa-l3). - The cache is shared between runners only when they have a distributed cache configured, as GitLab.com hosted runners do. Otherwise a job on a different runner restores nothing, and Nix builds or fetches the store paths itself.
slsa-l3
include:
- project: "gitlab-org/software-supply-chain-security/hardened-builds"
ref: main # TODO: switch this to a release tag once this project is released
file: "/ci/components/hardened-artifact-slsa-l3.yml"
inputs:
flake-target: "my-service-image"
artifact-name: "my-service"
sbom: true
sign: true
attest: true
scan: true
fips: false
slsa-build-tag: "nix-slsa-l3-amd64"
attic-shared-tag: "nix-attic-shared"
nix-image: "nixos/nix:2.35.2"
The inputs it shares with slsa-l1 behave the same. slsa-l3 has no
hardened-builds-via-ci-job-token input. Its hardened:build runs on the
offline SLSA L3 VM, which has no network and no secrets by design. Its
hardened:sbom, hardened:scan and hardened:sign evaluate your flake on
the shared runner, so while hardened-builds is private they need a credential
for the hardened-builds input, which slsa-l3 does not set up yet. In
addition:
sign(defaulttrue) - keyless Sigstore sign the artifact (and SBOM). See Signing & provenance.attest(defaulttrue) - generate and sign a SLSA v1.0 provenance statement.slsa-build-tag/attic-shared-tag- the runner tags for the offline build VM and the networked shared runner.
This is a security boundary, not just scheduling (ADR-0035):
| Job | Runner | Why |
|---|---|---|
hardened:build | offline SLSA L3 VM (slsa-build-tag) | Realises the derivation from source under isolation. No secrets, no network, no signing key. |
hardened:sbom | shared runner (attic-shared-tag) | Substitutes the closure the offline build pushed to Attic; does not rebuild. |
hardened:scan | shared runner | Grype gate against the substituted SBOM. |
hardened:sign | shared runner | Holds the Sigstore identity and network; signs the artifact + SBOM + provenance. |
The build runs offline with no secrets. Every downstream job substitutes the
closure with nix build … --max-jobs 0 — a cache miss fails rather than
rebuilding off the VM, so the signed thing is provably the thing the offline VM
built. Signing never touches the offline job: the VM has no key by construction,
so it can’t. That’s the whole point of the split — read
SLSA Level 3 builds for the isolation model.
Building from source instead
If your team would rather hand the pipeline a source tree and let
hardened-builds detect the ecosystem and build it, swap mkHardenedImage for
mkSecureArtifact. Start from the per-ecosystem guide:
Go, Python,
Ruby, Rust.
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) — thebuildGoModulevendor hash. There is no way around this; a missingvendorHashthrows 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 ofsubPackages.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 fetchedsrc. Reading a lock file out of a fetched derivation is import-from-derivation, whichnix flake check --no-buildcannot evaluate and the repo’sno-ifd-lockfilelint forbids.python(optional) — the interpreter (defaultpython312).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 touv.lock, e.g../uv.lock, with a copy of the project’spyproject.tomlnext to it (and of each workspace member’s, at the same relative path). uv2nix reads them at eval time; the project itself is built fromsrc.python(optional) — the interpreter (defaultpython312). It must satisfy the project’srequires-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 overlayfinal: 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 agemset.nix, e.g../gemset.nix. Unlike Poetry’s lock, aGemfile.lockdoes not carry content hashes, sogemset.nix(produced bybundixas an offline pre-step) is what supplies them. It must be a vendored path in your repo — never a read out of the fetchedsrc(that is import-from-derivation, forbidden by theno-ifd-lockfilelint).ruby(optional) — the interpreter (defaultruby_3_3, falling back toruby).gemdir(optional) — the directory holding theGemfile/Gemfile.lock(defaultsrc).
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 toCargo.lock, written exactly as./Cargo.lock.outputHashes(optional) —cargoLock.outputHashesfor git dependencies. Git deps have no crates.io checksum, so they need an explicit"<crate>-<version>" -> sha256entry. 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):
- The artifact —
cosign sign-blobover the built image path. - The SBOM — when
sbom = true, the CycloneDX*.cdx.json. - A SLSA v1.0 provenance statement — when
attest = true, an in-toto Statement with ahttps://slsa.dev/provenance/v1predicate 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 artifactwhen: 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:
- Build your derivation under the FIPS package set, so it composes a validated
FIPS module:
- Go: BoringCrypto (ADR-0026).
- Rust:
aws-lc-rsin FIPS mode (ADR-0028). - The base crypto libraries follow the FIPS composition (ADR-0002).
- Then pass
fips = trueso 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 bundleentrypoint/cmd(list|null) — OCI entrypoint/cmd (default null)env(attrset) — extra env vars (default {})extraDeps(list) /extraLabels(attrset) — extra runtime deps / labelsuser/workDir/debug— passthrough tobuildGitlabImage(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 builtpackageunder the FIPS set; this flag does NOT convert a non-FIPS build into a FIPS one.sbom(bool, default true) — attach + build a CycloneDX SBOMsbomEcosystems(list|null) — overridelib/sbomauto-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 unlessoverrideDerivation) — the sourcerev(string) — full commit SHA (used for Go ldflags etc.)ecosystem(string|null) — force “go”|“python”|“ruby”|“rust”; null ⇒ detect fromsrc(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.lockoverrideDerivation(derivation|null) — bypass detect+build entirely and image THIS derivation (FR-097)image(attrset) — extra args forwarded tomkHardenedImagefips,sbom,sbomEcosystems— as inmkHardenedImage
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’sversionto 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)
| Package | Version | Reason |
|---|---|---|
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 arego.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
buildGitlabImageand attaches its CycloneDX SBOM vialib/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:
| Concern | Delegated to |
|---|---|
| OCI image | lib/gitlab-container-builder.nix → pkgs.buildGitlabImage |
| SBOM | lib/sbom (bombon-backed, ADR-0036) |
| Ecosystem detection | lib/ecosystem/detect.nix (ADR-0032) |
| Ecosystem build args | lib/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:
- A language. A purely functional, lazily evaluated expression language for describing how to build software. Every package is a function from inputs to outputs.
- 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. - A package manager / build system. The
nixCLI 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:
| Helper | What it builds |
|---|---|
buildGoModule | Go modules, vendoring go.sum |
buildRustPackage / craneLib | Rust crates with cargo |
bundlerEnv | Ruby gem environments |
buildPythonPackage | Python packages |
buildNpmPackage | Node / 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, 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
nixCLI. - 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-l3the component emits$CI_SERVER_URL/$CI_PROJECT_PATH, which is the consuming project rather than the build platform. Underslsa-l1GitLab Runner emitshttps://<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-l3signs 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.