The Ur Project

Documenting the craft of stacking things so they remain stacked. For a very long time.

pkg-sbom(1)

NAME

pkg-sbom - emit a software bill of materials for the installed set

SYNOPSIS

pkg sbom [--format=FORMAT]... [--output=PATH] [--name=NAME]

DESCRIPTION

A software bill of materials, an SBOM, is the list of everything that's in a system, written in a format someone else's tooling can read. Auditors ask for them, procurement asks for them, and vulnerability scanners eat them. This command writes one for the installed system, as SPDX 2.3 JSON, CycloneDX 1.6 JSON, or both.

Where does the list come from?

Not from looking around. The component list is the installed set, exactly as pkg-state(5) holds it, and every field in the document traces back to a manifest the build emitted and the repository signed. Nothing scans the filesystem.

That's the whole difference between an SBOM the build can stand behind and one reconstructed from a finished binary after the fact. And the document says which kind it is.

Where does it go?

To standard output, by default, because it's a report: pipe it into jq, into a scanner, or into a file.

Its sibling is pkg-compose(1) --sbom, which writes the same document next to a composed artifact, from the closure that produced it. On a build the dispatcher runs, that document is published into the image store, next to the image and its signature.

What does the document promise?

Identity
A component is named name/subpkg and carries the package URL pkg:rpm/ur/name@version-release?arch=arch&subpkg=subpkg. A scanner that matches on name@version finds it without knowing that Ur splits a build into subpackages. The subpkg qualifier is what keeps six subpackages of one build from collapsing into one component. Each is a separate artifact with its own payload.
Hash
The SHA-256 of the payload blob, the artifact's own hash. Not a digest of a file list, and not recomputed from the installed tree.
License
The declared expression, verbatim. SPDX licenseConcluded is always NOASSERTION: concluding a license is an act of review, and this tool hasn't reviewed anything. Every LicenseRef- is declared in hasExtractedLicensingInfos with NOASSERTION text, because a document that references a ref it never defines isn't valid SPDX. See pkg-license(1) for what those refs are.
Dependencies
From the installed capability graph: a component depends on another when it requires a soname, or a package, that the other provides. SPDX DEPENDS_ON, CycloneDX dependencies.
Unknowns
Stated, never left out. A field with no answer carries an explicit NOASSERTION. An absent field says nothing at all, and that difference matters to whoever has to act on the document.

Same system, same bytes

The document's identifier comes from a hash of the component set, not from a random UUID. The timestamp comes from SOURCE_DATE_EPOCH when it's set. So the same set renders the same bytes, and that's what makes a document worth signing, diffing or republishing. A signature over something that doesn't reproduce proves who wrote it, not what it says.

With SOURCE_DATE_EPOCH unset, a report on a live system uses the current time. It's describing now, after all. The sidecar next to a composed artifact pins the epoch instead, because it has to reproduce along with the artifact it ships with.

OPTIONS

--format=FORMAT

spdx, the default, and what procurement and regulators ask for. Or cyclonedx (also cdx), which is what vulnerability tooling speaks, and where VEX statements will go. Repeat it, or comma-separate it, for both. Both render from one model, so they can't disagree about what's installed.

Two formats can't share one stream, so pass --output to write both.

--output=PATH
Write to PATH instead of standard output. With one format, the path is used as given. With two, it's a base name, and each document gets its own extension added (.spdx.json, .cdx.json). The paths written are printed.
--name=NAME
What the document calls its subject. There's nothing pkg can read off a machine that isn't a guess. A hostname moves, and a machine-id isn't public. So this is yours to say. The default, installed-system, claims nothing beyond what it is.

All global options apply; see pkg(1). --json doesn't change the document. The formats are the schema. The only thing it silences is the list of written paths.

EXAMPLES

The installed system, to standard output

pkg sbom

Both formats, as a pair of files

pkg sbom --format spdx,cyclonedx --output /srv/sbom/host

  • Writes /srv/sbom/host.spdx.json and /srv/sbom/host.cdx.json

Every component and its license

pkg sbom | jq -r '.packages[] | "(.name)\t(.licenseDeclared)"'

Every package URL, for a scanner that takes a list

pkg sbom --format cyclonedx | jq -r '.components[].purl'

What's in a composed image, without booting it

pkg compose --composition desktop.toml --sbom spdx jq '.packages | length' desktop.img.spdx.json

How do two machines differ?

pkg sbom --format cyclonedx | jq -r '.components[].purl' | sort > a ssh other pkg sbom --format cyclonedx | jq -r '.components[].purl' | sort > b diff a b

Does it reproduce?

SOURCE_DATE_EPOCH=1 pkg sbom > a.json SOURCE_DATE_EPOCH=1 pkg sbom > b.json cmp a.json b.json

EXIT STATUS

0
The document was written.
1
Two formats with no --output, an unknown format, a state that couldn't be read, or a file that couldn't be written.

FILES

/var/lib/pkg/installed/
The manifests every component is built from.

NOTES

What this document does not carry yet. All of it is recorded in docs/design/sbom.md.

No pointer from each component back to its build attestation. That pointer is the thing standard SBOM formats can't express and this project can: follow it, and you can reproduce the component offline. But it lives in a stub on the repository side, not in the manifest, so it would be a fetch per component instead of something the document can just project. The payload digest is the anchor it would be reached through.

No signature of its own. The bytes reproduce, which is the prerequisite. Signing them is its own step, with its own key. A document the dispatcher publishes into the image store is covered by that build's image.json.sig, since it's hashed into image.json like every other published file. One you wrote by hand next to a local compose isn't signed by anything.

No upstream supplier. originator is NOASSERTION, because a manifest names the Ur package, not the upstream project. It's the same missing field the CVE match key needs, and it should be solved once.

No per-file detail. filesAnalyzed is false, and that's what makes the absent packageVerificationCode correct rather than missing.

SEE ALSO

pkg(1), pkg-license(1), pkg-compose(1), pkg-list(1), pkg-state(5)

docs/design/sbom.md, docs/design/composition.md