From 1883d8ca4a4eceb9e2f8a0a256dfe56460d2c2d7 Mon Sep 17 00:00:00 2001 From: Jonathan Ringer Date: Thu, 27 Aug 2026 09:29:09 -0700 Subject: [PATCH] SBOM and CVE metadata for ekapkgs --- eeps/0044-sbom-cve-meta-attrs.md | 323 +++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 eeps/0044-sbom-cve-meta-attrs.md diff --git a/eeps/0044-sbom-cve-meta-attrs.md b/eeps/0044-sbom-cve-meta-attrs.md new file mode 100644 index 0000000..c76b2c5 --- /dev/null +++ b/eeps/0044-sbom-cve-meta-attrs.md @@ -0,0 +1,323 @@ +--- +EEP: 0044 +Title: SBOM generation and package metadata for CVE correlation +Author: Jonathan Ringer +Status: Draft +Type: Standards Track +Topic: Packaging +Requires: 0043 +Created: 2026-08-27 +--- + +# Motivation + +A Software Bill of Materials (SBOM) lists every component in a software +system along with its version, license, provenance, and dependencies. +Regulatory frameworks (the EU Cyber Resilience Act, US Executive Order +14028) increasingly require SBOMs for shipped software. Vulnerability +scanners (Grype, Trivy, OSV-Scanner) consume SBOMs to match components +against CVE databases. The quality of that matching depends entirely on +the quality of the identifiers in the SBOM. + +Nix has a structural advantage here. Every nix closure is a complete, +reproducible dependency graph. The information needed for a high-fidelity +SBOM already exists at evaluation time: the full set of packages, their +versions, licenses, and dependency relationships. But no standard tooling +surfaces this information in a format that vulnerability scanners can +consume. + +The existing tool in this space, [sbomnix](https://github.com/tiiuae/sbomnix), +reconstructs package metadata after the fact by parsing store path names +and performing heuristic CPE matching against the NIST dictionary. This +approach has three problems: + +1. **Name parsing is unreliable.** A store path like + `/nix/store/abc123-python3.11-requests-2.31.0` is ambiguous. Is the + package name `python3.11-requests` or `requests`? Is the version + `2.31.0` or `3.11-requests-2.31.0`? The heuristic gets this wrong + often enough that scanners produce false positives and miss real + vulnerabilities. + +2. **CPE matching is lossy.** Without an authoritative CPE identifier, + sbomnix guesses based on package name similarity. `openssl` matches, + but `python3-cryptography` does not match the OpenSSL CPE even though + it bundles a vendored copy. The heuristic cannot express this. + +3. **Provenance is absent.** There is no way to distinguish a package + that was built from source from one that contains pre-built binaries. + There is no way to tell whether a package appears in the closure + because the user explicitly requested it, because a service module + pulled it in, or because it is a transitive dependency of glibc. + +The ekaos module system controls the full evaluation context. Every +package's `meta` attributes — including `license`, `identifiers.cpe`, +`identifiers.purl`, `sourceProvenance`, and `knownVulnerabilities` — are +available at evaluation time. Rather than reconstructing this information +heuristically from store paths, the module system can embed an +authoritative package manifest into the system closure at build time. + +This proposal introduces two things: a nix module that generates a +structured package manifest during system evaluation, and an `ekapkgs` +CLI command that consumes this manifest to produce CycloneDX 1.5 SBOMs +with accurate identifiers. + +# Design + +## Package manifest + +A new ekaos module, `system/package-manifest.nix`, generates a JSON file +listing every package in the system closure. The manifest is produced at +nix evaluation time, when all `meta.*` attributes are available, and +embedded into the system closure at `/package-manifest.json`. + +For each package the manifest records: + +| Field | Source | Purpose | +|---|---|---| +| `pname` | `meta.pname` | Authoritative package name | +| `version` | `meta.version` | Authoritative version | +| `storePath` | `toString pkg` | Store path for closure matching | +| `outputs` | `pkg.outputs` | All output paths | +| `license` | `meta.license` | SPDX license identifiers | +| `description` | `meta.description` | Short description | +| `homepage` | `meta.homepage` | Project URL | +| `cpe` | `meta.identifiers.cpe` | CPE 2.3 identifier | +| `purl` | `meta.identifiers.purl` | Package URL identifier | +| `sourceProvenance` | `meta.sourceProvenance` | Source type classification | +| `knownVulnerabilities` | `meta.knownVulnerabilities` | Known CVE identifiers | +| `changelog` | `meta.changelog` | Changelog URL | +| `mainProgram` | `meta.mainProgram` | Primary executable name | +| `role` | Computed | Why this package is present | +| `source` | Computed | Which module pulled it in | + +The `role` and `source` fields are computed by the manifest module. They +classify each package by how it entered the closure: + +- `default` — from `environment.defaultPackages` (coreutils, bash, etc.) +- `user` — from `environment.systemPackages`, not default or service +- `service` — from an enabled service module (e.g., `services.nginx`) +- `home` — from `users.users..packages` +- `boot` — kernel, systemd, initrd + +This classification lets SBOM consumers answer questions that are +otherwise impossible: "which packages are here because I asked for them" +versus "which are transitive dependencies of the boot process." + +## CPE and PURL: why they matter + +CPE (Common Platform Enumeration) and PURL (Package URL) are the two +standard identifiers that vulnerability databases and scanners use to +correlate packages with CVEs. + +**Without CPE/PURL**, a scanner sees a component named `openssl` version +`3.3.2` and attempts fuzzy matching against the NVD. This works for +well-known packages but fails for: + +- Multi-output packages where the store path name differs from the + canonical product name +- Packages with vendored dependencies (e.g., `python3-cryptography` + bundling OpenSSL) +- Packages whose nix name diverges from the upstream project name + (e.g., `xgcc` vs `gcc`) + +**With CPE/PURL**, the scanner performs exact lookup. The CPE +`cpe:2.3:a:openssl:openssl:3.3.2:*:*:*:*:*:*:*` matches precisely one +set of NVD entries. The PURL `pkg:nix/nixpkgs/openssl@3.3.2` identifies +the exact package in the exact repository. + +nixpkgs already computes `meta.identifiers.cpe` and +`meta.identifiers.purl` for packages that define `meta.identifiers.cpeParts` +or `meta.identifiers.purlParts`. The manifest module extracts these +identifiers and passes them through to the SBOM. Packages that do not +yet define identifier parts fall back to heuristic matching, same as +today — but incrementally adding `cpeParts` to high-priority packages +immediately improves SBOM accuracy for those packages. + +## CLI command + +The `ekapkgs closure sbom` command generates CycloneDX 1.5 JSON from any +nix installable: + +``` +ekapkgs closure sbom [--format cyclonedx|csv] [--buildtime] [-o FILE] +``` + +The generation pipeline: + +1. Build the installable (`nix build --json`) to realize store paths. +2. Read `/package-manifest.json` if present (ekaos systems). +3. Query `nix path-info -rS --json` for the runtime closure and + dependency graph. +4. For each store path in the closure, look up the manifest entry by + path. If found, use authoritative metadata. If not found, fall back + to parsing `pname` and `version` from the store path name. +5. Serialize to CycloneDX 1.5 JSON. + +The CycloneDX output uses standard fields where they exist: + +- `component.cpe` — first-class CycloneDX field, from manifest `cpe` +- `component.purl` — first-class CycloneDX field, from manifest `purl` +- `component.licenses[].license.id` — SPDX identifier from manifest +- `component.externalReferences` — homepage (`website`) and changelog + (`release-notes`) +- `dependencies[]` — runtime dependency graph from `nix path-info` + references + +Nix-specific metadata uses the `nix:` property namespace documented in +nixpkgs: + +- `nix:store_path` — full store path +- `nix:nar_size` — NAR archive size +- `nix:role` — role classification from manifest +- `nix:source` — module path that pulled in the package +- `nix:sourceProvenance` — source type (e.g., `fromSource`, + `binaryNativeCode`) +- `nix:mainProgram` — primary executable +- `nix:knownVulnerability` — one property per known CVE + +By default, only the runtime closure is included. The `--buildtime` flag +includes the full build closure (compilers, build tools, stdenv). The +runtime-only default avoids the noise problem where a simple C program +pulls in 150+ build-time dependencies. + +# Example usage + +```console +$ ekapkgs closure sbom nixpkgs#hello +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "urn:uuid:dd147361-fda6-47b8-a214-4a1580ba6e3f", + "metadata": { + "timestamp": "2026-08-27T15:31:43Z", + "tools": [{ "name": "ekapkgs", "version": "0.1.0" }], + "component": { + "type": "application", + "bom-ref": "zi2bj2hlavv8q743li2s9diqbcpmrf9b", + "name": "nixpkgs#hello", + "version": "" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "57iz36553175g3178pvxjij8z5rcsd4n", + "name": "glibc", + "version": "2.42-61", + "properties": [ + { "name": "nix:store_path", "value": "/nix/store/57iz...-glibc-2.42-61" }, + { "name": "nix:nar_size", "value": "35089528" } + ] + } + ], + "dependencies": [ + { + "ref": "zi2bj2hlavv8q743li2s9diqbcpmrf9b", + "dependsOn": ["57iz36553175g3178pvxjij8z5rcsd4n"] + } + ] +} +``` + +For an ekaos system closure with the embedded manifest, components gain +authoritative metadata: + +```json +{ + "type": "library", + "bom-ref": "abc123", + "name": "nginx", + "version": "1.26.2", + "cpe": "cpe:2.3:a:f5:nginx:1.26.2:*:*:*:*:*:*:*", + "purl": "pkg:nix/nixpkgs/nginx@1.26.2", + "description": "A reverse proxy and lightweight HTTP server", + "licenses": [{ "license": { "id": "BSD-2-Clause" } }], + "externalReferences": [ + { "type": "website", "url": "https://nginx.org" }, + { "type": "release-notes", "url": "https://nginx.org/en/CHANGES" } + ], + "properties": [ + { "name": "nix:store_path", "value": "/nix/store/abc...-nginx-1.26.2" }, + { "name": "nix:role", "value": "service" }, + { "name": "nix:source", "value": "services.nginx" }, + { "name": "nix:sourceProvenance", "value": "fromSource" }, + { "name": "nix:mainProgram", "value": "nginx" } + ] +} +``` + +A vulnerability scanner consuming this SBOM performs an exact CPE lookup +against the NVD rather than guessing from the package name. + +# Prior art + +**sbomnix** (github.com/tiiuae/sbomnix) generates CycloneDX and SPDX +SBOMs from nix closures. It reconstructs metadata heuristically from +store paths and uses the NIST CPE dictionary for fuzzy matching. The +approach works for simple cases but produces inaccurate results for +multi-output packages, vendored dependencies, and packages whose nix +name diverges from upstream. + +**nixpkgs CycloneDX documentation** (doc/interoperability/cyclonedx.md) +defines the `nix:` property namespace for CycloneDX components, +including `nix:store_path`, `nix:narinfo:*`, and `nix:fod:*` namespaces. +This proposal follows those conventions and adds `nix:role`, +`nix:source`, `nix:sourceProvenance`, `nix:mainProgram`, and +`nix:knownVulnerability`. + +**nixpkgs meta.identifiers** (pkgs/stdenv/generic/check-meta.nix) +already computes CPE 2.3 and PURL identifiers for packages that define +`meta.identifiers.cpeParts` or `meta.identifiers.purlParts`. This +proposal surfaces those identifiers in the SBOM rather than introducing +a new identifier system. + +# Unresolved questions + +- **SPDX output.** The initial implementation produces CycloneDX JSON + only. SPDX is the other major SBOM standard. Whether to add SPDX + output, and whether to do so via direct serialization or by converting + from the CycloneDX representation, is deferred. + +- **Coverage of `cpeParts` in nixpkgs.** The value of CPE identifiers + in the SBOM depends on how many packages define `meta.identifiers.cpeParts`. + Currently coverage is partial. A systematic effort to add CPE parts to + high-priority packages (openssl, curl, glibc, systemd, linux kernel, + etc.) would significantly improve scanner accuracy, but the scope of + that effort is outside this proposal. + +- **Manifest for non-system closures.** The manifest module produces + metadata for ekaos system closures. Arbitrary installables + (`nixpkgs#hello`) fall back to heuristic parsing. Whether to provide a + mechanism for embedding manifests in non-system closures — perhaps via + a wrapper derivation or a `passthru` attribute — is an open question. + +- **Build-time SBOM accuracy.** The `--buildtime` flag includes all + derivation output paths but cannot query `nix path-info` for paths + that have not been built. Build-time SBOMs currently lack dependency + edges and size information. Improving this requires either building + the full closure or parsing the derivation graph for reference + information. + +# Future work + +- **Vulnerability integration.** Use the CPE and PURL identifiers to + query vulnerability databases (NVD, OSV) directly from the CLI, + producing a combined SBOM+vulnerability report. + +- **SBOM diffing.** Compare SBOMs across system generations to show + which packages were added, removed, or changed version. Useful for + change review before deploying a new system configuration. + +- **Signed SBOMs.** Sign the CycloneDX document with the same + certificate infrastructure used by `ekapkgs-serve` (EEP 0043), + providing cryptographic attestation that the SBOM was produced by a + trusted build system. + +- **CI integration.** Produce SBOMs as build artifacts in CI pipelines, + with automated vulnerability scanning gated on policy (e.g., fail the + build if any component has a critical CVE without a patch). + +- **Upstream `cpeParts` coverage.** Contribute `meta.identifiers.cpeParts` + definitions for the most security-critical packages in nixpkgs to + improve CPE matching across the ecosystem, not just for ekaos.