Metadata-Version: 2.5
Name: sbom-embedded
Version: 0.1.0
Summary: CycloneDX SBOM generator for Yocto and Buildroot build output
Project-URL: Homepage, https://github.com/RchrdWrd/sbom-embedded
Project-URL: Source, https://github.com/RchrdWrd/sbom-embedded
Author-email: Richard Ward <richard.daily.ward@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: buildroot,cra,cyclonedx,embedded,sbom,yocto
Requires-Python: >=3.11
Requires-Dist: cyclonedx-python-lib>=11.6
Requires-Dist: packageurl-python>=0.15
Requires-Dist: typer>=0.16
Description-Content-Type: text/markdown

# sbom-embedded

[![CI](https://github.com/RchrdWrd/sbom-embedded/actions/workflows/ci.yml/badge.svg)](https://github.com/RchrdWrd/sbom-embedded/actions/workflows/ci.yml)
[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://github.com/RchrdWrd/sbom-embedded/blob/main/pyproject.toml)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/RchrdWrd/sbom-embedded/blob/main/LICENSE)

Generate a CycloneDX SBOM from a Yocto or Buildroot build you have already
run, by reading the manifest files the build wrote — no rebuild, no bitbake,
under a tenth of a second.

The EU Cyber Resilience Act (2024/2847) requires device manufacturers to keep
a machine-readable component list for their products. Syft, Trivy and cdxgen
are good at containers and npm and weak at embedded Linux build systems. This
fills that gap.

## Install and run

```bash
pipx run --spec git+https://github.com/RchrdWrd/sbom-embedded sbom-embedded ./output > sbom.json
```

With `uv`:

```bash
uvx --from git+https://github.com/RchrdWrd/sbom-embedded sbom-embedded ./output > sbom.json
```

Or install it properly:

```bash
pipx install git+https://github.com/RchrdWrd/sbom-embedded
```

Python 3.11 or newer. The tool only reads files, so it runs anywhere Python
does.

## Usage

Point it at a build directory. It works out which build system produced it —
you do not have to say.

```bash
sbom-embedded ./build/tmp/deploy > sbom.json    # Yocto
sbom-embedded ./output > sbom.json              # Buildroot
```

| Option | Meaning |
| --- | --- |
| `--format` | Output format. `cyclonedx` is the only one. |
| `--image` | Which image to describe, when a Yocto deploy holds several. |
| `--name` | Name for the product. Defaults to the image name, or `buildroot`. |
| `--product-version` | Version of the product. Omitted from the SBOM if not given. |
| `--output`, `-o` | Write to a file instead of stdout. |

### Real output

Run against a Buildroot manifest in this repository:

```console
$ sbom-embedded tests/fixtures/buildroot-2023.02
{
  "components": [
    {
      "bom-ref": "pkg:generic/busybox@1.36.1",
      "licenses": [
        {
          "license": {
            "name": "GPL-2.0, bzip2-1.0.4"
          }
        }
      ],
      "name": "busybox",
      "purl": "pkg:generic/busybox@1.36.1",
      "type": "library",
      "version": "1.36.1"
    },
    ...
```

If a Yocto deploy directory holds more than one image, it stops and lists them
rather than picking one:

```console
$ sbom-embedded tests/fixtures/yocto-6.0.2
error: tests/fixtures/yocto-6.0.2 holds several images (core-image-full-cmdline,
core-image-minimal); pick one with --image
```

### On a real Buildroot build

Verified end to end on Buildroot `2026.08-rc3-28-g79fd6241e4`:

```bash
git clone https://gitlab.com/buildroot.org/buildroot.git
cd buildroot
make qemu_x86_64_defconfig
make legal-info          # downloads sources, does not compile: 10-30 min
sbom-embedded ./output -o sbom.json
```

```console
$ sbom-embedded ./output -o sbom.json
wrote 44 components to sbom.json
```

44 components in **0.09 s**; all 44 carry a purl, a version and a license; 26
distinct license expressions; the document validates against the CycloneDX 1.6
schema. That build's manifest is committed as
`tests/fixtures/buildroot-2026.08/` — see
[PROVENANCE.md](https://github.com/RchrdWrd/sbom-embedded/blob/main/tests/fixtures/PROVENANCE.md).

> **Ubuntu 25.10 and newer:** `make legal-info` refuses to start with
> *"You have an uutils 'install' version installed"*, because those releases
> ship uutils coreutils rather than GNU coreutils. You can fix it system-wide
> with `sudo update-alternatives --install /usr/bin/install install /usr/bin/gnuinstall 100`,
> or just for one build without touching the system:
>
> ```bash
> mkdir -p /tmp/gnushim && ln -sf /usr/bin/gnuinstall /tmp/gnushim/install
> PATH=/tmp/gnushim:$PATH make legal-info
> ```

## Read this before you scan the output

**A vulnerability scan of this SBOM can report zero findings while the
firmware is full of known vulnerabilities.** This is not a hypothetical.

Every component gets a `pkg:generic/<name>@<version>` purl. Neither Yocto nor
Buildroot packages exist in any ecosystem repository, so there is no better
purl type available. But Grype, Trivy and Dependency-Track do not resolve
`pkg:generic` to a vulnerability namespace — they reach the NVD through CPEs,
which this tool does not emit, because a CPE would be a guess about vendor and
product strings rather than something any manifest records.

Measured, not assumed. The SBOM from the real Buildroot build above, scanned
with Grype 0.118.0:

```console
$ grype sbom:sbom.json
No vulnerabilities found
```

The same five packages, with CPEs added by hand purely to demonstrate the
difference:

```console
NAME     INSTALLED  TYPE            VULNERABILITY   SEVERITY  EPSS         RISK
busybox  1.38.0     UnknownPackage  CVE-2026-38754  High      0.4% (33rd)  0.3
busybox  1.38.0     UnknownPackage  CVE-2026-38755  High      0.3% (27th)  0.2
busybox  1.38.0     UnknownPackage  CVE-2026-38753  High      0.2% (15th)  0.2
```

Same document, same packages, same scanner. The difference is the identifier,
not the firmware.

So: use this SBOM as a component inventory and a compliance record. **Do not
read a clean Grype run on it as evidence that the firmware is clean.** For
vulnerability matching you need a tool that maps package names to CPEs, or a
scanner configured for these package names specifically.

## How much of this is verified on real builds

Both paths have been walked from a build to an SBOM on real hardware, not
from fixtures alone.

**Buildroot.** A real `make legal-info` on Buildroot 2026.08-rc3 produced the
44-package manifest shown above, and a real `make` produced a complete output
tree of 867,355 files. That tree turned out to contain ten files named
`*.manifest` — all of them Windows application manifests inside host package
sources (host-python3, host-cmake, host-ninja, gcc). None sits at
`images/<dir>/*.manifest`, which is the only reason Yocto detection does not
fire on a Buildroot tree; there is a test pinning that.

**Yocto.** `bitbake core-image-minimal` was run to completion for qemux86-64
on the Yocto 6.0.2 release revisions, and the tool was run against the
`tmp/deploy` directory it wrote:

```console
$ sbom-embedded ./deploy -o sbom.json
wrote 39 components to sbom.json
```

39 components in 0.09 s, every one with a purl, a license and a `yocto:recipe`
property, validating against the CycloneDX 1.6 schema. That deploy tree is
committed as `tests/fixtures/yocto-6.0.2-live/`.

The same image was then rebuilt with `PACKAGE_CLASSES = "package_ipk"`. That
matters because every other fixture here comes from an rpm-backend build, and
rpm is the one backend whose version column carries no package revision. In
the ipk manifest all 38 rows do (`busybox 1.37.0-r0`), two carry `-r1`, and
`netbase all 1:6.5-r0` carries an epoch. The tool strips all of that, so the
37 purls the two images share are byte-identical: **the same firmware
described the same way regardless of how it was packaged.** Both manifests are
committed, and a test compares them.

It is the only fixture holding both manifest kinds from a single build, which
makes it the one that demonstrates why they must never be joined by package
name: the image manifest lists 37 packages, the license manifest 39, and 11 of
the 37 names have no counterpart on the other side — `libc6` against `glibc`,
`libz1` against `zlib`, `libcrypto3` against `libcrypto`, and so on.

> **Building Yocto on a current host:** the 6.0.2 release works, but the
> 5.0.9 release does not — its bitbake crashes on Python 3.14, and its
> `UNINATIVE_MAXGLIBCVERSION` is below a current glibc. Configuring
> `SSTATE_MIRRORS` against `sstate.yoctoproject.org` is what makes the build
> practical: 373 of 396 wanted objects came from the mirror, so it fit on a
> machine with 7 GB of free disk instead of needing 20-40.

## Known limitations

* **Yocto builds without a license manifest produce no licenses.** The image
  manifest has no license column. If your build kept
  `tmp/deploy/licenses/`, that is read instead and you get licenses and recipe
  names; otherwise you get names, versions and purls only. Buildroot always
  carries licenses.
* **Package names are not upstream project names.** Your firmware contains
  `libcrypto`, `openssl-conf` and `openssl-ossl-module-legacy`; a CVE database
  knows `openssl`. Where a Yocto license manifest is read, the recipe behind
  each package is recorded as a `yocto:recipe` property. Where only the image
  manifest exists, the names are the Debian-renamed forms (`libc6`, `libz1`)
  with no way back.
* **License strings are copied, not normalised.** Yocto writes
  `GPL-2.0-only & MIT` and Buildroot `GPL-2.0+ (programs), LGPL-2.1+` — neither
  is a valid SPDX expression. Valid SPDX identifiers and expressions are
  emitted as such; everything else is emitted as a named license, verbatim.
  Nothing is guessed at or dropped.
* **No supplier and no hashes.** Neither build system records a supplier or a
  per-package hash, so those fields are absent rather than invented.
* **The dependency graph is flat.** Every package hangs off the image. The
  manifests read do not carry inter-package dependencies.
* **Buildroot `SOURCE ARCHIVE`, `SOURCE SITE` and `LICENSE FILES` are not
  emitted.**
* **The product name and version are yours to supply.** A Buildroot manifest
  carries no product identity, so the root component is named `buildroot`
  unless you pass `--name`, and has no version unless you pass
  `--product-version`. Nothing is invented to fill them.

The underlying reasoning, and the manifest formats in detail, are in
[DESIGN.md](https://github.com/RchrdWrd/sbom-embedded/blob/main/DESIGN.md).

## Releasing

Publishing runs from `.github/workflows/publish.yml` through PyPI Trusted
Publishing, so there is no API token anywhere in the repository or in GitHub
secrets — PyPI verifies the workflow's identity over OpenID Connect.

One-time setup, on PyPI under *Your projects → Publishing → Add a pending
publisher*:

| Field | Value |
| --- | --- |
| PyPI project name | `sbom-embedded` |
| Owner | `RchrdWrd` |
| Repository name | `sbom-embedded` |
| Workflow name | `publish.yml` |
| Environment name | `pypi` |

Repeat it on [test.pypi.org](https://test.pypi.org) with environment
`testpypi`. Then, in the repository's *Settings → Environments*, create both
environments — add a required reviewer on `pypi` if you want a manual gate.

To release:

1. Run the **Publish** workflow manually against `testpypi` and check the
   rendered project page.
2. Bump `version` in `pyproject.toml`, update `CHANGELOG.md`, commit.
3. Tag and push: `git tag -a v0.2.0 -m "..." && git push origin v0.2.0`.

The tag push publishes to PyPI. The workflow refuses to publish if the tag and
the packaged version disagree, and it installs the built wheel and generates an
SBOM from it before either upload step runs. A PyPI release cannot be replaced,
only yanked, which is why TestPyPI comes first.

## Development

```bash
uv sync
uv run pytest
```

Without `uv`:

```bash
python3 -m venv .venv
.venv/bin/pip install -e . --group dev   # needs pip 25.1+ for --group
.venv/bin/python -m pytest
```

92 tests, a second or two. They run from fixture files under
`tests/fixtures`, never from a live build. Every fixture is unmodified output
from a real Yocto or Buildroot build —
[PROVENANCE.md](https://github.com/RchrdWrd/sbom-embedded/blob/main/tests/fixtures/PROVENANCE.md) records where each came from and
what it is kept for. The format details matter too much to mock.

## License

MIT — see [LICENSE](https://github.com/RchrdWrd/sbom-embedded/blob/main/LICENSE).
