diff --git a/docs/sbom.md b/docs/sbom.md index aed5507..73b00cb 100644 --- a/docs/sbom.md +++ b/docs/sbom.md @@ -642,6 +642,70 @@ graph TD D -->|GENERATED_FROM| C ``` +##### Bundled dependencies + +Some RPM spec files declare copylib or vendored components using `Provides: bundled(...)` or +`Provides: golang(...)`. These are recorded on binary subpackage RPM headers (for example +`vim-enhanced` may provide `bundled(libvterm)`), not on the SRPM itself. Collect them from +Koji `getRPMDeps` Provides (type `1`) across every RPM in the build. + +Each bundled component should be represented as its own package object, linked to the SRPM with +a `DEPENDENCY_OF` relationship (the bundled package is a dependency of the SRPM build). Set +`primaryPackagePurpose` to `LIBRARY`, `downloadLocation` to `NOASSERTION`, and `filesAnalyzed` +to `false`. + +Use a typed purl when the provide name indicates the ecosystem: + +| Language prefix in provide | purl type | Example provide | Example purl | +|----------------------------|-----------|-----------------|--------------| +| *(none)* / generic bundled | `generic` | `bundled(libvterm)` | `pkg:generic/libvterm` | +| `golang(...)` | `golang` | `golang(github.com/foo/bar)` | `pkg:golang/github.com/foo/bar@1.2.3` | +| `bundled(python(...))` | `pypi` | `bundled(python(requests))` | `pkg:pypi/requests@2.31.0` | +| `bundled(nodejs(...))` | `npm` | `bundled(nodejs(lodash))` | `pkg:npm/lodash@4.17.21` | +| `bundled(ruby(...))` | `gem` | `bundled(ruby(rake))` | `pkg:gem/rake@13.0.6` | +| `bundled(crate(...))` | `cargo` | `bundled(crate(serde))` | `pkg:cargo/serde@1.0.0` | +| `bundled(mvn(...))` | `maven` | `bundled(mvn(org/foo))` | `pkg:maven/org/foo@1.0.0` | + +=== "SPDX 2.3" + + ```json + { + "SPDXID": "SPDXRef-Bundled-11cdd6f19dc1", + "name": "libvterm (generic)", + "versionInfo": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "primaryPackagePurpose": "LIBRARY", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:generic/libvterm" + } + ] + } + ``` + +=== "SPDX 2.3" + + ```json + { + "spdxElementId": "SPDXRef-Bundled-11cdd6f19dc1", + "relationshipType": "DEPENDENCY_OF", + "relatedSpdxElement": "SPDXRef-SRPM" + } + ``` + +**Example:** the build-time SBOM for `vim-9.1.083-5.el10` includes `bundled(libvterm)` from +the `vim-enhanced` subpackage: +[build/vim-9.1.083-5.el10](https://github.com/RedHatProductSecurity/security-data-guidelines/blob/main/sbom/examples/rpm/build/vim-9.1.083-5.el10.spdx.json). + +Bundled components declared in the spec are distinct from: + +- **Source archives** (`Source0`, `Source1`, …), which are linked with `CONTAINS` from the SRPM. +- **Components discovered by scanning** an unpacked source tree (for example with Syft), which + are nested under the relevant source archive. + #### Product Individual components such as packages and container images are almost always provided as part of a specific product. diff --git a/sbom/examples/rpm/build/README.md b/sbom/examples/rpm/build/README.md index 5347f12..0ce3b6c 100644 --- a/sbom/examples/rpm/build/README.md +++ b/sbom/examples/rpm/build/README.md @@ -26,6 +26,14 @@ When comparing two `pkg:rpm` purls, identical except that one has a `repository_id` qualifier and the other does not, many systems may treat these as distinct. +Bundled dependencies +-------------------- + +Build-time SBOMs may include packages for `bundled()` / `golang()` Provides +from the RPM spec (see [Understanding SBOMs — Bundled dependencies](../../../../docs/sbom.md#bundled-dependencies)). +The `vim-9.1.083-5.el10` example includes `bundled(libvterm)` linked to the +SRPM with `DEPENDENCY_OF`. + When a container image is built including RPMs, the SBOM for the container image should refer to the RPMs using external references both with and without the `repository_id` qualifier, to ensure purl matching to the original RPM diff --git a/sbom/examples/rpm/build/bundled_provides.py b/sbom/examples/rpm/build/bundled_provides.py new file mode 100644 index 0000000..a100dec --- /dev/null +++ b/sbom/examples/rpm/build/bundled_provides.py @@ -0,0 +1,189 @@ +""" +``bundled()`` / ``golang()`` from Koji Provides (Deptopia ``internal/sources/rpm.go``). + +Lightweight SPDX 2 document fragments for manifests (packages + DEPENDENCY_OF). +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Any + +LANG_TO_PURL_TYPE: dict[str, str] = { + "golang": "golang", + "python": "pypi", + "nodejs": "npm", + "rust": "cargo", + "ruby": "gem", + "java": "maven", + "generic": "generic", +} + + +@dataclass +class RpmDep: + """Koji ``getRPMDeps`` row.""" + + name: str + version: str + dep_type: int # 0 requires, 1 provides + + +@dataclass +class BundledDep: + """Single ``bundled()`` or ``golang()`` provide after parsing.""" + + path: str + version: str + lang: str # "generic", "golang", "python", ... + + +def _bundled_purl(dep: BundledDep) -> str: + purl_type = LANG_TO_PURL_TYPE.get(dep.lang, "generic") + ver = f"@{dep.version}" if dep.version else "" + return f"pkg:{purl_type}/{dep.path}{ver}" + + +def _dep_lang_from_inner(name_inner: str) -> tuple[str, str]: + """Map inner provide name → (path, lang). Mirrors ``getDepListLangFromName``.""" + if name_inner.startswith("golang)") and "(" in name_inner: + ss = name_inner[len("golang)") :].lstrip() + if ss.startswith("(") and ")" in ss: + path = ss[1 : ss.index(")")] + return path, "golang" + + s = name_inner + if " with " in s: + s = s.strip().strip("()") + s = s.split(" ", 1)[0] + + parts = s.split("(", 1) + if len(parts) > 1: + prefix, rest = parts[0], parts[1].rstrip(")") + lp = prefix.lower() + if "golang" in lp: + return rest, "golang" + if "python" in lp: + return rest, "python" + if "npm" in lp or "nodejs" in lp: + return rest, "nodejs" + if "ruby" in lp: + return rest, "ruby" + if "crate" in lp: + return rest, "rust" + if "mvn" in lp: + return rest, "java" + + sl = name_inner.lower() + if sl.startswith("nodejs-"): + return name_inner.split("-", 1)[1], "nodejs" + if sl.startswith("python-") or sl.startswith("python3-") or sl.startswith("python2-"): + return name_inner.split("-", 1)[1], "python" + if sl.startswith("rubygem-"): + return name_inner.split("-", 1)[1], "ruby" + + return name_inner, "generic" + + +def _parse_wrapped( + prefix: str, rpm_name: str, rpm_ver: str, default_lang: str +) -> BundledDep | None: + pfx = prefix + "(" + if not rpm_name.startswith(pfx) or not rpm_name.endswith(")"): + return None + inner = rpm_name[len(pfx) : -1] + path, lang = _dep_lang_from_inner(inner) + if lang == "generic" and default_lang != "generic": + lang = default_lang + return BundledDep(path=path, version=rpm_ver, lang=lang) + + +def bundled_golang_from_provides(provides: list[RpmDep]) -> list[BundledDep]: + """Extract ``bundled(...)`` and ``golang(...)`` Provides (type 1 rows).""" + out: list[BundledDep] = [] + seen: set[tuple[str, str, str]] = set() + for d in provides: + b = _parse_wrapped("bundled", d.name, d.version, "generic") + if b: + key = (b.path, b.version, b.lang) + if key not in seen: + seen.add(key) + out.append(b) + continue + g = _parse_wrapped("golang", d.name, d.version, "golang") + if g: + key = (g.path, g.version, g.lang) + if key not in seen: + seen.add(key) + out.append(g) + return out + + +def _ref_id(s: str) -> str: + h = hashlib.sha256(s.encode("utf-8")).hexdigest()[:12] + return f"SPDXRef-Bundled-{h}" + + +def bundled_provides_to_spdx_fragments( + bundled: list[BundledDep], + *, + srpm_spdx_id: str, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """ + Return ``(packages, relationships)`` SPDX JSON-LD-style dicts (subset). + + Each bundled dep becomes a package; ``DEPENDENCY_OF`` links it to ``srpm_spdx_id``. + """ + packages: list[dict[str, Any]] = [] + rels: list[dict[str, Any]] = [] + for b in bundled: + pid = _ref_id(f"{b.path}\0{b.version}\0{b.lang}") + name = f"{b.path} ({b.lang})" + if b.version: + name = f"{name} {b.version}" + packages.append( + { + "SPDXID": pid, + "name": name, + "versionInfo": b.version or "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "primaryPackagePurpose": "LIBRARY", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": _bundled_purl(b), + } + ], + } + ) + rels.append( + { + "spdxElementId": pid, + "relationshipType": "DEPENDENCY_OF", + "relatedSpdxElement": srpm_spdx_id, + } + ) + return packages, rels + + +def bundled_provides_to_cdx_components(bundled: list[BundledDep]) -> list[dict[str, Any]]: + """Return CycloneDX components for bundled deps (type ``library``).""" + components: list[dict[str, Any]] = [] + for b in bundled: + purl = _bundled_purl(b) + name = f"{b.path} ({b.lang})" + if b.version: + name = f"{name} {b.version}" + components.append( + { + "bom-ref": purl, + "type": "library", + "name": name, + "version": b.version or None, + "purl": purl, + } + ) + return components diff --git a/sbom/examples/rpm/build/from-koji.py b/sbom/examples/rpm/build/from-koji.py index ded7e25..8fcdb75 100755 --- a/sbom/examples/rpm/build/from-koji.py +++ b/sbom/examples/rpm/build/from-koji.py @@ -4,10 +4,17 @@ import re import subprocess import sys -import koji from copy import deepcopy from tempfile import TemporaryDirectory +import koji +from bundled_provides import ( + RpmDep, + bundled_golang_from_provides, + bundled_provides_to_cdx_components, + bundled_provides_to_spdx_fragments, +) + # Script requires these RPMs: brewkoji, rpmdevtools, rpm-build # Run with: ./from-koji.py brew @@ -99,6 +106,39 @@ def get_sha256_checksum(filename): h.update(chunk) return h.hexdigest() + @staticmethod + def get_build_provides(build_id): + """Collect Provides (type 1) from every RPM in a Koji build.""" + build = SESSION.getBuild(build_id) + provides = [] + for rpm in SESSION.listBuildRPMs(build["id"]): + rpm_id = rpm.get("id") + if not rpm_id: + continue + for dep in SESSION.getRPMDeps(rpm_id): + if dep.get("type") != 1: + continue + provides.append( + RpmDep( + name=str(dep.get("name") or ""), + version=str(dep.get("version") or ""), + dep_type=1, + ) + ) + return provides + + def add_bundled_provides(self, build_id): + """Add bundled()/golang() provides as DEPENDENCY_OF packages linked to the SRPM.""" + bundled_deps = bundled_golang_from_provides(self.get_build_provides(build_id)) + if not bundled_deps: + return [] + packages, rels = bundled_provides_to_spdx_fragments( + bundled_deps, srpm_spdx_id="SPDXRef-SRPM" + ) + self.spdx_packages.extend(packages) + self.spdx_relationships.extend(rels) + return bundled_provides_to_cdx_components(bundled_deps) + def run_syft_spdx(self, builddir): syft = subprocess.run( cwd=os.path.dirname(builddir), @@ -515,6 +555,8 @@ def process_build(self, build_id, rpmmod): self.spdx_packages.append(package) self.cdx_components.append(create_cdx_from_spdx(package)) + bundled_cdx_components = self.add_bundled_provides(build_id) + spdx = { "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", @@ -553,17 +595,27 @@ def process_build(self, build_id, rpmmod): copy_of_cdx_root["pedigree"] = {"ancestors": cdx_pedigrees} self.cdx_components.append(copy_of_cdx_root) + for bundled_component in bundled_cdx_components: + if bundled_component.get("version") is None: + bundled_component.pop("version", None) + self.cdx_components.append(bundled_component) cdx["components"] = sorted(self.cdx_components, key=lambda c: c["purl"]) binary_rpm_purls = set() for cdx_component in self.cdx_components: if cdx_component["bom-ref"] == copy_of_cdx_root["bom-ref"]: continue - binary_rpm_purls.add(cdx_component["purl"]) + if cdx_component["bom-ref"].startswith("pkg:rpm/"): + binary_rpm_purls.add(cdx_component["purl"]) - cdx["dependencies"] = [ - {"ref": copy_of_cdx_root["bom-ref"], "provides": sorted(list(binary_rpm_purls))} - ] + srpm_dep = { + "ref": copy_of_cdx_root["bom-ref"], + "provides": sorted(list(binary_rpm_purls)), + } + bundled_refs = sorted(c["bom-ref"] for c in bundled_cdx_components) + if bundled_refs: + srpm_dep["dependsOn"] = bundled_refs + cdx["dependencies"] = [srpm_dep] with open(f"{build_id}.spdx.json", "w") as fp: # Add an extra newline at the end since a lot of editors add one when you save a file, diff --git a/sbom/examples/rpm/build/vim-9.1.083-5.el10.cdx.json b/sbom/examples/rpm/build/vim-9.1.083-5.el10.cdx.json index a07985c..52f5f99 100644 --- a/sbom/examples/rpm/build/vim-9.1.083-5.el10.cdx.json +++ b/sbom/examples/rpm/build/vim-9.1.083-5.el10.cdx.json @@ -43,6 +43,12 @@ } }, "components": [ + { + "bom-ref": "pkg:generic/libvterm", + "type": "library", + "name": "libvterm (generic)", + "purl": "pkg:generic/libvterm" + }, { "bom-ref": "pkg:rpm/redhat/vim-X11-debuginfo@9.1.083-5.el10?arch=aarch64&epoch=2", "type": "library", @@ -1192,6 +1198,9 @@ "pkg:rpm/redhat/xxd@9.1.083-5.el10?arch=ppc64le&epoch=2", "pkg:rpm/redhat/xxd@9.1.083-5.el10?arch=s390x&epoch=2", "pkg:rpm/redhat/xxd@9.1.083-5.el10?arch=x86_64&epoch=2" + ], + "dependsOn": [ + "pkg:generic/libvterm" ] } ] diff --git a/sbom/examples/rpm/build/vim-9.1.083-5.el10.spdx.json b/sbom/examples/rpm/build/vim-9.1.083-5.el10.spdx.json index 47468cf..986f447 100644 --- a/sbom/examples/rpm/build/vim-9.1.083-5.el10.spdx.json +++ b/sbom/examples/rpm/build/vim-9.1.083-5.el10.spdx.json @@ -68,6 +68,21 @@ } ] }, + { + "SPDXID": "SPDXRef-Bundled-11cdd6f19dc1", + "name": "libvterm (generic)", + "versionInfo": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "primaryPackagePurpose": "LIBRARY", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:generic/libvterm" + } + ] + }, { "SPDXID": "SPDXRef-aarch64-vim-X11-debuginfo", "name": "vim-X11-debuginfo", @@ -1736,6 +1751,11 @@ "relationshipType": "CONTAINS", "relatedSpdxElement": "SPDXRef-Source0" }, + { + "spdxElementId": "SPDXRef-Bundled-11cdd6f19dc1", + "relationshipType": "DEPENDENCY_OF", + "relatedSpdxElement": "SPDXRef-SRPM" + }, { "spdxElementId": "SPDXRef-aarch64-vim-X11-debuginfo", "relationshipType": "GENERATED_FROM", diff --git a/sbom/examples/rpm/release/vim-9.1.083-5.el10.cdx.json b/sbom/examples/rpm/release/vim-9.1.083-5.el10.cdx.json index 48b70d7..34bcefa 100644 --- a/sbom/examples/rpm/release/vim-9.1.083-5.el10.cdx.json +++ b/sbom/examples/rpm/release/vim-9.1.083-5.el10.cdx.json @@ -1940,11 +1940,20 @@ } ] } + }, + { + "bom-ref": "pkg:generic/libvterm", + "type": "library", + "name": "libvterm (generic)", + "purl": "pkg:generic/libvterm" } ], "dependencies": [ { "ref": "pkg:rpm/redhat/vim@9.1.083-5.el10?arch=src&epoch=2", + "dependsOn": [ + "pkg:generic/libvterm" + ], "provides": [ "pkg:rpm/redhat/vim-X11-debuginfo@9.1.083-5.el10?arch=aarch64&epoch=2", "pkg:rpm/redhat/vim-X11-debuginfo@9.1.083-5.el10?arch=ppc64le&epoch=2", diff --git a/sbom/examples/rpm/release/vim-9.1.083-5.el10.spdx.json b/sbom/examples/rpm/release/vim-9.1.083-5.el10.spdx.json index 62a0f26..b17a9f4 100644 --- a/sbom/examples/rpm/release/vim-9.1.083-5.el10.spdx.json +++ b/sbom/examples/rpm/release/vim-9.1.083-5.el10.spdx.json @@ -138,6 +138,21 @@ } ] }, + { + "SPDXID": "SPDXRef-Bundled-11cdd6f19dc1", + "name": "libvterm (generic)", + "versionInfo": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "primaryPackagePurpose": "LIBRARY", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:generic/libvterm" + } + ] + }, { "SPDXID": "SPDXRef-aarch64-vim-X11-debuginfo", "name": "vim-X11-debuginfo", @@ -2266,6 +2281,11 @@ "relationshipType": "CONTAINS", "relatedSpdxElement": "SPDXRef-Source0" }, + { + "spdxElementId": "SPDXRef-Bundled-11cdd6f19dc1", + "relationshipType": "DEPENDENCY_OF", + "relatedSpdxElement": "SPDXRef-SRPM" + }, { "spdxElementId": "SPDXRef-aarch64-vim-X11-debuginfo", "relationshipType": "GENERATED_FROM",