Nix's functional purity makes deterministic artifacts a first class citizen. Shout out to the community for keeping it real. Its a primary building block we can build upon to keep our environments clean as a whistle.
For the unfortunate souls not yet using nix, we got you covered. Keep reading to the end.
Meme recap: Old security checks IDs. EnvSecOps checks IDs and the bag.
This post: Why Nix is a first-class way to define, prove, and police the bag.
The 30-Second Version
Nix gives you a cryptographic bag by construction.
Flakes/derivations are content-addressed; the resulting store path (and its closure) has a stable hash.
Policy becomes allowlisting closure roots.
“Only these store paths (and their closures) may receive tokens for this role.”
Attestation writes itself.
You can sign the closure root/narHash + flake.lock and record it in an append-only log.
Renewals are deterministic.
If the running workload can’t prove it’s built from the approved closure, no reissue.
Why Nix fits EnvSecOps
Reproducibility: Inputs are pinned (flake.lock), outputs are content-addressed.
Minimal drift surface: The /nix/store is read-only; mutation stands out.
Smaller policy: Instead of enumerating a thousand files, you allowlist a closure root (and optional successor roots).
The bag stops being “a pile of files” and becomes “this exact closure hash and nothing else.”
Attestation mapping (practical shape)
What you attest:
Closure root path: /nix/store/-app
narHash: the NAR content hash of the root
flake.lock: pinned inputs commit hashes
Successors (optional): a list of allowed replacement roots for hotfixes
Build metadata: sandbox on/off, substituters, trusted keys
What you record:
A signed statement of the above (cosign/witness)
Pointer to append-only evidence (transparency log or WORM storage)
Policy: allow the closure, not vibes
Rego vibes:
package envsecops.nix
default allow := false
allow {
input.root_path in data.nix.approved_roots
input.narHash == data.nix.roots[input.root_path].narHash
input.requested_minutes <= data.policies.max_minutes
}
# Hotfix upgrades: allow successors for a given root
allow {
some s
s := data.nix.roots[input.prev_root].successors[_]
s == input.root_path
input.narHash == data.nix.roots[input.root_path].narHash
input.requested_minutes <= data.policies.max_minutes
}
# Policy data bundle (generated by CI):
{
"nix": {
"approved_roots": ["/nix/store/abc-app"],
"roots": {
"/nix/store/abc-app": {
"narHash": "sha256-…",
"successors": ["/nix/store/def-app"]
},
"/nix/store/def-app": {
"narHash":"sha256-…",
"successors":[]
}
}
},
"policies": {"max_minutes": 15}
}
Renewal: proving the running bag
AT EACH RENEWAL:
- The workload (or a tiny helper) surfaces:
- root path label embedded at build time
- narHash (or evidence pointer)
- The broker verifies:
- root ∈ approved, narHash matches, successors allowed if used
- recent runtime signals show no mutation (e.g., no writes outside mutable dirs)
- Issue short-lived creds with tags (env_hash = narHash, policy_id) only if the checks pass.
If the root or narHash doesn’t match—or mutation is detected—deny and don’t reissue.
Migration playbook (fast & boring)
Build from Nix (for the service you’ll gate first).
Emit labels/artifacts at build time: closure root path, narHash, and flake.lock digest.
CI signs an attestation (root, narHash, lock digest) and pushes to an append-only log.
Publish a policy data bundle with approved roots/successors.
Gate one role on that bundle; require env_hash/policy_id tags in tokens.
Renew at TTL/2; deny on mismatch; measure and ratchet down TTLs as confidence grows.
Where Nix shines in this setup (reduces policy toil)
From file allowlists to root allowlists. One item per release instead of a thousand.
Successor maps tame digest churn. Hotfixes don’t flap renewals.
Lockfile diffs become change requests. CI opens the PR with the new root + narHash.
Caveats (be honest)
Purity isn’t magic: Ensure sandboxed builds; eliminate impure sources (time, network) or you’ll get “same intent, new hash” churn.
Runtime still matters: Even with a read-only store, enforce RO mounts and minimal caps; detect in-memory tricks at renewal time.
Non-Nix dependencies: If you wrap non-Nix artifacts, include them in the closure or attest separately.
What to publish (so others can follow)
The attestation predicate shape you use for Nix closures
A sample policy data bundle and the OPA rule
A screenshot of a denied renewal with a human-readable reason
Not on Nix Yet? (Cold-Start Playbook for the… ahem… “unfortunate”)
You don’t have to halt everything and rewrite the world in Nix to get check-the-bag benefits. Do this in stages and flip to Nix when ready.
Phase 0 — Pin & Prove (OCI-only)
-
Pin images by digest (
image@sha256:…) and make rootfs read-only; remove package managers from runtime images. - Generate SBOMs (Syft) in CI; sign image + SBOM with Cosign; log to Rekor.
-
Attest an “env hash” =
H(image_digest || protected_paths_merkle || policy_id). -
Policy: allowlist image digests; deny renewal on writes under
/usr, RW mounts, new caps, or disqualifying runtime events (Falco/Tetragon). - Successor map so hotfix rebuilds don’t flap renewals:
allowed_successors:
sha256:old: [sha256:new1, sha256:new2]
Phase 1 — Repro Roots (OCI with stricter provenance)
- Reproducible builds: fixed timestamps, deterministic toolchains, hermetic build steps where possible.
- Freeze inputs: lockfile pinning (npm/pip/poetry/go mod vendor, etc.).
- Attest inputs: include lockfile digests and builder metadata in the predicate.
-
Treat the image like a “proto-closure”: your policy matches a tuple
{image_digest, lockfile_digest}instead of a thousand files.
Phase 2 — Nix at the Edges (no redesign required)
Pick the least painful on-ramp; run it in parallel to your current pipeline:
-
Container build with Nix:
nix2container/dockerToolsproduce an OCI image. - Binary/tooling via Nix: ship your app as today but bring tooling (compilers, shells, deps) from Nix to cut drift.
-
Dev/CI first: generate closure roots and
narHashin CI even if prod isn’t using them yet; sign & log them to start building history.
Phase 3 — Flip the Bag (service-by-service)
- For one service, swap base to a Nix-built image.
-
Emit labels at build time: closure root path,
narHash,flake.lockdigest. - Policy data bundle now allowlists closure roots (and successors) instead of raw digests.
- Gate one role on that bundle; measure renewal SLOs and denial reasons; then expand.
When to declare “Nix complete” for a service
- All prod artifacts come from a sandboxed Nix build.
- Renewal gates match closure roots (or approved successors) exclusively.
- SBOM and lockfile evidence are attached to the same attestation as the closure.
What you still enforce (Nix or not)
- Short TTLs (renew at TTL/2; deny on stale proof).
- Runtime signals (Falco/Tetragon) for on-disk and in-memory tricks.
- Read-only rootfs, minimal caps, signed images—boring but priceless.
TL;DR
Nix turns “check the bag” into check the closure.
Attest a root, allowlist it, and refuse to mint tokens for anything else.
No attested, policy-approved closure → no token.
Top comments (0)