DEV Community

jl03
jl03

Posted on

EnvSecOps: Nix, Recognized: The Strongest Bag Wins

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

  1. Reproducibility: Inputs are pinned (flake.lock), outputs are content-addressed.

  2. Minimal drift surface: The /nix/store is read-only; mutation stands out.

  3. 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:

  1. Closure root path: /nix/store/-app

  2. narHash: the NAR content hash of the root

  3. flake.lock: pinned inputs commit hashes

  4. Successors (optional): a list of allowed replacement roots for hotfixes

  5. Build metadata: sandbox on/off, substituters, trusted keys

What you record:

  1. A signed statement of the above (cosign/witness)

  2. 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}
}
Enter fullscreen mode Exit fullscreen mode

Renewal: proving the running bag

AT EACH RENEWAL:

  1. The workload (or a tiny helper) surfaces:
    • root path label embedded at build time
    • narHash (or evidence pointer)
  2. The broker verifies:
    • root ∈ approved, narHash matches, successors allowed if used
    • recent runtime signals show no mutation (e.g., no writes outside mutable dirs)
  3. 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)

  1. Build from Nix (for the service you’ll gate first).

  2. Emit labels/artifacts at build time: closure root path, narHash, and flake.lock digest.

  3. CI signs an attestation (root, narHash, lock digest) and pushes to an append-only log.

  4. Publish a policy data bundle with approved roots/successors.

  5. Gate one role on that bundle; require env_hash/policy_id tags in tokens.

  6. 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]
Enter fullscreen mode Exit fullscreen mode

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 / dockerTools produce 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 narHash in 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.lock digest.
  • 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)