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.
- sbom_embedded/__init__.py +3 -0
- sbom_embedded/cli.py +153 -0
- sbom_embedded/models.py +63 -0
- sbom_embedded/parsers/__init__.py +0 -0
- sbom_embedded/parsers/buildroot.py +125 -0
- sbom_embedded/parsers/detect.py +109 -0
- sbom_embedded/parsers/yocto.py +347 -0
- sbom_embedded/py.typed +0 -0
- sbom_embedded/writer.py +147 -0
- sbom_embedded-0.1.0.dist-info/METADATA +313 -0
- sbom_embedded-0.1.0.dist-info/RECORD +14 -0
- sbom_embedded-0.1.0.dist-info/WHEEL +4 -0
- sbom_embedded-0.1.0.dist-info/entry_points.txt +2 -0
- sbom_embedded-0.1.0.dist-info/licenses/LICENSE +21 -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
|
+
)
|
sbom_embedded/models.py
ADDED
|
@@ -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
|
sbom_embedded/writer.py
ADDED
|
@@ -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
|
+
[](https://github.com/RchrdWrd/sbom-embedded/actions/workflows/ci.yml)
|
|
20
|
+
[](https://github.com/RchrdWrd/sbom-embedded/blob/main/pyproject.toml)
|
|
21
|
+
[](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,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.
|