sbom-embedded 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """CycloneDX SBOM generation from Yocto and Buildroot build output."""
2
+
3
+ __version__ = "0.1.0"
sbom_embedded/cli.py ADDED
@@ -0,0 +1,153 @@
1
+ """Command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from . import __version__
13
+ from .parsers import buildroot, yocto
14
+ from .parsers.detect import BuildSystem, DetectionError, detect
15
+ from .writer import to_json
16
+
17
+ app = typer.Typer(
18
+ add_completion=False,
19
+ help="Generate a CycloneDX SBOM from Yocto or Buildroot build output.",
20
+ )
21
+
22
+
23
+ class Format(StrEnum):
24
+ CYCLONEDX = "cyclonedx"
25
+
26
+
27
+ def _version_callback(value: bool) -> None:
28
+ if value:
29
+ typer.echo(f"sbom-embedded {__version__}")
30
+ raise typer.Exit()
31
+
32
+
33
+ @app.command()
34
+ def main(
35
+ path: Annotated[
36
+ Path,
37
+ typer.Argument(
38
+ help="A Yocto deploy directory or a Buildroot output directory.",
39
+ show_default=False,
40
+ ),
41
+ ],
42
+ output_format: Annotated[
43
+ Format,
44
+ typer.Option("--format", help="Output format."),
45
+ ] = Format.CYCLONEDX,
46
+ image: Annotated[
47
+ str | None,
48
+ typer.Option(
49
+ "--image",
50
+ help="Which image to describe, when a Yocto deploy holds several.",
51
+ show_default=False,
52
+ ),
53
+ ] = None,
54
+ name: Annotated[
55
+ str | None,
56
+ typer.Option(
57
+ "--name",
58
+ help=(
59
+ "Name for the product being described. Defaults to the "
60
+ "manifest label (core-image-minimal-qemux86-64, or "
61
+ "'buildroot')."
62
+ ),
63
+ show_default=False,
64
+ ),
65
+ ] = None,
66
+ product_version: Annotated[
67
+ str | None,
68
+ typer.Option(
69
+ "--product-version",
70
+ help=(
71
+ "Version of the product being described. Left out of the SBOM "
72
+ "when not given -- no manifest records it."
73
+ ),
74
+ show_default=False,
75
+ ),
76
+ ] = None,
77
+ output: Annotated[
78
+ Path | None,
79
+ typer.Option(
80
+ "--output",
81
+ "-o",
82
+ help="Write here instead of stdout.",
83
+ show_default=False,
84
+ ),
85
+ ] = None,
86
+ _version: Annotated[
87
+ bool,
88
+ typer.Option(
89
+ "--version",
90
+ callback=_version_callback,
91
+ is_eager=True,
92
+ help="Show the version and exit.",
93
+ ),
94
+ ] = False,
95
+ ) -> None:
96
+ """Read an existing build's manifests and write a CycloneDX SBOM."""
97
+ try:
98
+ found = detect(path)
99
+ if found.system is BuildSystem.YOCTO:
100
+ components, label = yocto.parse(found.root, image=image)
101
+ elif found.system is BuildSystem.BUILDROOT:
102
+ if image is not None:
103
+ raise typer.BadParameter(
104
+ "--image applies to Yocto builds; a Buildroot manifest "
105
+ "describes a single target."
106
+ )
107
+ components, label = buildroot.parse(found.root)
108
+ else: # a BuildSystem member was added without a parser
109
+ raise DetectionError(f"no parser for {found.system}")
110
+ except (
111
+ DetectionError,
112
+ yocto.YoctoParseError,
113
+ buildroot.BuildrootParseError,
114
+ ) as err:
115
+ typer.secho(f"error: {err}", fg=typer.colors.RED, err=True)
116
+ raise typer.Exit(code=1) from err
117
+
118
+ if not components:
119
+ # A valid, empty SBOM is the worst possible compliance artifact: it
120
+ # looks like a clean result. Still emit it -- the caller asked -- but
121
+ # never let it pass without a word.
122
+ typer.secho(
123
+ f"warning: no packages found in {path}; the SBOM will be empty",
124
+ fg=typer.colors.YELLOW,
125
+ err=True,
126
+ )
127
+
128
+ # product_version stays None unless the user supplies it. No manifest
129
+ # carries a product version, so anything else would be invented.
130
+ document = to_json(
131
+ components,
132
+ product_name=name or label,
133
+ product_version=product_version,
134
+ )
135
+
136
+ if output is None:
137
+ # Deliberately not typer.echo: the SBOM is data on stdout, and the
138
+ # documented usage pipes it into a file. The trailing newline makes
139
+ # the redirected file a well-formed text file.
140
+ sys.stdout.write(document + "\n")
141
+ else:
142
+ try:
143
+ output.write_text(document + "\n", encoding="utf-8")
144
+ except OSError as err:
145
+ typer.secho(
146
+ f"error: cannot write {output}: {err}", fg=typer.colors.RED, err=True
147
+ )
148
+ raise typer.Exit(code=1) from err
149
+ typer.secho(
150
+ f"wrote {len(components)} components to {output}",
151
+ fg=typer.colors.GREEN,
152
+ err=True,
153
+ )
@@ -0,0 +1,63 @@
1
+ """The one component model every parser returns.
2
+
3
+ Parsers know about manifest formats; the CycloneDX writer knows about
4
+ CycloneDX. `Component` is the only thing they share, which is why adding a
5
+ build system means adding a parser and nothing else.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from packageurl import PackageURL
13
+
14
+ # Neither Yocto nor Buildroot has a registered purl type, and their packages
15
+ # are not published to any ecosystem repository. "generic" with a name and
16
+ # version is what CVE matchers (Grype, Trivy, OSV) can actually consume, and a
17
+ # component without a purl is invisible to all of them -- so every Component
18
+ # gets one, derived if the parser did not supply a better-qualified string.
19
+ PURL_TYPE = "generic"
20
+
21
+
22
+ def make_purl(
23
+ name: str,
24
+ version: str | None = None,
25
+ *,
26
+ qualifiers: dict[str, str | None] | None = None,
27
+ ) -> str:
28
+ """Build a purl string, dropping qualifiers the manifest did not fill in."""
29
+ kept = {k: v for k, v in (qualifiers or {}).items() if v}
30
+ return PackageURL(
31
+ type=PURL_TYPE,
32
+ name=name,
33
+ version=version or None,
34
+ qualifiers=kept or None,
35
+ ).to_string()
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class Component:
40
+ """One package installed in the image.
41
+
42
+ Only `name` is guaranteed by every manifest format we read. A Yocto image
43
+ manifest, for instance, carries no license and no supplier at all -- those
44
+ stay None rather than being invented.
45
+ """
46
+
47
+ name: str
48
+ version: str | None = None
49
+ supplier: str | None = None
50
+ license: str | None = None
51
+ hash: str | None = None
52
+ purl: str | None = None
53
+ # Build-system-specific provenance that has no field of its own, keyed by
54
+ # a namespaced name. A Yocto package records the recipe that produced it:
55
+ # "libcrypto" and "openssl-conf" both come from openssl, and openssl is
56
+ # the name a CVE feed knows. Emitted as CycloneDX properties.
57
+ properties: dict[str, str] = field(default_factory=dict)
58
+
59
+ def __post_init__(self) -> None:
60
+ if not self.name.strip():
61
+ raise ValueError("component name must not be empty")
62
+ if self.purl is None:
63
+ self.purl = make_purl(self.name, self.version)
File without changes
@@ -0,0 +1,125 @@
1
+ """Buildroot build output.
2
+
3
+ Reads `output/legal-info/manifest.csv`, the CSV `make legal-info` writes. Only
4
+ the target manifest is read: `host-manifest.csv` lists build-time tools such
5
+ as ccache and pkgconf, which are not part of the shipped firmware.
6
+
7
+ Columns are looked up by header name rather than by position, because the
8
+ column set has changed over the years -- `DEPENDENCIES WITH LICENSES` was
9
+ added in 2018.11, so a manifest from 2017 has six columns and one from 2023
10
+ has seven. Only PACKAGE, VERSION and LICENSE are read, and all three are
11
+ required; anything else in the header is ignored, which is what lets both
12
+ shapes parse.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import csv
18
+ from pathlib import Path
19
+
20
+ from ..models import Component, make_purl
21
+
22
+
23
+ class BuildrootParseError(Exception):
24
+ """The directory does not hold a Buildroot legal-info manifest."""
25
+
26
+
27
+ MANIFEST_NAME = "manifest.csv"
28
+
29
+ # Header spellings seen across releases, newest first.
30
+ _PACKAGE = ("PACKAGE",)
31
+ _VERSION = ("VERSION",)
32
+ _LICENSE = ("LICENSE",)
33
+
34
+ # Buildroot writes these literals when it has nothing real to record. Carrying
35
+ # them through would put a package named "unknown" at version "custom" into an
36
+ # SBOM, which reads as data rather than as the absence of data.
37
+ _NO_LICENSE = {"", "unknown"}
38
+ _NO_VERSION = {"", "custom"}
39
+
40
+
41
+ def find_manifest(root: Path) -> Path:
42
+ """Locate manifest.csv from whatever the user pointed us at.
43
+
44
+ Accepts the legal-info directory, the output directory above it, or the
45
+ file itself, because all three are things a person reasonably types.
46
+ """
47
+ if root.is_file():
48
+ return root
49
+ for candidate in (root / MANIFEST_NAME, root / "legal-info" / MANIFEST_NAME):
50
+ if candidate.is_file():
51
+ return candidate
52
+ raise BuildrootParseError(f"no {MANIFEST_NAME} found in or under {root}")
53
+
54
+
55
+ def _column(header: list[str], names: tuple[str, ...], path: Path) -> int:
56
+ for name in names:
57
+ if name in header:
58
+ return header.index(name)
59
+ raise BuildrootParseError(f"{path}: no {names[0]} column in header {header!r}")
60
+
61
+
62
+ def parse_manifest_csv(path: Path) -> list[Component]:
63
+ """Read a legal-info manifest into components."""
64
+ # newline="" is required by the csv module and matters here: real
65
+ # manifests in the wild carry CRLF endings. utf-8-sig drops a BOM if an
66
+ # editor introduced one.
67
+ try:
68
+ with path.open(newline="", encoding="utf-8-sig") as handle:
69
+ rows = list(csv.reader(handle))
70
+ except (OSError, UnicodeDecodeError, csv.Error) as err:
71
+ # A non-UTF-8 byte, an unreadable file, or a field over the csv
72
+ # module's limit would otherwise escape as a traceback.
73
+ raise BuildrootParseError(f"{path}: cannot read: {err}") from err
74
+
75
+ if not rows:
76
+ raise BuildrootParseError(f"{path}: file is empty")
77
+
78
+ header = [cell.strip() for cell in rows[0]]
79
+ package_at = _column(header, _PACKAGE, path)
80
+ version_at = _column(header, _VERSION, path)
81
+ license_at = _column(header, _LICENSE, path)
82
+
83
+ components: list[Component] = []
84
+ for lineno, row in enumerate(rows[1:], 2):
85
+ if not any(cell.strip() for cell in row):
86
+ continue
87
+ # Only the three columns actually read have to be present. A row with
88
+ # fewer trailing columns than the header is common in older manifests
89
+ # and harmless; one too short to reach LICENSE is not.
90
+ needed = max(package_at, version_at, license_at) + 1
91
+ if len(row) < needed:
92
+ raise BuildrootParseError(
93
+ f"{path}:{lineno}: need at least {needed} columns to reach "
94
+ f"PACKAGE, VERSION and LICENSE, got {len(row)}"
95
+ )
96
+ name = row[package_at].strip()
97
+ if not name:
98
+ raise BuildrootParseError(f"{path}:{lineno}: empty package name")
99
+
100
+ raw_version = row[version_at].strip()
101
+ version = None if raw_version in _NO_VERSION else raw_version
102
+
103
+ raw_license = row[license_at].strip()
104
+ license_ = None if raw_license in _NO_LICENSE else raw_license
105
+
106
+ components.append(
107
+ Component(
108
+ name=name,
109
+ version=version,
110
+ license=license_,
111
+ purl=make_purl(name, version),
112
+ )
113
+ )
114
+ return components
115
+
116
+
117
+ def parse(root: Path) -> tuple[list[Component], str]:
118
+ """Parse a Buildroot legal-info directory.
119
+
120
+ Returns the components and a label for the SBOM's root component.
121
+ Buildroot has no image name of its own, so the caller is expected to
122
+ override the label when the product has a real name.
123
+ """
124
+ manifest = find_manifest(root)
125
+ return parse_manifest_csv(manifest), "buildroot"
@@ -0,0 +1,109 @@
1
+ """Work out which build system produced a directory.
2
+
3
+ The user should not have to know, and more to the point should not have to
4
+ remember, whether a given directory is a Yocto deploy tree or a Buildroot
5
+ output tree.
6
+
7
+ Both are recognised by the presence of an artifact a parser would go on to
8
+ read, which rules out the obvious false positive: an empty `images/` directory
9
+ is not a Yocto build. It is an existence check, not a parse, so it does not
10
+ promise the parser will then succeed -- a deploy holding only timestamped
11
+ manifests, a zero-byte manifest.csv, or several images and no `--image` all
12
+ detect and then fail in the parser.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from enum import StrEnum
19
+ from pathlib import Path
20
+
21
+
22
+ class DetectionError(Exception):
23
+ """Nothing recognisable was found."""
24
+
25
+
26
+ class BuildSystem(StrEnum):
27
+ YOCTO = "yocto"
28
+ BUILDROOT = "buildroot"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Detected:
33
+ """What was found, and the directory the matching parser should be given."""
34
+
35
+ system: BuildSystem
36
+ root: Path
37
+
38
+
39
+ # Where each build system's evidence sits, relative to what the user typed.
40
+ # The nested entries let someone point at the top of a build tree instead of
41
+ # hunting for the exact subdirectory.
42
+ _YOCTO_PROBES = (
43
+ Path(),
44
+ Path("tmp/deploy"),
45
+ Path("build/tmp/deploy"),
46
+ )
47
+ _BUILDROOT_PROBES = (
48
+ Path(),
49
+ Path("legal-info"),
50
+ Path("output/legal-info"),
51
+ )
52
+
53
+ # A deploy directory is recognised by an artifact a parser can actually read.
54
+ # Either alone is enough: a build that kept its licenses but not its images is
55
+ # still readable, and so is the reverse.
56
+ _YOCTO_EVIDENCE = (
57
+ "images/*/*.manifest",
58
+ "licenses/*/license.manifest",
59
+ "licenses/*/*/license.manifest",
60
+ )
61
+
62
+
63
+ def _yocto_root(start: Path) -> Path | None:
64
+ for probe in _YOCTO_PROBES:
65
+ deploy = start / probe
66
+ if not deploy.is_dir():
67
+ continue
68
+ if any(next(deploy.glob(pattern), None) for pattern in _YOCTO_EVIDENCE):
69
+ return deploy
70
+ return None
71
+
72
+
73
+ def _buildroot_root(start: Path) -> Path | None:
74
+ for probe in _BUILDROOT_PROBES:
75
+ directory = start / probe
76
+ if (directory / "manifest.csv").is_file():
77
+ return directory
78
+ return None
79
+
80
+
81
+ def detect(root: Path) -> Detected:
82
+ """Decide which build system produced `root`.
83
+
84
+ Raises if nothing is found, or if both are -- guessing between two real
85
+ build trees would silently drop half the components.
86
+ """
87
+ if not root.exists():
88
+ raise DetectionError(f"{root} does not exist")
89
+ if not root.is_dir():
90
+ raise DetectionError(f"{root} is not a directory")
91
+
92
+ yocto = _yocto_root(root)
93
+ buildroot = _buildroot_root(root)
94
+
95
+ if yocto and buildroot:
96
+ raise DetectionError(
97
+ f"{root} looks like both a Yocto deploy directory ({yocto}) and a "
98
+ f"Buildroot output directory ({buildroot}); point at one of them"
99
+ )
100
+ if yocto:
101
+ return Detected(BuildSystem.YOCTO, yocto)
102
+ if buildroot:
103
+ return Detected(BuildSystem.BUILDROOT, buildroot)
104
+
105
+ raise DetectionError(
106
+ f"{root} is neither a Yocto deploy directory nor a Buildroot output "
107
+ f"directory: looked for images/*/*.manifest, licenses/*/license.manifest "
108
+ f"and legal-info/manifest.csv"
109
+ )
@@ -0,0 +1,347 @@
1
+ """Yocto / OpenEmbedded build output.
2
+
3
+ Reads what a finished build already wrote to `tmp/deploy`. Nothing here runs
4
+ bitbake, and nothing here needs the build tree to still exist.
5
+
6
+ The image manifest is written by `format_pkg_list(..., "ver")` in
7
+ oe/utils.py as `"%s %s %s" % (pkg, arch, ver)` -- one space, no quoting, no
8
+ header. That has been stable across every release and every package backend.
9
+ What is *in* those columns is not, which is what the parsing below is about.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from enum import StrEnum
17
+ from pathlib import Path
18
+
19
+ from ..models import Component, make_purl
20
+
21
+
22
+ class YoctoParseError(Exception):
23
+ """The deploy directory does not hold what we need to read."""
24
+
25
+
26
+ def _excerpt(text: str, limit: int = 120) -> str:
27
+ """Keep an error message the size of an error message.
28
+
29
+ These messages interpolate raw input, and a manifest is allowed to be
30
+ enormous: a single 100 MB line otherwise produces 100 MB of stderr. The
31
+ line number already says where to look.
32
+ """
33
+ if len(text) <= limit:
34
+ return repr(text)
35
+ return f"{text[:limit]!r}... ({len(text)} chars)"
36
+
37
+
38
+ # The rpm backend reports a bare PKGV; deb and ipk both report the control
39
+ # file's Version field, which is "[PKGE:]PKGV-PKGR" -- so the same image built
40
+ # with PACKAGE_CLASSES=package_ipk yields "1.36.1-r0" where rpm yields
41
+ # "1.36.1". PKGR is Yocto's packaging revision and PKGE its epoch; neither is
42
+ # part of the upstream version a CVE feed knows. Stripping them is what makes
43
+ # the SBOM identical regardless of which backend produced the image.
44
+ _EPOCH = re.compile(r"^(?P<epoch>\d+):(?P<rest>.*)$")
45
+ _REVISION = re.compile(r"-(?P<revision>r\d+)$")
46
+
47
+
48
+ def normalize_version(raw: str) -> str:
49
+ """Reduce a package-manager version string to the upstream version.
50
+
51
+ The `+git0+<sha>` suffix OE appends to git recipes is deliberately kept:
52
+ it is part of PKGV and identifies the actual source revision.
53
+ """
54
+ version = raw
55
+ if match := _EPOCH.match(version):
56
+ version = match["rest"]
57
+ if match := _REVISION.search(version):
58
+ version = version[: match.start()]
59
+ return version
60
+
61
+
62
+ class ManifestKind(StrEnum):
63
+ """Which artifact a manifest file is, in order of usefulness.
64
+
65
+ LICENSE wins wherever both describe the same image: it is a complete
66
+ inventory of the installed packages *and* carries licenses and recipe
67
+ names, which the image manifest does not.
68
+ """
69
+
70
+ LICENSE = "license"
71
+ IMAGE = "image"
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class ManifestFile:
76
+ """A manifest found in a deploy directory, and what it describes."""
77
+
78
+ path: Path
79
+ kind: ManifestKind
80
+ # "core-image-minimal-qemux86-64" -- the SBOM's root component name.
81
+ label: str
82
+ # The image alone, when the machine could be split off the label. Image
83
+ # manifests always can (the machine is their parent directory); license
84
+ # manifests only can when an image manifest revealed the machine name.
85
+ image: str | None = None
86
+
87
+ def matches(self, selector: str) -> bool:
88
+ return selector == self.label or selector == self.image
89
+
90
+ @property
91
+ def choice(self) -> str:
92
+ return self.image or self.label
93
+
94
+
95
+ # Yocto appends a build timestamp to the license directory and writes a
96
+ # symlink without it: "<image>-<machine>.rootfs-20250610090225" beside
97
+ # "<image>-<machine>.rootfs". Releases before 4.3 omit the ".rootfs" infix.
98
+ _TIMESTAMPED = re.compile(r"-\d{14}$")
99
+
100
+
101
+ def _license_label(directory: Path) -> str:
102
+ return _TIMESTAMPED.sub("", directory.name).removesuffix(".rootfs")
103
+
104
+
105
+ def find_license_manifests(deploy: Path, machines: set[str]) -> list[ManifestFile]:
106
+ """Locate every `license.manifest` under a deploy directory.
107
+
108
+ The arch level in `licenses/<arch>/<image>-<machine>/` was added in Yocto
109
+ 4.3, so both depths are searched. `image_license.manifest` is deliberately
110
+ not picked up: it describes deployed artifacts, not installed packages.
111
+ """
112
+ found: dict[str, ManifestFile] = {}
113
+ licenses = deploy / "licenses"
114
+ for pattern in ("*/license.manifest", "*/*/license.manifest"):
115
+ for path in sorted(licenses.glob(pattern)):
116
+ label = _license_label(path.parent)
117
+ if label in found:
118
+ # The timestamped directory and its symlink hold the same file.
119
+ continue
120
+ image = next(
121
+ (
122
+ label.removesuffix(f"-{m}")
123
+ for m in machines
124
+ if label.endswith(f"-{m}")
125
+ ),
126
+ None,
127
+ )
128
+ found[label] = ManifestFile(
129
+ path=path, kind=ManifestKind.LICENSE, label=label, image=image
130
+ )
131
+ return list(found.values())
132
+
133
+
134
+ def find_image_manifests(deploy: Path) -> list[ManifestFile]:
135
+ """Locate every image manifest under a `tmp/deploy` directory.
136
+
137
+ Yocto writes a timestamped manifest plus a stable symlink to it. Only the
138
+ stable name is returned, so an image is never counted twice. Releases
139
+ before 4.3 omit the `.rootfs` infix, which is why it is stripped
140
+ optionally rather than required.
141
+ """
142
+ found: list[ManifestFile] = []
143
+ for path in sorted((deploy / "images").glob("*/*.manifest")):
144
+ machine = path.parent.name
145
+ # "core-image-minimal-qemux86-64.rootfs.manifest" -> "core-image-minimal"
146
+ stem = path.name.removesuffix(".manifest").removesuffix(".rootfs")
147
+ if not stem.endswith(f"-{machine}"):
148
+ # A timestamped copy (or a -dbg/SDK manifest). The symlink beside
149
+ # it carries the same content under the stable name.
150
+ continue
151
+ image = stem.removesuffix(f"-{machine}")
152
+ found.append(
153
+ ManifestFile(path=path, kind=ManifestKind.IMAGE, label=stem, image=image)
154
+ )
155
+ return found
156
+
157
+
158
+ def find_manifests(deploy: Path) -> list[ManifestFile]:
159
+ """Find every manifest in a deploy directory, best source per image first."""
160
+ images = find_image_manifests(deploy)
161
+ machines = {m.label.removeprefix(f"{m.image}-") for m in images if m.image}
162
+ licenses = find_license_manifests(deploy, machines)
163
+
164
+ by_label: dict[str, ManifestFile] = {m.label: m for m in images}
165
+ for manifest in licenses:
166
+ by_label[manifest.label] = manifest
167
+ return sorted(by_label.values(), key=lambda m: m.label)
168
+
169
+
170
+ def _read(path: Path) -> str:
171
+ """Read a manifest, turning read failures into our own error type.
172
+
173
+ Without this, a non-UTF-8 byte, an unreadable file or a dangling symlink
174
+ escapes as a traceback instead of the documented `error: ...` line.
175
+ """
176
+ try:
177
+ return path.read_text(encoding="utf-8")
178
+ except (OSError, UnicodeDecodeError) as err:
179
+ raise YoctoParseError(f"{path}: cannot read: {err}") from err
180
+
181
+
182
+ def parse_image_manifest(path: Path) -> list[Component]:
183
+ """Read a three-column image manifest: `name arch version`."""
184
+ components: list[Component] = []
185
+ text = _read(path)
186
+ for lineno, line in enumerate(text.splitlines(), 1):
187
+ if not line.strip():
188
+ continue
189
+ # Split on the single separator space rather than on runs of
190
+ # whitespace: opkg_query initialises both arch and version to "", so a
191
+ # package missing either field produces "name version" or "name arch "
192
+ # -- three fields, one of them empty. str.split() would silently see
193
+ # two fields and this would look like a malformed line.
194
+ fields = line.split(" ", 2)
195
+ if len(fields) != 3:
196
+ raise YoctoParseError(
197
+ f"{path}:{lineno}: expected 'name arch version', got {_excerpt(line)}"
198
+ )
199
+ name, _arch, raw_version = fields
200
+ # Not `if not name`: a name of only tabs or non-breaking space is
201
+ # truthy, and packageurl then fails with an opaque TypeError naming
202
+ # neither the file nor the line.
203
+ if not name.strip():
204
+ raise YoctoParseError(f"{path}:{lineno}: empty package name")
205
+ # maxsplit=2 puts everything after the second space in the version.
206
+ # No package version contains a space, so a fourth field means this is
207
+ # not the file we think it is -- and folding it into the version would
208
+ # produce a corrupt purl rather than an error.
209
+ if " " in raw_version.strip():
210
+ raise YoctoParseError(
211
+ f"{path}:{lineno}: version {raw_version.strip()!r} contains a "
212
+ f"space; expected exactly three fields"
213
+ )
214
+ # The architecture is deliberately not carried into the purl: rpm
215
+ # spells it core2_64, deb spells it core2-64, and purl-spec removed
216
+ # `arch` from the yocto type outright. It also adds nothing to
217
+ # identity, since a package name appears at most once per image.
218
+ version = normalize_version(raw_version.strip()) or None
219
+ components.append(
220
+ Component(name=name, version=version, purl=make_purl(name, version))
221
+ )
222
+ return components
223
+
224
+
225
+ def parse_license_manifest(path: Path) -> list[Component]:
226
+ """Read a license.manifest into components.
227
+
228
+ Blocks of `KEY: value` lines separated by one blank line, written by
229
+ `write_license_files()` in license_image.bbclass:
230
+
231
+ PACKAGE NAME: libcrypto
232
+ PACKAGE VERSION: 3.3.2
233
+ RECIPE NAME: openssl
234
+ LICENSE: Apache-2.0
235
+
236
+ The recipe name is kept as a property because it is what a CVE feed
237
+ knows: this image installs `libcrypto`, `openssl-conf` and
238
+ `openssl-ossl-module-legacy`, and all three are openssl.
239
+ """
240
+ components: list[Component] = []
241
+ text = _read(path)
242
+
243
+ # Blocks are separated by a blank line. Splitting on the literal "\n\n"
244
+ # would treat a separator line holding a stray space or tab as ordinary
245
+ # content, silently merging two packages into one record -- and because a
246
+ # block is a dict, the second package's values would quietly win while the
247
+ # first disappeared from the SBOM with a zero exit code.
248
+ blocks: list[list[str]] = []
249
+ current: list[str] = []
250
+ for line in text.splitlines():
251
+ if line.strip():
252
+ current.append(line)
253
+ elif current:
254
+ blocks.append(current)
255
+ current = []
256
+ if current:
257
+ blocks.append(current)
258
+
259
+ for lines in blocks:
260
+ fields: dict[str, str] = {}
261
+ for line in lines:
262
+ key, separator, value = line.partition(":")
263
+ if not separator:
264
+ raise YoctoParseError(
265
+ f"{path}: expected 'KEY: value', got {_excerpt(line)}"
266
+ )
267
+ key = key.strip()
268
+ if key in fields:
269
+ # Two blocks run together with no separator at all. Last-wins
270
+ # would drop a package without a word.
271
+ raise YoctoParseError(
272
+ f"{path}: duplicate key {key!r} in one block "
273
+ f"({_excerpt(fields[key])} then {_excerpt(value.strip())}); "
274
+ f"blocks must be separated by a blank line"
275
+ )
276
+ fields[key] = value.strip()
277
+
278
+ if "PACKAGE NAME" not in fields:
279
+ # image_license.manifest uses RECIPE NAME / VERSION / LICENSE /
280
+ # FILES and describes deployed artifacts rather than installed
281
+ # packages. Reading it as a package list would be wrong, and
282
+ # keying on "PACKAGE NAME" would silently yield nothing.
283
+ raise YoctoParseError(
284
+ f"{path}: no 'PACKAGE NAME' in block with keys "
285
+ f"{sorted(fields)} -- this looks like an "
286
+ f"image_license.manifest, which lists deployed files"
287
+ )
288
+
289
+ name = fields["PACKAGE NAME"]
290
+ if not name:
291
+ raise YoctoParseError(f"{path}: empty PACKAGE NAME")
292
+ version = normalize_version(fields.get("PACKAGE VERSION", "")) or None
293
+ recipe = fields.get("RECIPE NAME") or None
294
+
295
+ components.append(
296
+ Component(
297
+ name=name,
298
+ version=version,
299
+ license=fields.get("LICENSE") or None,
300
+ purl=make_purl(name, version),
301
+ properties={"yocto:recipe": recipe} if recipe else {},
302
+ )
303
+ )
304
+ return components
305
+
306
+
307
+ def parse(deploy: Path, *, image: str | None = None) -> tuple[list[Component], str]:
308
+ """Parse one image from a deploy directory.
309
+
310
+ Returns the components and the image label to use as the SBOM's root
311
+ component. Where a license manifest and an image manifest describe the
312
+ same image the license manifest is used, because it is the same inventory
313
+ with licenses and recipe names attached.
314
+
315
+ A deploy directory holding several images is ambiguous rather than wrong,
316
+ so the caller is told to choose instead of getting a guess.
317
+ """
318
+ manifests = find_manifests(deploy)
319
+ if not manifests:
320
+ raise YoctoParseError(f"no image or license manifest found under {deploy}")
321
+
322
+ if image is not None:
323
+ # An exact label wins over a short image name. Without this, a license
324
+ # directory named "core-image-minimal" beside an image manifest for
325
+ # "core-image-minimal-qemux86-64" leaves no string that selects the
326
+ # first: the short name matches both, and the label matches both too.
327
+ exact = [m for m in manifests if m.label == image]
328
+ manifests = exact or [m for m in manifests if m.matches(image)]
329
+ if not manifests:
330
+ raise YoctoParseError(f"no manifest matching {image!r} under {deploy}")
331
+
332
+ if len(manifests) > 1:
333
+ # `choice` is the short image name where one could be derived. Where
334
+ # two manifests share it -- a license directory that omits the machine
335
+ # suffix beside an image manifest that has it -- the short names would
336
+ # name the same image twice and neither would select anything.
337
+ short = [m.choice for m in manifests]
338
+ names = short if len(set(short)) == len(short) else [m.label for m in manifests]
339
+ choices = ", ".join(sorted(names))
340
+ raise YoctoParseError(
341
+ f"{deploy} holds several images ({choices}); pick one with --image"
342
+ )
343
+
344
+ manifest = manifests[0]
345
+ if manifest.kind is ManifestKind.LICENSE:
346
+ return parse_license_manifest(manifest.path), manifest.label
347
+ return parse_image_manifest(manifest.path), manifest.label
sbom_embedded/py.typed ADDED
File without changes
@@ -0,0 +1,147 @@
1
+ """CycloneDX output.
2
+
3
+ Written once, against `Component`. Parsers never touch CycloneDX types, so
4
+ the schema details -- and the library that validates them -- live here alone.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+ from datetime import datetime
11
+ from uuid import UUID
12
+
13
+ from cyclonedx.contrib.hash.factories import HashTypeFactory
14
+ from cyclonedx.contrib.license.factories import LicenseFactory
15
+ from cyclonedx.model import Property
16
+ from cyclonedx.model.bom import Bom
17
+ from cyclonedx.model.component import Component as CdxComponent
18
+ from cyclonedx.model.component import ComponentType
19
+ from cyclonedx.model.contact import OrganizationalEntity
20
+ from cyclonedx.model.dependency import Dependency
21
+ from cyclonedx.output import make_outputter
22
+ from cyclonedx.schema import OutputFormat, SchemaVersion
23
+ from packageurl import PackageURL
24
+
25
+ from . import __version__
26
+ from .models import Component
27
+
28
+ # 1.6 is what the CRA-adjacent tooling (Dependency-Track, Grype, Trivy) reads
29
+ # today; 1.7 is newer than most of it.
30
+ DEFAULT_SCHEMA_VERSION = SchemaVersion.V1_6
31
+
32
+ _licenses = LicenseFactory()
33
+ _hashes = HashTypeFactory()
34
+
35
+
36
+ def _to_cyclonedx(component: Component) -> CdxComponent:
37
+ """Translate one parsed component into a CycloneDX component."""
38
+ licenses = []
39
+ if component.license:
40
+ # Yocto writes "GPL-2.0-only & MIT" and Buildroot "GPL-2.0+, MIT" --
41
+ # neither is a valid SPDX expression. make_from_string keeps a real
42
+ # SPDX id or expression as such and falls back to a named license
43
+ # instead of dropping the string on the floor.
44
+ licenses.append(_licenses.make_from_string(component.license))
45
+
46
+ hashes = []
47
+ if component.hash:
48
+ hashes.append(_hashes.from_composite_str(component.hash))
49
+
50
+ return CdxComponent(
51
+ name=component.name,
52
+ version=component.version,
53
+ type=ComponentType.LIBRARY,
54
+ purl=PackageURL.from_string(component.purl) if component.purl else None,
55
+ bom_ref=component.purl,
56
+ supplier=(
57
+ OrganizationalEntity(name=component.supplier)
58
+ if component.supplier
59
+ else None
60
+ ),
61
+ licenses=licenses or None,
62
+ hashes=hashes or None,
63
+ properties=[
64
+ Property(name=name, value=value)
65
+ for name, value in sorted(component.properties.items())
66
+ ]
67
+ or None,
68
+ )
69
+
70
+
71
+ def build_bom(
72
+ components: Iterable[Component],
73
+ *,
74
+ product_name: str,
75
+ product_version: str | None = None,
76
+ serial_number: UUID | None = None,
77
+ timestamp: datetime | None = None,
78
+ ) -> Bom:
79
+ """Assemble a BOM whose root component is the image itself.
80
+
81
+ `serial_number` and `timestamp` exist so tests (and reproducible builds)
82
+ can pin the two fields that would otherwise change on every run.
83
+ """
84
+ bom = Bom(serial_number=serial_number)
85
+
86
+ root = CdxComponent(
87
+ name=product_name,
88
+ version=product_version,
89
+ # The deliverable is a firmware image, not an application -- this is
90
+ # the distinction a CRA reviewer cares about.
91
+ type=ComponentType.FIRMWARE,
92
+ bom_ref=product_name,
93
+ )
94
+ bom.metadata.component = root
95
+ if timestamp is not None:
96
+ bom.metadata.timestamp = timestamp
97
+ bom.metadata.tools.components.add(
98
+ CdxComponent(
99
+ name="sbom-embedded",
100
+ version=__version__,
101
+ type=ComponentType.APPLICATION,
102
+ )
103
+ )
104
+
105
+ rendered = [_to_cyclonedx(component) for component in components]
106
+ for cdx in rendered:
107
+ bom.components.add(cdx)
108
+
109
+ # Every package is a direct part of the image. This is not a real
110
+ # dependency graph -- the manifests do not carry one -- but consumers that
111
+ # walk `dependencies` from the root would otherwise see nothing.
112
+ #
113
+ # Built directly rather than through Bom.register_dependency, which scans
114
+ # the whole dependency set on every call and so costs O(n^2): rendering
115
+ # 4000 packages took 2.7 s through it and 1.6 s this way, for
116
+ # byte-identical output. Real images reach a few thousand packages.
117
+ bom.dependencies.add(
118
+ Dependency(
119
+ ref=root.bom_ref,
120
+ dependencies=[Dependency(ref=cdx.bom_ref) for cdx in rendered],
121
+ )
122
+ )
123
+ for cdx in rendered:
124
+ bom.dependencies.add(Dependency(ref=cdx.bom_ref))
125
+
126
+ return bom
127
+
128
+
129
+ def to_json(
130
+ components: Iterable[Component],
131
+ *,
132
+ product_name: str,
133
+ product_version: str | None = None,
134
+ serial_number: UUID | None = None,
135
+ timestamp: datetime | None = None,
136
+ schema_version: SchemaVersion = DEFAULT_SCHEMA_VERSION,
137
+ ) -> str:
138
+ """Render components as a CycloneDX JSON document."""
139
+ bom = build_bom(
140
+ components,
141
+ product_name=product_name,
142
+ product_version=product_version,
143
+ serial_number=serial_number,
144
+ timestamp=timestamp,
145
+ )
146
+ outputter = make_outputter(bom, OutputFormat.JSON, schema_version)
147
+ return outputter.output_as_string(indent=2)
@@ -0,0 +1,313 @@
1
+ Metadata-Version: 2.5
2
+ Name: sbom-embedded
3
+ Version: 0.1.0
4
+ Summary: CycloneDX SBOM generator for Yocto and Buildroot build output
5
+ Project-URL: Homepage, https://github.com/RchrdWrd/sbom-embedded
6
+ Project-URL: Source, https://github.com/RchrdWrd/sbom-embedded
7
+ Author-email: Richard Ward <richard.daily.ward@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: buildroot,cra,cyclonedx,embedded,sbom,yocto
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: cyclonedx-python-lib>=11.6
13
+ Requires-Dist: packageurl-python>=0.15
14
+ Requires-Dist: typer>=0.16
15
+ Description-Content-Type: text/markdown
16
+
17
+ # sbom-embedded
18
+
19
+ [![CI](https://github.com/RchrdWrd/sbom-embedded/actions/workflows/ci.yml/badge.svg)](https://github.com/RchrdWrd/sbom-embedded/actions/workflows/ci.yml)
20
+ [![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)
21
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/RchrdWrd/sbom-embedded/blob/main/LICENSE)
22
+
23
+ Generate a CycloneDX SBOM from a Yocto or Buildroot build you have already
24
+ run, by reading the manifest files the build wrote — no rebuild, no bitbake,
25
+ under a tenth of a second.
26
+
27
+ The EU Cyber Resilience Act (2024/2847) requires device manufacturers to keep
28
+ a machine-readable component list for their products. Syft, Trivy and cdxgen
29
+ are good at containers and npm and weak at embedded Linux build systems. This
30
+ fills that gap.
31
+
32
+ ## Install and run
33
+
34
+ ```bash
35
+ pipx run --spec git+https://github.com/RchrdWrd/sbom-embedded sbom-embedded ./output > sbom.json
36
+ ```
37
+
38
+ With `uv`:
39
+
40
+ ```bash
41
+ uvx --from git+https://github.com/RchrdWrd/sbom-embedded sbom-embedded ./output > sbom.json
42
+ ```
43
+
44
+ Or install it properly:
45
+
46
+ ```bash
47
+ pipx install git+https://github.com/RchrdWrd/sbom-embedded
48
+ ```
49
+
50
+ Python 3.11 or newer. The tool only reads files, so it runs anywhere Python
51
+ does.
52
+
53
+ ## Usage
54
+
55
+ Point it at a build directory. It works out which build system produced it —
56
+ you do not have to say.
57
+
58
+ ```bash
59
+ sbom-embedded ./build/tmp/deploy > sbom.json # Yocto
60
+ sbom-embedded ./output > sbom.json # Buildroot
61
+ ```
62
+
63
+ | Option | Meaning |
64
+ | --- | --- |
65
+ | `--format` | Output format. `cyclonedx` is the only one. |
66
+ | `--image` | Which image to describe, when a Yocto deploy holds several. |
67
+ | `--name` | Name for the product. Defaults to the image name, or `buildroot`. |
68
+ | `--product-version` | Version of the product. Omitted from the SBOM if not given. |
69
+ | `--output`, `-o` | Write to a file instead of stdout. |
70
+
71
+ ### Real output
72
+
73
+ Run against a Buildroot manifest in this repository:
74
+
75
+ ```console
76
+ $ sbom-embedded tests/fixtures/buildroot-2023.02
77
+ {
78
+ "components": [
79
+ {
80
+ "bom-ref": "pkg:generic/busybox@1.36.1",
81
+ "licenses": [
82
+ {
83
+ "license": {
84
+ "name": "GPL-2.0, bzip2-1.0.4"
85
+ }
86
+ }
87
+ ],
88
+ "name": "busybox",
89
+ "purl": "pkg:generic/busybox@1.36.1",
90
+ "type": "library",
91
+ "version": "1.36.1"
92
+ },
93
+ ...
94
+ ```
95
+
96
+ If a Yocto deploy directory holds more than one image, it stops and lists them
97
+ rather than picking one:
98
+
99
+ ```console
100
+ $ sbom-embedded tests/fixtures/yocto-6.0.2
101
+ error: tests/fixtures/yocto-6.0.2 holds several images (core-image-full-cmdline,
102
+ core-image-minimal); pick one with --image
103
+ ```
104
+
105
+ ### On a real Buildroot build
106
+
107
+ Verified end to end on Buildroot `2026.08-rc3-28-g79fd6241e4`:
108
+
109
+ ```bash
110
+ git clone https://gitlab.com/buildroot.org/buildroot.git
111
+ cd buildroot
112
+ make qemu_x86_64_defconfig
113
+ make legal-info # downloads sources, does not compile: 10-30 min
114
+ sbom-embedded ./output -o sbom.json
115
+ ```
116
+
117
+ ```console
118
+ $ sbom-embedded ./output -o sbom.json
119
+ wrote 44 components to sbom.json
120
+ ```
121
+
122
+ 44 components in **0.09 s**; all 44 carry a purl, a version and a license; 26
123
+ distinct license expressions; the document validates against the CycloneDX 1.6
124
+ schema. That build's manifest is committed as
125
+ `tests/fixtures/buildroot-2026.08/` — see
126
+ [PROVENANCE.md](https://github.com/RchrdWrd/sbom-embedded/blob/main/tests/fixtures/PROVENANCE.md).
127
+
128
+ > **Ubuntu 25.10 and newer:** `make legal-info` refuses to start with
129
+ > *"You have an uutils 'install' version installed"*, because those releases
130
+ > ship uutils coreutils rather than GNU coreutils. You can fix it system-wide
131
+ > with `sudo update-alternatives --install /usr/bin/install install /usr/bin/gnuinstall 100`,
132
+ > or just for one build without touching the system:
133
+ >
134
+ > ```bash
135
+ > mkdir -p /tmp/gnushim && ln -sf /usr/bin/gnuinstall /tmp/gnushim/install
136
+ > PATH=/tmp/gnushim:$PATH make legal-info
137
+ > ```
138
+
139
+ ## Read this before you scan the output
140
+
141
+ **A vulnerability scan of this SBOM can report zero findings while the
142
+ firmware is full of known vulnerabilities.** This is not a hypothetical.
143
+
144
+ Every component gets a `pkg:generic/<name>@<version>` purl. Neither Yocto nor
145
+ Buildroot packages exist in any ecosystem repository, so there is no better
146
+ purl type available. But Grype, Trivy and Dependency-Track do not resolve
147
+ `pkg:generic` to a vulnerability namespace — they reach the NVD through CPEs,
148
+ which this tool does not emit, because a CPE would be a guess about vendor and
149
+ product strings rather than something any manifest records.
150
+
151
+ Measured, not assumed. The SBOM from the real Buildroot build above, scanned
152
+ with Grype 0.118.0:
153
+
154
+ ```console
155
+ $ grype sbom:sbom.json
156
+ No vulnerabilities found
157
+ ```
158
+
159
+ The same five packages, with CPEs added by hand purely to demonstrate the
160
+ difference:
161
+
162
+ ```console
163
+ NAME INSTALLED TYPE VULNERABILITY SEVERITY EPSS RISK
164
+ busybox 1.38.0 UnknownPackage CVE-2026-38754 High 0.4% (33rd) 0.3
165
+ busybox 1.38.0 UnknownPackage CVE-2026-38755 High 0.3% (27th) 0.2
166
+ busybox 1.38.0 UnknownPackage CVE-2026-38753 High 0.2% (15th) 0.2
167
+ ```
168
+
169
+ Same document, same packages, same scanner. The difference is the identifier,
170
+ not the firmware.
171
+
172
+ So: use this SBOM as a component inventory and a compliance record. **Do not
173
+ read a clean Grype run on it as evidence that the firmware is clean.** For
174
+ vulnerability matching you need a tool that maps package names to CPEs, or a
175
+ scanner configured for these package names specifically.
176
+
177
+ ## How much of this is verified on real builds
178
+
179
+ Both paths have been walked from a build to an SBOM on real hardware, not
180
+ from fixtures alone.
181
+
182
+ **Buildroot.** A real `make legal-info` on Buildroot 2026.08-rc3 produced the
183
+ 44-package manifest shown above, and a real `make` produced a complete output
184
+ tree of 867,355 files. That tree turned out to contain ten files named
185
+ `*.manifest` — all of them Windows application manifests inside host package
186
+ sources (host-python3, host-cmake, host-ninja, gcc). None sits at
187
+ `images/<dir>/*.manifest`, which is the only reason Yocto detection does not
188
+ fire on a Buildroot tree; there is a test pinning that.
189
+
190
+ **Yocto.** `bitbake core-image-minimal` was run to completion for qemux86-64
191
+ on the Yocto 6.0.2 release revisions, and the tool was run against the
192
+ `tmp/deploy` directory it wrote:
193
+
194
+ ```console
195
+ $ sbom-embedded ./deploy -o sbom.json
196
+ wrote 39 components to sbom.json
197
+ ```
198
+
199
+ 39 components in 0.09 s, every one with a purl, a license and a `yocto:recipe`
200
+ property, validating against the CycloneDX 1.6 schema. That deploy tree is
201
+ committed as `tests/fixtures/yocto-6.0.2-live/`.
202
+
203
+ The same image was then rebuilt with `PACKAGE_CLASSES = "package_ipk"`. That
204
+ matters because every other fixture here comes from an rpm-backend build, and
205
+ rpm is the one backend whose version column carries no package revision. In
206
+ the ipk manifest all 38 rows do (`busybox 1.37.0-r0`), two carry `-r1`, and
207
+ `netbase all 1:6.5-r0` carries an epoch. The tool strips all of that, so the
208
+ 37 purls the two images share are byte-identical: **the same firmware
209
+ described the same way regardless of how it was packaged.** Both manifests are
210
+ committed, and a test compares them.
211
+
212
+ It is the only fixture holding both manifest kinds from a single build, which
213
+ makes it the one that demonstrates why they must never be joined by package
214
+ name: the image manifest lists 37 packages, the license manifest 39, and 11 of
215
+ the 37 names have no counterpart on the other side — `libc6` against `glibc`,
216
+ `libz1` against `zlib`, `libcrypto3` against `libcrypto`, and so on.
217
+
218
+ > **Building Yocto on a current host:** the 6.0.2 release works, but the
219
+ > 5.0.9 release does not — its bitbake crashes on Python 3.14, and its
220
+ > `UNINATIVE_MAXGLIBCVERSION` is below a current glibc. Configuring
221
+ > `SSTATE_MIRRORS` against `sstate.yoctoproject.org` is what makes the build
222
+ > practical: 373 of 396 wanted objects came from the mirror, so it fit on a
223
+ > machine with 7 GB of free disk instead of needing 20-40.
224
+
225
+ ## Known limitations
226
+
227
+ * **Yocto builds without a license manifest produce no licenses.** The image
228
+ manifest has no license column. If your build kept
229
+ `tmp/deploy/licenses/`, that is read instead and you get licenses and recipe
230
+ names; otherwise you get names, versions and purls only. Buildroot always
231
+ carries licenses.
232
+ * **Package names are not upstream project names.** Your firmware contains
233
+ `libcrypto`, `openssl-conf` and `openssl-ossl-module-legacy`; a CVE database
234
+ knows `openssl`. Where a Yocto license manifest is read, the recipe behind
235
+ each package is recorded as a `yocto:recipe` property. Where only the image
236
+ manifest exists, the names are the Debian-renamed forms (`libc6`, `libz1`)
237
+ with no way back.
238
+ * **License strings are copied, not normalised.** Yocto writes
239
+ `GPL-2.0-only & MIT` and Buildroot `GPL-2.0+ (programs), LGPL-2.1+` — neither
240
+ is a valid SPDX expression. Valid SPDX identifiers and expressions are
241
+ emitted as such; everything else is emitted as a named license, verbatim.
242
+ Nothing is guessed at or dropped.
243
+ * **No supplier and no hashes.** Neither build system records a supplier or a
244
+ per-package hash, so those fields are absent rather than invented.
245
+ * **The dependency graph is flat.** Every package hangs off the image. The
246
+ manifests read do not carry inter-package dependencies.
247
+ * **Buildroot `SOURCE ARCHIVE`, `SOURCE SITE` and `LICENSE FILES` are not
248
+ emitted.**
249
+ * **The product name and version are yours to supply.** A Buildroot manifest
250
+ carries no product identity, so the root component is named `buildroot`
251
+ unless you pass `--name`, and has no version unless you pass
252
+ `--product-version`. Nothing is invented to fill them.
253
+
254
+ The underlying reasoning, and the manifest formats in detail, are in
255
+ [DESIGN.md](https://github.com/RchrdWrd/sbom-embedded/blob/main/DESIGN.md).
256
+
257
+ ## Releasing
258
+
259
+ Publishing runs from `.github/workflows/publish.yml` through PyPI Trusted
260
+ Publishing, so there is no API token anywhere in the repository or in GitHub
261
+ secrets — PyPI verifies the workflow's identity over OpenID Connect.
262
+
263
+ One-time setup, on PyPI under *Your projects → Publishing → Add a pending
264
+ publisher*:
265
+
266
+ | Field | Value |
267
+ | --- | --- |
268
+ | PyPI project name | `sbom-embedded` |
269
+ | Owner | `RchrdWrd` |
270
+ | Repository name | `sbom-embedded` |
271
+ | Workflow name | `publish.yml` |
272
+ | Environment name | `pypi` |
273
+
274
+ Repeat it on [test.pypi.org](https://test.pypi.org) with environment
275
+ `testpypi`. Then, in the repository's *Settings → Environments*, create both
276
+ environments — add a required reviewer on `pypi` if you want a manual gate.
277
+
278
+ To release:
279
+
280
+ 1. Run the **Publish** workflow manually against `testpypi` and check the
281
+ rendered project page.
282
+ 2. Bump `version` in `pyproject.toml`, update `CHANGELOG.md`, commit.
283
+ 3. Tag and push: `git tag -a v0.2.0 -m "..." && git push origin v0.2.0`.
284
+
285
+ The tag push publishes to PyPI. The workflow refuses to publish if the tag and
286
+ the packaged version disagree, and it installs the built wheel and generates an
287
+ SBOM from it before either upload step runs. A PyPI release cannot be replaced,
288
+ only yanked, which is why TestPyPI comes first.
289
+
290
+ ## Development
291
+
292
+ ```bash
293
+ uv sync
294
+ uv run pytest
295
+ ```
296
+
297
+ Without `uv`:
298
+
299
+ ```bash
300
+ python3 -m venv .venv
301
+ .venv/bin/pip install -e . --group dev # needs pip 25.1+ for --group
302
+ .venv/bin/python -m pytest
303
+ ```
304
+
305
+ 92 tests, a second or two. They run from fixture files under
306
+ `tests/fixtures`, never from a live build. Every fixture is unmodified output
307
+ from a real Yocto or Buildroot build —
308
+ [PROVENANCE.md](https://github.com/RchrdWrd/sbom-embedded/blob/main/tests/fixtures/PROVENANCE.md) records where each came from and
309
+ what it is kept for. The format details matter too much to mock.
310
+
311
+ ## License
312
+
313
+ MIT — see [LICENSE](https://github.com/RchrdWrd/sbom-embedded/blob/main/LICENSE).
@@ -0,0 +1,14 @@
1
+ sbom_embedded/__init__.py,sha256=FfqzTmaeOxJp1tHwAosjACYE7Db6t9CKGiSLz5dDrAA,94
2
+ sbom_embedded/cli.py,sha256=mkOYPH6BSliddXSLYtJ-u-RL6E2Jx_6Zen0fVjBpJLY,4644
3
+ sbom_embedded/models.py,sha256=awnRLm1mdLmZimJP84Q1axF8X43153x2yLmAJv_9SOU,2242
4
+ sbom_embedded/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ sbom_embedded/writer.py,sha256=WsAGPdYa1rKL85BCRGckOFmAL_NqbtUtJQwTyONzn9I,4992
6
+ sbom_embedded/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ sbom_embedded/parsers/buildroot.py,sha256=t6vwaHn1ao_ntG4IiyLb_j0KVTHh-WWWto4YqqmgOLM,4710
8
+ sbom_embedded/parsers/detect.py,sha256=m27-E4Qyuj57Y7F_5WUpg3MwNl5j3BAtq0DI4M00U8E,3334
9
+ sbom_embedded/parsers/yocto.py,sha256=zJQ6pLEdbblda4i6rTWqelwnY4FeCsXWDAuDxvqzeC4,14404
10
+ sbom_embedded-0.1.0.dist-info/METADATA,sha256=J8cdgf2ybHeV5kZp2aRrhbZbqiTOi8s2PxnRjB29fo0,12490
11
+ sbom_embedded-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
+ sbom_embedded-0.1.0.dist-info/entry_points.txt,sha256=Ca8oRgbjaNl2ithnIQDCJ7otixjsL3pOFXlNQxFz_F0,56
13
+ sbom_embedded-0.1.0.dist-info/licenses/LICENSE,sha256=k-JiC0-uu0JRXqMfu1238PBZhG7giCrWauKThcIKdkY,1069
14
+ sbom_embedded-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sbom-embedded = sbom_embedded.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Richard Ward
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.