Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 323 additions & 0 deletions eeps/0044-sbom-cve-meta-attrs.md
Original file line number Diff line number Diff line change
@@ -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 `<toplevel>/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.<name>.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 <installable> [--format cyclonedx|csv] [--buildtime] [-o FILE]
```

The generation pipeline:

1. Build the installable (`nix build --json`) to realize store paths.
2. Read `<output>/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.