* [RFC PATCH 0/1] SBOM Generation for isar
@ 2025-02-20 9:59 'Felix Moessbauer' via isar-users
2025-02-20 9:59 ` [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation 'Felix Moessbauer' via isar-users
2025-07-31 5:38 ` [RFC PATCH 0/1] SBOM Generation for isar Syeda Shagufta Naaz
0 siblings, 2 replies; 15+ messages in thread
From: 'Felix Moessbauer' via isar-users @ 2025-02-20 9:59 UTC (permalink / raw)
To: isar-users; +Cc: jan.kiszka, gernot.hillier, Christoph Steiger
From: Christoph Steiger <christoph.steiger@siemens.com>
This patch would add SBOM generation support for isar.
We already generate a manifest as part of the do_rootfs task which is
used by some people internally at Siemens to create SBOMs, but it has
a proprietary format and is not documented. It also has become apparent
that more information than in the manifest is required.
To create the SBOMs we parse the dpkg status file in a given image and
have some python scripts to build a valid SBOM for the two standard
formats (CycloneDX and SPDX).
The python scripts are a very minimal implementation to generate SBOMs,
as all other tools have heavier dependencies that are not packaged in
debian. As we also require only a small subset of these libraries (we
only generate a specific version and format, using also only a small
part of the data structures) I chose to quickly implement this myself.
The current implementation also emits source package information in the
SPDX format. Unfortunately the CDX standard does not allow to map the
relationship between a debian source and binary package in a
satisfactory way, so I omitted it for now. There is talks internally
about how to represent this relationship, but it is probably a good idea
to leave it empty for now.
TODOs/next steps:
- license/copyright parsing: debian has no machine-readable format for
these, but they are very valuable for clearing purposes
- tigther bitbake integration: if we hook into each recipe we could add
more information and correctly represent vendor packages
Please tell me what you think and how we could land SBOM generation
here :-)
Christoph Steiger (1):
meta: add CycloneDX/SPDX SBOM generation
meta/classes/create-sbom.bbclass | 49 ++++
meta/classes/image.bbclass | 2 +
meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++
meta/lib/sbom_cdx_types.py | 82 ++++++
meta/lib/sbom_spdx_types.py | 95 +++++++
5 files changed, 674 insertions(+)
create mode 100644 meta/classes/create-sbom.bbclass
create mode 100644 meta/lib/sbom.py
create mode 100644 meta/lib/sbom_cdx_types.py
create mode 100644 meta/lib/sbom_spdx_types.py
--
2.39.5
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/20250220095944.114203-1-felix.moessbauer%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
* [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-02-20 9:59 [RFC PATCH 0/1] SBOM Generation for isar 'Felix Moessbauer' via isar-users
@ 2025-02-20 9:59 ` 'Felix Moessbauer' via isar-users
2025-02-20 18:58 ` 'Gernot Hillier' via isar-users
` (4 more replies)
2025-07-31 5:38 ` [RFC PATCH 0/1] SBOM Generation for isar Syeda Shagufta Naaz
1 sibling, 5 replies; 15+ messages in thread
From: 'Felix Moessbauer' via isar-users @ 2025-02-20 9:59 UTC (permalink / raw)
To: isar-users; +Cc: jan.kiszka, gernot.hillier, Christoph Steiger
From: Christoph Steiger <christoph.steiger@siemens.com>
Add a new class to allow generation of software bill of materials
(SBOM). Supported are the two standard SBOM formats CycloneDX and SPDX.
SBOM generation is enabled per default for all images.
Both formats support the minimal usecase of binary packages information
and their dependencies. Unfortunately there is no proper way to express
the relationships of debian source packages and their corresponding
binary packages in the CDX format, so it is left out there.
The information included in the SBOM is parsed from the dpkg status
file found in the created image.
Signed-off-by: Christoph Steiger <christoph.steiger@siemens.com>
---
meta/classes/create-sbom.bbclass | 49 ++++
meta/classes/image.bbclass | 2 +
meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++
meta/lib/sbom_cdx_types.py | 82 ++++++
meta/lib/sbom_spdx_types.py | 95 +++++++
5 files changed, 674 insertions(+)
create mode 100644 meta/classes/create-sbom.bbclass
create mode 100644 meta/lib/sbom.py
create mode 100644 meta/lib/sbom_cdx_types.py
create mode 100644 meta/lib/sbom_spdx_types.py
diff --git a/meta/classes/create-sbom.bbclass b/meta/classes/create-sbom.bbclass
new file mode 100644
index 00000000..8c647699
--- /dev/null
+++ b/meta/classes/create-sbom.bbclass
@@ -0,0 +1,49 @@
+# This software is a part of ISAR.
+# Copyright (C) 2025 Siemens AG
+#
+# SPDX-License-Identifier: MIT
+
+# sbom type to generate, accepted are "cyclonedx" and "spdx"
+SBOM_TYPE ?= "cyclonedx spdx"
+
+# general user variables
+SBOM_DISTRO_SUPPLIER ?= "ISAR"
+SBOM_DISTRO_NAME ?= "ISAR-Debian-GNU-Linux"
+SBOM_DISTRO_VERSION ?= "1.0.0"
+SBOM_DISTRO_SUMMARY ?= "Linux distribution built with ISAR"
+SBOM_DOCUMENT_UUID ?= ""
+
+# SPDX specific user variables
+SBOM_SPDX_NAMESPACE_PREFIX ?= "https://spdx.org/spdxdocs"
+
+SBOM_DEPLOY_BASE = "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
+
+SBOM_GEN_VERSION = "0.1.0"
+
+# adapted from the isar-cip-core image_uuid.bbclass
+def generate_document_uuid(d):
+ import uuid
+
+ base_hash = d.getVar("BB_TASKHASH")
+ if base_hash is None:
+ bb.warn("no BB_TASKHASH available, SBOM UUID is not reproducible")
+ return uuid.uuid4()
+ return str(uuid.UUID(base_hash[:32], version=4))
+
+python do_create_sbom() {
+ import sbom
+
+ dpkg_status = d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"
+ packages = sbom.Package.parse_status_file(dpkg_status)
+
+ if not d.getVar("SBOM_DOCUMENT_UUID"):
+ d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))
+
+ sbom_type = d.getVar("SBOM_TYPE")
+ if "cyclonedx" in sbom_type:
+ sbom.generate(d, packages, sbom.SBOMType.CycloneDX, d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
+ if "spdx" in sbom_type:
+ sbom.generate(d, packages, sbom.SBOMType.SPDX, d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")
+}
+
+addtask do_create_sbom after do_rootfs before do_build
diff --git a/meta/classes/image.bbclass b/meta/classes/image.bbclass
index 56eca202..e9da6a61 100644
--- a/meta/classes/image.bbclass
+++ b/meta/classes/image.bbclass
@@ -81,6 +81,8 @@ inherit image-postproc-extension
inherit image-locales-extension
inherit image-account-extension
+inherit create-sbom
+
# Extra space for rootfs in MB
ROOTFS_EXTRA ?= "64"
diff --git a/meta/lib/sbom.py b/meta/lib/sbom.py
new file mode 100644
index 00000000..d7c79e43
--- /dev/null
+++ b/meta/lib/sbom.py
@@ -0,0 +1,446 @@
+# This software is part of ISAR.
+# Copyright (C) 2025 Siemens AG
+#
+# SPDX-License-Identifier: MIT
+
+from dataclasses import dataclass
+from datetime import datetime
+from enum import Enum
+from typing import Dict, List, Type
+import json
+import re
+from uuid import uuid4
+
+import sbom_cdx_types as cdx
+import sbom_spdx_types as spdx
+
+
+class SBOMType(Enum):
+ CycloneDX = (0,)
+ SPDX = (1,)
+
+
+@dataclass
+class SourcePackage:
+ name: str
+ version: str | None
+
+ def purl(self):
+ """Return the PURL of the package."""
+ return "pkg:deb/debian/{}@{}?arch=source".format(self.name, self.version)
+
+ def bom_ref(self, sbom_type: SBOMType) -> str:
+ """Return a unique BOM reference."""
+ if sbom_type == SBOMType.CycloneDX:
+ return cdx.CDXREF_PREFIX + "{}-src".format(self.name)
+ elif sbom_type == SBOMType.SPDX:
+ return spdx.SPDX_REF_PREFIX + "{}-src".format(self.name)
+
+ def parse(s: str) -> Type["SourcePackage"]:
+ split = s.split(" ")
+ name = split[0]
+ try:
+ version = " ".join(split[1:]).strip("()")
+ except IndexError:
+ version = None
+
+ return SourcePackage(name=name, version=version)
+
+
+@dataclass
+class Dependency:
+ name: str
+ version: str | None
+
+ def bom_ref(self, sbom_type: SBOMType) -> str:
+ """Return a unique BOM reference."""
+ if sbom_type == SBOMType.CycloneDX:
+ return cdx.CDX_REF_PREFIX + "{}".format(self.name)
+ elif sbom_type == SBOMType.SPDX:
+ return spdx.SPDX_REF_PREFIX + "{}".format(self.name)
+
+ def parse_multiple(s: str) -> List[Type["Dependency"]]:
+ """Parse a 'Depends' line in the dpkg status file."""
+ dependencies = []
+ for entry in s.split(","):
+ entry = entry.strip()
+ for entry in entry.split("|"):
+ split = entry.split("(")
+ name = split[0].strip()
+ try:
+ version = split[1].strip(")")
+ except IndexError:
+ version = None
+ dependencies.append(Dependency(name=name, version=version))
+
+ return dependencies
+
+
+@dataclass
+class Package:
+ """Incomplete representation of a debian package."""
+
+ name: str
+ section: str
+ maintainer: str
+ architecture: str
+ source: SourcePackage
+ version: str
+ depends: List[Dependency]
+ description: str
+ homepage: str
+
+ def purl(self) -> str:
+ """Return the PURL of the package."""
+ purl = "pkg:deb/debian/{}@{}".format(self.name, self.version)
+ if self.architecture:
+ purl = purl + "?arch={}".format(self.architecture)
+ return purl
+
+ def bom_ref(self, sbom_type: SBOMType) -> str:
+ """Return a unique BOM reference."""
+ if sbom_type == SBOMType.CycloneDX:
+ return cdx.CDX_REF_PREFIX + self.name
+ elif sbom_type == SBOMType.SPDX:
+ return spdx.SPDX_REF_PREFIX + self.name
+
+ def parse_status_file(status_file: str) -> List[Type["Package"]]:
+ """Parse a dpkg status file."""
+ packages = []
+ with open(status_file, "r") as f:
+ name = None
+ section = None
+ maintainer = None
+ architecture = None
+ source = None
+ version = None
+ dependencies = None
+ description = None
+ homepage = None
+ for line in f.readlines():
+ if line.strip():
+ if line[0] == " ":
+ # this is a description line, we ignore it
+ continue
+ else:
+ split = line.split(":")
+ key = split[0]
+ value = ":".join(split[1:]).strip()
+ if key == "Package":
+ name = value
+ elif key == "Section":
+ section = value
+ elif key == "Maintainer":
+ maintainer = value
+ elif key == "Architecture":
+ architecture = value
+ elif key == "Source":
+ source = SourcePackage.parse(value)
+ elif key == "Version":
+ version = value
+ elif key == "Depends":
+ dependencies = Dependency.parse_multiple(value)
+ elif key == "Description":
+ description = value
+ elif key == "Homepage":
+ homepage = value
+ else:
+ # fixup source version, if not specified it is the same
+ # as the package version
+ if source and not source.version:
+ source.version = version
+ # empty line means new package, so finish the current one
+ packages.append(
+ Package(
+ name=name,
+ section=section,
+ maintainer=maintainer,
+ architecture=architecture,
+ source=source,
+ version=version,
+ depends=dependencies,
+ description=description,
+ homepage=homepage,
+ )
+ )
+ name = None
+ section = None
+ maintainer = None
+ architecture = None
+ source = None
+ version = None
+ dependencies = None
+ description = None
+ homepage = None
+
+ return packages
+
+
+def cyclonedx_bom(d, packages: List[Package]) -> Dict:
+ """Return a valid CycloneDX SBOM."""
+ data = []
+ dependencies = []
+
+ pattern = re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
+ for package in packages:
+ match = pattern.match(package.maintainer)
+ supplier = cdx.CDXSupplier(name=match["supplier_name"])
+ supplier_email = match["supplier_email"]
+ if supplier_email:
+ supplier.contact = [cdx.CDXSupplierContact(email=supplier_email)]
+ entry = cdx.CDXComponent(
+ type=cdx.CDX_COMPONENT_TYPE_LIBRARY,
+ bom_ref=package.bom_ref(SBOMType.CycloneDX),
+ supplier=supplier,
+ name=package.name,
+ version=package.version,
+ description=package.description,
+ purl=package.purl(),
+ )
+ if package.homepage:
+ entry.externalReferences = (
+ cdx.CDXExternalReference(
+ url=package.homepage,
+ type=cdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,
+ comment="homepage",
+ ),
+ )
+ data.append(entry)
+
+ distro_bom_ref = cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
+ distro_dependencies = []
+ # after we have found all packages we can start to resolve dependencies
+ package_names = [package.name for package in packages]
+ for package in packages:
+ distro_dependencies.append(package.bom_ref(SBOMType.CycloneDX))
+ if package.depends:
+ deps = []
+ for dep in package.depends:
+ dep_bom_ref = dep.bom_ref(SBOMType.CycloneDX)
+ # it is possibe to specify the same package multiple times, but
+ # in different versions
+ if dep.name in package_names and dep_bom_ref not in deps:
+ deps.append(dep_bom_ref)
+ else:
+ # this might happen if we have optional dependencies
+ continue
+ dependency = cdx.CDXDependency(
+ ref=package.bom_ref(SBOMType.CycloneDX),
+ dependsOn=deps,
+ )
+ dependencies.append(dependency)
+ dependency = cdx.CDXDependency(
+ ref=distro_bom_ref,
+ dependsOn=distro_dependencies,
+ )
+ dependencies.append(dependency)
+
+ doc_uuid = d.getVar("SBOM_DOCUMENT_UUID")
+ distro_component = cdx.CDXComponent(
+ type=cdx.CDX_COMPONENT_TYPE_OS,
+ bom_ref=cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME"),
+ supplier=cdx.CDXSupplier(name=d.getVar("SBOM_DISTRO_SUPPLIER")),
+ name=d.getVar("SBOM_DISTRO_NAME"),
+ version=d.getVar("SBOM_DISTRO_VERSION"),
+ description=d.getVar("SBOM_DISTRO_SUMMARY"),
+ )
+
+ timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
+ bom = cdx.CDXBOM(
+ bomFormat=cdx.CDX_BOM_FORMAT,
+ specVersion=cdx.CDX_SPEC_VERSION,
+ serialNumber="urn:uuid:{}".format(doc_uuid if doc_uuid else uuid4()),
+ version=1,
+ metadata=cdx.CDXBOMMetadata(
+ timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ component=distro_component,
+ tools=cdx.CDXBOMMetadataTool(
+ components=[
+ cdx.CDXComponent(
+ type=cdx.CDX_COMPONENT_TYPE_APPLICATION,
+ name="ISAR SBOM Generator",
+ version=d.getVar("SBOM_GEN_VERSION"),
+ )
+ ],
+ ),
+ ),
+ components=data,
+ dependencies=dependencies,
+ )
+ return bom
+
+
+def spdx_bom(d, packages: List[Package]) -> Dict:
+ "Return a valid SPDX SBOM."
+
+ data = []
+ # create a "fake" entry for the distribution
+ distro_ref = spdx.SPDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
+ distro_package = spdx.SPDXPackage(
+ SPDXID=distro_ref,
+ name=d.getVar("SBOM_DISTRO_NAME"),
+ versionInfo=d.getVar("SBOM_DISTRO_VERSION"),
+ primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_OS,
+ supplier="Organization: {}".format(d.getVar("SBOM_DISTRO_SUPPLIER")),
+ downloadLocation=spdx.SPDX_NOASSERTION,
+ filesAnalyzed=False,
+ licenseConcluded=spdx.SPDX_NOASSERTION,
+ licenseDeclared=spdx.SPDX_NOASSERTION,
+ copyrightText=spdx.SPDX_NOASSERTION,
+ summary=d.getVar("SBOM_DISTRO_SUMMARY"),
+ )
+
+ data.append(distro_package)
+
+ pattern = re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
+ for package in packages:
+ match = pattern.match(package.maintainer)
+ supplier_name = match["supplier_name"]
+ supplier_email = match["supplier_email"]
+ if any([cue in supplier_name.lower() for cue in spdx.SPDX_SUPPLIER_ORG_CUE]):
+ supplier = "Organization: {}".format(supplier_name)
+ else:
+ supplier = "Person: {}".format(supplier_name)
+ if supplier_email:
+ supplier += "({})".format(supplier_email)
+
+ entry = spdx.SPDXPackage(
+ SPDXID=package.bom_ref(SBOMType.SPDX),
+ name=package.name,
+ versionInfo=package.version,
+ primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_LIBRARY,
+ supplier=supplier,
+ downloadLocation=spdx.SPDX_NOASSERTION,
+ filesAnalyzed=False,
+ # TODO: it should be possible to conclude license/copyright
+ # information, we could look e.g. in /usr/share/doc/*/copyright
+ licenseConcluded=spdx.SPDX_NOASSERTION,
+ licenseDeclared=spdx.SPDX_NOASSERTION,
+ copyrightText=spdx.SPDX_NOASSERTION,
+ summary=package.description,
+ externalRefs=[
+ spdx.SPDXExternalRef(
+ referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
+ referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
+ referenceLocator=package.purl(),
+ )
+ ],
+ )
+ if package.homepage:
+ entry.homepage = package.homepage
+ data.append(entry)
+
+ if package.source:
+ src_entry = spdx.SPDXPackage(
+ SPDXID=package.source.bom_ref(SBOMType.SPDX),
+ name=package.source.name,
+ versionInfo=package.source.version,
+ primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_SRC,
+ supplier=supplier,
+ downloadLocation=spdx.SPDX_NOASSERTION,
+ filesAnalyzed=False,
+ licenseConcluded=spdx.SPDX_NOASSERTION,
+ licenseDeclared=spdx.SPDX_NOASSERTION,
+ copyrightText=spdx.SPDX_NOASSERTION,
+ summary="debian source code package '{}'".format(package.source.name),
+ externalRefs=[
+ spdx.SPDXExternalRef(
+ referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
+ referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
+ referenceLocator=package.source.purl(),
+ )
+ ],
+ )
+ # source packages might be referenced multiple times
+ if src_entry not in data:
+ data.append(src_entry)
+
+ relationships = []
+ # after we have found all packages we can start to resolve dependencies
+ package_names = [package.name for package in packages]
+ for package in packages:
+ relationships.append(
+ spdx.SPDXRelationship(
+ spdxElementId=package.bom_ref(SBOMType.SPDX),
+ relatedSpdxElement=distro_ref,
+ relationshipType=spdx.SPDX_RELATIONSHIP_PACKAGE_OF,
+ )
+ )
+ if package.depends:
+ for dep in package.depends:
+ if dep.name in package_names:
+ relationship = spdx.SPDXRelationship(
+ spdxElementId=package.bom_ref(SBOMType.SPDX),
+ relatedSpdxElement=dep.bom_ref(SBOMType.SPDX),
+ relationshipType=spdx.SPDX_RELATIONSHIP_DEPENDS_ON,
+ )
+ relationships.append(relationship)
+ else:
+ # this might happen if we have optional dependencies
+ pass
+ if package.source:
+ relationship = spdx.SPDXRelationship(
+ spdxElementId=package.source.bom_ref(SBOMType.SPDX),
+ relatedSpdxElement=package.bom_ref(SBOMType.SPDX),
+ relationshipType=spdx.SPDX_RELATIONSHIP_GENERATES,
+ )
+ relationships.append(relationship)
+ relationships.append(
+ spdx.SPDXRelationship(
+ spdxElementId=spdx.SPDX_REF_DOCUMENT,
+ relatedSpdxElement=distro_ref,
+ relationshipType=spdx.SPDX_RELATIONSHIP_DESCRIBES,
+ )
+ )
+
+ namespace_uuid = d.getVar("SBOM_DOCUMENT_UUID")
+ timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
+ bom = spdx.SPDXBOM(
+ SPDXID=spdx.SPDX_REF_DOCUMENT,
+ spdxVersion=spdx.SPDX_VERSION,
+ creationInfo=spdx.SPDXCreationInfo(
+ comment="This document has been generated as part of an ISAR build.",
+ creators=[
+ "Tool: ISAR SBOM Generator - {}".format(d.getVar("SBOM_GEN_VERSION"))
+ ],
+ created=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ ),
+ name=d.getVar("SBOM_DISTRO_NAME"),
+ dataLicense="CC0-1.0",
+ documentNamespace="{}/{}-{}".format(
+ d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),
+ d.getVar("SBOM_DISTRO_NAME"),
+ namespace_uuid if namespace_uuid else uuid4(),
+ ),
+ packages=data,
+ relationships=relationships,
+ )
+ return bom
+
+
+def fixup_dict(o):
+ """Apply fixups for the BOMs.
+
+ This is necessary for some field names and to remove fields with a None
+ value.
+ """
+ dct = vars(o)
+ new_dct = {}
+ for k, v in dct.items():
+ # remove fields with no content
+ if v is not None:
+ # we can not name our fields with dashes, so convert them
+ k = k.replace("_", "-")
+ new_dct[k] = v
+ return new_dct
+
+
+def generate(d, packages: List[Package], sbom_type: SBOMType, out: str):
+ """Generate a SBOM."""
+ if sbom_type == SBOMType.CycloneDX:
+ bom = cyclonedx_bom(d, packages)
+ elif sbom_type == SBOMType.SPDX:
+ bom = spdx_bom(d, packages)
+
+ with open(out, "w") as bom_file:
+ json.dump(bom, bom_file, indent=2, default=fixup_dict, sort_keys=True)
diff --git a/meta/lib/sbom_cdx_types.py b/meta/lib/sbom_cdx_types.py
new file mode 100644
index 00000000..4911cc23
--- /dev/null
+++ b/meta/lib/sbom_cdx_types.py
@@ -0,0 +1,82 @@
+# This software is part of ISAR.
+# Copyright (C) 2025 Siemens AG
+#
+# SPDX-License-Identifier: MIT
+
+from dataclasses import dataclass
+from typing import List, Optional
+
+# Minimal implementation of some CycloneDX SBOM types.
+# Please mind that (almost) none of these types are complete, they only
+# reflect what was strictly necessary for immediate SBOM creation
+
+CDX_BOM_FORMAT = "CycloneDX"
+CDX_SPEC_VERSION = "1.6"
+
+CDX_REF_PREFIX = "CDXRef-"
+
+CDX_PACKAGE_EXTREF_TYPE_WEBSITE = "website"
+
+CDX_COMPONENT_TYPE_LIBRARY = "library"
+CDX_COMPONENT_TYPE_APPLICATION = "application"
+CDX_COMPONENT_TYPE_OS = "operating-system"
+
+
+@dataclass
+class CDXDependency:
+ ref: str
+ dependsOn: Optional[str]
+
+
+@dataclass
+class CDXExternalReference:
+ url: str
+ type: str
+ comment: Optional[str] = None
+
+
+@dataclass
+class CDXSupplierContact:
+ email: Optional[str] = None
+
+
+@dataclass
+class CDXSupplier:
+ name: Optional[str] = None
+ contact: Optional[CDXSupplierContact] = None
+
+
+@dataclass
+class CDXComponent:
+ type: str
+ name: str
+ bom_ref: Optional[str] = None
+ supplier: Optional[str] = None
+ version: Optional[CDXSupplier] = None
+ description: Optional[str] = None
+ purl: Optional[str] = None
+ externalReferences: Optional[List[CDXExternalReference]] = None
+ homepage: Optional[str] = None
+
+
+@dataclass
+class CDXBOMMetadataTool:
+ components: Optional[List[CDXComponent]]
+
+
+@dataclass
+class CDXBOMMetadata:
+ timestamp: Optional[str] = None
+ component: Optional[str] = None
+ tools: Optional[List[CDXBOMMetadataTool]] = None
+
+
+@dataclass
+class CDXBOM:
+ bomFormat: str
+ specVersion: str
+ serialNumber: Optional[str] = None
+ version: Optional[str] = None
+ metadata: Optional[CDXBOMMetadata] = None
+ components: Optional[List[CDXComponent]] = None
+ dependencies: Optional[List[CDXDependency]] = None
diff --git a/meta/lib/sbom_spdx_types.py b/meta/lib/sbom_spdx_types.py
new file mode 100644
index 00000000..efd7cc0c
--- /dev/null
+++ b/meta/lib/sbom_spdx_types.py
@@ -0,0 +1,95 @@
+# This software is part of ISAR.
+# Copyright (C) 2025 Siemens AG
+#
+# SPDX-License-Identifier: MIT
+
+from dataclasses import dataclass
+from typing import List, Optional
+
+# Minimal implementation of some SPDX SBOM types.
+# Please mind that (almost) none of these types are complete, they only
+# reflect what was strictly necessary for immediate SBOM creation
+
+SPDX_VERSION = "SPDX-2.3"
+
+SPDX_REF_PREFIX = "SPDXRef-"
+
+SPDX_REF_DOCUMENT = "SPDXRef-DOCUMENT"
+
+SPDX_PACKAGE_PURPOSE_LIBRARY = "LIBRARY"
+SPDX_PACKAGE_PURPOSE_OS = "OPERATING_SYSTEM"
+SPDX_PACKAGE_PURPOSE_SRC = "SOURCE"
+
+SPDX_NOASSERTION = "NOASSERTION"
+
+SPDX_RELATIONSHIP_DEPENDS_ON = "DEPENDS_ON"
+SPDX_RELATIONSHIP_PACKAGE_OF = "PACKAGE_OF"
+SPDX_RELATIONSHIP_GENERATES = "GENERATES"
+SPDX_RELATIONSHIP_DESCRIBES = "DESCRIBES"
+
+SPDX_REFERENCE_CATEGORY_PKG_MANAGER = "PACKAGE_MANAGER"
+SPDX_REFERENCE_TYPE_PURL = "purl"
+
+# cues for an organization in the maintainer name
+SPDX_SUPPLIER_ORG_CUE = [
+ "maintainers",
+ "group",
+ "developers",
+ "team",
+ "project",
+ "task force",
+ "strike force",
+ "packagers",
+]
+
+
+@dataclass
+class SPDXRelationship:
+ spdxElementId: str
+ relatedSpdxElement: str
+ relationshipType: str
+
+
+@dataclass
+class SPDXExternalRef:
+ referenceCategory: str
+ referenceType: str
+ referenceLocator: str
+
+
+@dataclass
+class SPDXPackage:
+ SPDXID: str
+ name: str
+ downloadLocation: str
+ filesAnalyzed: Optional[bool] = False
+ versionInfo: Optional[str] = None
+ homepage: Optional[str] = None
+ primaryPackagePurpose: Optional[str] = None
+ supplier: Optional[str] = None
+ licenseConcluded: Optional[str] = None
+ licenseDeclared: Optional[str] = None
+ copyrightText: Optional[str] = None
+ summary: Optional[str] = None
+ externalRefs: Optional[List[SPDXExternalRef]] = None
+
+
+@dataclass
+class SPDXCreationInfo:
+ created: str
+ comment: Optional[str] = None
+ creators: List[str] = None
+
+
+@dataclass
+class SPDXBOM:
+ """Incomplete BOM as of SPDX spec v2.3."""
+
+ SPDXID: str
+ spdxVersion: str
+ creationInfo: SPDXCreationInfo
+ name: str
+ dataLicense: str
+ documentNamespace: str
+ packages: List[SPDXPackage]
+ relationships: List[SPDXRelationship]
--
2.39.5
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/20250220095944.114203-2-felix.moessbauer%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-02-20 9:59 ` [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation 'Felix Moessbauer' via isar-users
@ 2025-02-20 18:58 ` 'Gernot Hillier' via isar-users
2025-03-04 11:54 ` 'Niedermayr, BENEDIKT' via isar-users
` (3 subsequent siblings)
4 siblings, 0 replies; 15+ messages in thread
From: 'Gernot Hillier' via isar-users @ 2025-02-20 18:58 UTC (permalink / raw)
To: Felix Moessbauer, isar-users, Christoph Steiger; +Cc: jan.kiszka
On 20.02.25 10:59, Felix Moessbauer wrote:
> + if "cyclonedx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.CycloneDX, d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
According to https://cyclonedx.org/specification/overview/, "Recognized
file patterns", file extension should be .cdx.json.
--
Gernot
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/5a7883cb-9089-4e7e-89c3-57ad92de04db%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-02-20 9:59 ` [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation 'Felix Moessbauer' via isar-users
2025-02-20 18:58 ` 'Gernot Hillier' via isar-users
@ 2025-03-04 11:54 ` 'Niedermayr, BENEDIKT' via isar-users
2025-03-04 12:12 ` 'Christoph Steiger' via isar-users
2025-06-10 12:03 ` 'Christoph Steiger' via isar-users
` (2 subsequent siblings)
4 siblings, 1 reply; 15+ messages in thread
From: 'Niedermayr, BENEDIKT' via isar-users @ 2025-03-04 11:54 UTC (permalink / raw)
To: MOESSBAUER, Felix, isar-users
Cc: Kiszka, Jan, Hillier, Gernot, Steiger, Christoph
On 20.02.25 10:59, 'Felix Moessbauer' via isar-users wrote:
> From: Christoph Steiger <christoph.steiger@siemens.com>
>
> Add a new class to allow generation of software bill of materials
> (SBOM). Supported are the two standard SBOM formats CycloneDX and SPDX.
> SBOM generation is enabled per default for all images.
>
> Both formats support the minimal usecase of binary packages information
> and their dependencies. Unfortunately there is no proper way to express
> the relationships of debian source packages and their corresponding
> binary packages in the CDX format, so it is left out there.
>
> The information included in the SBOM is parsed from the dpkg status
> file found in the created image.
AFAIK you're using the cyclonedx-python-lib [1], right?
This package is not packaged by debian, yet and the python package needs
to be run in python-virtualenv.
Do you have any plans to support this?
[1] https://pypi.org/project/cyclonedx-python-lib/
>
> Signed-off-by: Christoph Steiger <christoph.steiger@siemens.com>
> ---
> meta/classes/create-sbom.bbclass | 49 ++++
> meta/classes/image.bbclass | 2 +
> meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++
> meta/lib/sbom_cdx_types.py | 82 ++++++
> meta/lib/sbom_spdx_types.py | 95 +++++++
> 5 files changed, 674 insertions(+)
> create mode 100644 meta/classes/create-sbom.bbclass
> create mode 100644 meta/lib/sbom.py
> create mode 100644 meta/lib/sbom_cdx_types.py
> create mode 100644 meta/lib/sbom_spdx_types.py
>
> diff --git a/meta/classes/create-sbom.bbclass b/meta/classes/create-sbom.bbclass
> new file mode 100644
> index 00000000..8c647699
> --- /dev/null
> +++ b/meta/classes/create-sbom.bbclass
> @@ -0,0 +1,49 @@
> +# This software is a part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +# sbom type to generate, accepted are "cyclonedx" and "spdx"
> +SBOM_TYPE ?= "cyclonedx spdx"
> +
> +# general user variables
> +SBOM_DISTRO_SUPPLIER ?= "ISAR"
> +SBOM_DISTRO_NAME ?= "ISAR-Debian-GNU-Linux"
> +SBOM_DISTRO_VERSION ?= "1.0.0"
> +SBOM_DISTRO_SUMMARY ?= "Linux distribution built with ISAR"
> +SBOM_DOCUMENT_UUID ?= ""
> +
> +# SPDX specific user variables
> +SBOM_SPDX_NAMESPACE_PREFIX ?= "https://spdx.org/spdxdocs"
> +
> +SBOM_DEPLOY_BASE = "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
> +
> +SBOM_GEN_VERSION = "0.1.0"
> +
> +# adapted from the isar-cip-core image_uuid.bbclass
> +def generate_document_uuid(d):
> + import uuid
> +
> + base_hash = d.getVar("BB_TASKHASH")
> + if base_hash is None:
> + bb.warn("no BB_TASKHASH available, SBOM UUID is not reproducible")
> + return uuid.uuid4()
> + return str(uuid.UUID(base_hash[:32], version=4))
> +
> +python do_create_sbom() {
> + import sbom
> +
> + dpkg_status = d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"
> + packages = sbom.Package.parse_status_file(dpkg_status)
> +
> + if not d.getVar("SBOM_DOCUMENT_UUID"):
> + d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))
> +
> + sbom_type = d.getVar("SBOM_TYPE")
> + if "cyclonedx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.CycloneDX, d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
> + if "spdx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.SPDX, d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")
> +}
Maybe a variable for enabling/disabling this feature would be nice.
(IMAGE_FEATURES, DISTRO_FEATURES).
> +
> +addtask do_create_sbom after do_rootfs before do_build
> diff --git a/meta/classes/image.bbclass b/meta/classes/image.bbclass
> index 56eca202..e9da6a61 100644
> --- a/meta/classes/image.bbclass
> +++ b/meta/classes/image.bbclass
> @@ -81,6 +81,8 @@ inherit image-postproc-extension
> inherit image-locales-extension
> inherit image-account-extension
>
> +inherit create-sbom
> +
> # Extra space for rootfs in MB
> ROOTFS_EXTRA ?= "64"
>
> diff --git a/meta/lib/sbom.py b/meta/lib/sbom.py
> new file mode 100644
> index 00000000..d7c79e43
> --- /dev/null
> +++ b/meta/lib/sbom.py
> @@ -0,0 +1,446 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from datetime import datetime
> +from enum import Enum
> +from typing import Dict, List, Type
> +import json
> +import re
> +from uuid import uuid4
> +
> +import sbom_cdx_types as cdx
> +import sbom_spdx_types as spdx
> +
> +
> +class SBOMType(Enum):
> + CycloneDX = (0,)
> + SPDX = (1,)
> +
> +
> +@dataclass
> +class SourcePackage:
> + name: str
> + version: str | None
> +
> + def purl(self):
> + """Return the PURL of the package."""
> + return "pkg:deb/debian/{}@{}?arch=source".format(self.name, self.version)
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDXREF_PREFIX + "{}-src".format(self.name)
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}-src".format(self.name)
> +
> + def parse(s: str) -> Type["SourcePackage"]:
Using "@classmethod" for this factory method would be more idiomatic.
> + split = s.split(" ")
> + name = split[0]
> + try:
> + version = " ".join(split[1:]).strip("()")
> + except IndexError:
> + version = None
> +
> + return SourcePackage(name=name, version=version)
> +
> +
> +@dataclass
> +class Dependency:
> + name: str
> + version: str | None
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + "{}".format(self.name)
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}".format(self.name)
> +
> + def parse_multiple(s: str) -> List[Type["Dependency"]]:
... Using "@classmethod" for this factory method would be more idiomatic.
> + """Parse a 'Depends' line in the dpkg status file."""
> + dependencies = []
> + for entry in s.split(","):
> + entry = entry.strip()
> + for entry in entry.split("|"):
> + split = entry.split("(")
> + name = split[0].strip()
> + try:
> + version = split[1].strip(")")
> + except IndexError:
> + version = None
> + dependencies.append(Dependency(name=name, version=version))
> +
> + return dependencies
> +
> +
> +@dataclass
> +class Package:
> + """Incomplete representation of a debian package."""
> +
> + name: str
> + section: str
> + maintainer: str
> + architecture: str
> + source: SourcePackage
> + version: str
> + depends: List[Dependency]
> + description: str
> + homepage: str
> +
> + def purl(self) -> str:
> + """Return the PURL of the package."""
> + purl = "pkg:deb/debian/{}@{}".format(self.name, self.version)
> + if self.architecture:
> + purl = purl + "?arch={}".format(self.architecture)
> + return purl
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + self.name
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + self.name
> +
> + def parse_status_file(status_file: str) -> List[Type["Package"]]:
...Using "@classmethod" for this factory method would be more idiomatic.
Regards,
Benedikt
> + """Parse a dpkg status file."""
> + packages = []
> + with open(status_file, "r") as f:
> + name = None
> + section = None
> + maintainer = None
> + architecture = None
> + source = None
> + version = None
> + dependencies = None
> + description = None
> + homepage = None
> + for line in f.readlines():
> + if line.strip():
> + if line[0] == " ":
> + # this is a description line, we ignore it
> + continue
> + else:
> + split = line.split(":")
> + key = split[0]
> + value = ":".join(split[1:]).strip()
> + if key == "Package":
> + name = value
> + elif key == "Section":
> + section = value
> + elif key == "Maintainer":
> + maintainer = value
> + elif key == "Architecture":
> + architecture = value
> + elif key == "Source":
> + source = SourcePackage.parse(value)
> + elif key == "Version":
> + version = value
> + elif key == "Depends":
> + dependencies = Dependency.parse_multiple(value)
> + elif key == "Description":
> + description = value
> + elif key == "Homepage":
> + homepage = value
> + else:
> + # fixup source version, if not specified it is the same
> + # as the package version
> + if source and not source.version:
> + source.version = version
> + # empty line means new package, so finish the current one
> + packages.append(
> + Package(
> + name=name,
> + section=section,
> + maintainer=maintainer,
> + architecture=architecture,
> + source=source,
> + version=version,
> + depends=dependencies,
> + description=description,
> + homepage=homepage,
> + )
> + )
> + name = None
> + section = None
> + maintainer = None
> + architecture = None
> + source = None
> + version = None
> + dependencies = None
> + description = None
> + homepage = None
> +
> + return packages
> +
> +
> +def cyclonedx_bom(d, packages: List[Package]) -> Dict:
> + """Return a valid CycloneDX SBOM."""
> + data = []
> + dependencies = []
> +
> + pattern = re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
> + for package in packages:
> + match = pattern.match(package.maintainer)
> + supplier = cdx.CDXSupplier(name=match["supplier_name"])
> + supplier_email = match["supplier_email"]
> + if supplier_email:
> + supplier.contact = [cdx.CDXSupplierContact(email=supplier_email)]
> + entry = cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_LIBRARY,
> + bom_ref=package.bom_ref(SBOMType.CycloneDX),
> + supplier=supplier,
> + name=package.name,
> + version=package.version,
> + description=package.description,
> + purl=package.purl(),
> + )
> + if package.homepage:
> + entry.externalReferences = (
> + cdx.CDXExternalReference(
> + url=package.homepage,
> + type=cdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,
> + comment="homepage",
> + ),
> + )
> + data.append(entry)
> +
> + distro_bom_ref = cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
> + distro_dependencies = []
> + # after we have found all packages we can start to resolve dependencies
> + package_names = [package.name for package in packages]
> + for package in packages:
> + distro_dependencies.append(package.bom_ref(SBOMType.CycloneDX))
> + if package.depends:
> + deps = []
> + for dep in package.depends:
> + dep_bom_ref = dep.bom_ref(SBOMType.CycloneDX)
> + # it is possibe to specify the same package multiple times, but
> + # in different versions
> + if dep.name in package_names and dep_bom_ref not in deps:
> + deps.append(dep_bom_ref)
> + else:
> + # this might happen if we have optional dependencies
> + continue
> + dependency = cdx.CDXDependency(
> + ref=package.bom_ref(SBOMType.CycloneDX),
> + dependsOn=deps,
> + )
> + dependencies.append(dependency)
> + dependency = cdx.CDXDependency(
> + ref=distro_bom_ref,
> + dependsOn=distro_dependencies,
> + )
> + dependencies.append(dependency)
> +
> + doc_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> + distro_component = cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_OS,
> + bom_ref=cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME"),
> + supplier=cdx.CDXSupplier(name=d.getVar("SBOM_DISTRO_SUPPLIER")),
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + version=d.getVar("SBOM_DISTRO_VERSION"),
> + description=d.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> + bom = cdx.CDXBOM(
> + bomFormat=cdx.CDX_BOM_FORMAT,
> + specVersion=cdx.CDX_SPEC_VERSION,
> + serialNumber="urn:uuid:{}".format(doc_uuid if doc_uuid else uuid4()),
> + version=1,
> + metadata=cdx.CDXBOMMetadata(
> + timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + component=distro_component,
> + tools=cdx.CDXBOMMetadataTool(
> + components=[
> + cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_APPLICATION,
> + name="ISAR SBOM Generator",
> + version=d.getVar("SBOM_GEN_VERSION"),
> + )
> + ],
> + ),
> + ),
> + components=data,
> + dependencies=dependencies,
> + )
> + return bom
> +
> +
> +def spdx_bom(d, packages: List[Package]) -> Dict:
> + "Return a valid SPDX SBOM."
> +
> + data = []
> + # create a "fake" entry for the distribution
> + distro_ref = spdx.SPDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
> + distro_package = spdx.SPDXPackage(
> + SPDXID=distro_ref,
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + versionInfo=d.getVar("SBOM_DISTRO_VERSION"),
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_OS,
> + supplier="Organization: {}".format(d.getVar("SBOM_DISTRO_SUPPLIER")),
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary=d.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + data.append(distro_package)
> +
> + pattern = re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
> + for package in packages:
> + match = pattern.match(package.maintainer)
> + supplier_name = match["supplier_name"]
> + supplier_email = match["supplier_email"]
> + if any([cue in supplier_name.lower() for cue in spdx.SPDX_SUPPLIER_ORG_CUE]):
> + supplier = "Organization: {}".format(supplier_name)
> + else:
> + supplier = "Person: {}".format(supplier_name)
> + if supplier_email:
> + supplier += "({})".format(supplier_email)
> +
> + entry = spdx.SPDXPackage(
> + SPDXID=package.bom_ref(SBOMType.SPDX),
> + name=package.name,
> + versionInfo=package.version,
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_LIBRARY,
> + supplier=supplier,
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + # TODO: it should be possible to conclude license/copyright
> + # information, we could look e.g. in /usr/share/doc/*/copyright
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary=package.description,
> + externalRefs=[
> + spdx.SPDXExternalRef(
> + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> + referenceLocator=package.purl(),
> + )
> + ],
> + )
> + if package.homepage:
> + entry.homepage = package.homepage
> + data.append(entry)
> +
> + if package.source:
> + src_entry = spdx.SPDXPackage(
> + SPDXID=package.source.bom_ref(SBOMType.SPDX),
> + name=package.source.name,
> + versionInfo=package.source.version,
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_SRC,
> + supplier=supplier,
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary="debian source code package '{}'".format(package.source.name),
> + externalRefs=[
> + spdx.SPDXExternalRef(
> + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> + referenceLocator=package.source.purl(),
> + )
> + ],
> + )
> + # source packages might be referenced multiple times
> + if src_entry not in data:
> + data.append(src_entry)
> +
> + relationships = []
> + # after we have found all packages we can start to resolve dependencies
> + package_names = [package.name for package in packages]
> + for package in packages:
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=package.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=distro_ref,
> + relationshipType=spdx.SPDX_RELATIONSHIP_PACKAGE_OF,
> + )
> + )
> + if package.depends:
> + for dep in package.depends:
> + if dep.name in package_names:
> + relationship = spdx.SPDXRelationship(
> + spdxElementId=package.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=dep.bom_ref(SBOMType.SPDX),
> + relationshipType=spdx.SPDX_RELATIONSHIP_DEPENDS_ON,
> + )
> + relationships.append(relationship)
> + else:
> + # this might happen if we have optional dependencies
> + pass
> + if package.source:
> + relationship = spdx.SPDXRelationship(
> + spdxElementId=package.source.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=package.bom_ref(SBOMType.SPDX),
> + relationshipType=spdx.SPDX_RELATIONSHIP_GENERATES,
> + )
> + relationships.append(relationship)
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=spdx.SPDX_REF_DOCUMENT,
> + relatedSpdxElement=distro_ref,
> + relationshipType=spdx.SPDX_RELATIONSHIP_DESCRIBES,
> + )
> + )
> +
> + namespace_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> + timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> + bom = spdx.SPDXBOM(
> + SPDXID=spdx.SPDX_REF_DOCUMENT,
> + spdxVersion=spdx.SPDX_VERSION,
> + creationInfo=spdx.SPDXCreationInfo(
> + comment="This document has been generated as part of an ISAR build.",
> + creators=[
> + "Tool: ISAR SBOM Generator - {}".format(d.getVar("SBOM_GEN_VERSION"))
> + ],
> + created=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + ),
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + dataLicense="CC0-1.0",
> + documentNamespace="{}/{}-{}".format(
> + d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),
> + d.getVar("SBOM_DISTRO_NAME"),
> + namespace_uuid if namespace_uuid else uuid4(),
> + ),
> + packages=data,
> + relationships=relationships,
> + )
> + return bom
> +
> +
> +def fixup_dict(o):
> + """Apply fixups for the BOMs.
> +
> + This is necessary for some field names and to remove fields with a None
> + value.
> + """
> + dct = vars(o)
> + new_dct = {}
> + for k, v in dct.items():
> + # remove fields with no content
> + if v is not None:
> + # we can not name our fields with dashes, so convert them
> + k = k.replace("_", "-")
> + new_dct[k] = v
> + return new_dct
> +
> +
> +def generate(d, packages: List[Package], sbom_type: SBOMType, out: str):
> + """Generate a SBOM."""
> + if sbom_type == SBOMType.CycloneDX:
> + bom = cyclonedx_bom(d, packages)
> + elif sbom_type == SBOMType.SPDX:
> + bom = spdx_bom(d, packages)
> +
> + with open(out, "w") as bom_file:
> + json.dump(bom, bom_file, indent=2, default=fixup_dict, sort_keys=True)
> diff --git a/meta/lib/sbom_cdx_types.py b/meta/lib/sbom_cdx_types.py
> new file mode 100644
> index 00000000..4911cc23
> --- /dev/null
> +++ b/meta/lib/sbom_cdx_types.py
> @@ -0,0 +1,82 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from typing import List, Optional
> +
> +# Minimal implementation of some CycloneDX SBOM types.
> +# Please mind that (almost) none of these types are complete, they only
> +# reflect what was strictly necessary for immediate SBOM creation
> +
> +CDX_BOM_FORMAT = "CycloneDX"
> +CDX_SPEC_VERSION = "1.6"
> +
> +CDX_REF_PREFIX = "CDXRef-"
> +
> +CDX_PACKAGE_EXTREF_TYPE_WEBSITE = "website"
> +
> +CDX_COMPONENT_TYPE_LIBRARY = "library"
> +CDX_COMPONENT_TYPE_APPLICATION = "application"
> +CDX_COMPONENT_TYPE_OS = "operating-system"
> +
> +
> +@dataclass
> +class CDXDependency:
> + ref: str
> + dependsOn: Optional[str]
> +
> +
> +@dataclass
> +class CDXExternalReference:
> + url: str
> + type: str
> + comment: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXSupplierContact:
> + email: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXSupplier:
> + name: Optional[str] = None
> + contact: Optional[CDXSupplierContact] = None
> +
> +
> +@dataclass
> +class CDXComponent:
> + type: str
> + name: str
> + bom_ref: Optional[str] = None
> + supplier: Optional[str] = None
> + version: Optional[CDXSupplier] = None
> + description: Optional[str] = None
> + purl: Optional[str] = None
> + externalReferences: Optional[List[CDXExternalReference]] = None
> + homepage: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXBOMMetadataTool:
> + components: Optional[List[CDXComponent]]
> +
> +
> +@dataclass
> +class CDXBOMMetadata:
> + timestamp: Optional[str] = None
> + component: Optional[str] = None
> + tools: Optional[List[CDXBOMMetadataTool]] = None
> +
> +
> +@dataclass
> +class CDXBOM:
> + bomFormat: str
> + specVersion: str
> + serialNumber: Optional[str] = None
> + version: Optional[str] = None
> + metadata: Optional[CDXBOMMetadata] = None
> + components: Optional[List[CDXComponent]] = None
> + dependencies: Optional[List[CDXDependency]] = None
> diff --git a/meta/lib/sbom_spdx_types.py b/meta/lib/sbom_spdx_types.py
> new file mode 100644
> index 00000000..efd7cc0c
> --- /dev/null
> +++ b/meta/lib/sbom_spdx_types.py
> @@ -0,0 +1,95 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from typing import List, Optional
> +
> +# Minimal implementation of some SPDX SBOM types.
> +# Please mind that (almost) none of these types are complete, they only
> +# reflect what was strictly necessary for immediate SBOM creation
> +
> +SPDX_VERSION = "SPDX-2.3"
> +
> +SPDX_REF_PREFIX = "SPDXRef-"
> +
> +SPDX_REF_DOCUMENT = "SPDXRef-DOCUMENT"
> +
> +SPDX_PACKAGE_PURPOSE_LIBRARY = "LIBRARY"
> +SPDX_PACKAGE_PURPOSE_OS = "OPERATING_SYSTEM"
> +SPDX_PACKAGE_PURPOSE_SRC = "SOURCE"
> +
> +SPDX_NOASSERTION = "NOASSERTION"
> +
> +SPDX_RELATIONSHIP_DEPENDS_ON = "DEPENDS_ON"
> +SPDX_RELATIONSHIP_PACKAGE_OF = "PACKAGE_OF"
> +SPDX_RELATIONSHIP_GENERATES = "GENERATES"
> +SPDX_RELATIONSHIP_DESCRIBES = "DESCRIBES"
> +
> +SPDX_REFERENCE_CATEGORY_PKG_MANAGER = "PACKAGE_MANAGER"
> +SPDX_REFERENCE_TYPE_PURL = "purl"
> +
> +# cues for an organization in the maintainer name
> +SPDX_SUPPLIER_ORG_CUE = [
> + "maintainers",
> + "group",
> + "developers",
> + "team",
> + "project",
> + "task force",
> + "strike force",
> + "packagers",
> +]
> +
> +
> +@dataclass
> +class SPDXRelationship:
> + spdxElementId: str
> + relatedSpdxElement: str
> + relationshipType: str
> +
> +
> +@dataclass
> +class SPDXExternalRef:
> + referenceCategory: str
> + referenceType: str
> + referenceLocator: str
> +
> +
> +@dataclass
> +class SPDXPackage:
> + SPDXID: str
> + name: str
> + downloadLocation: str
> + filesAnalyzed: Optional[bool] = False
> + versionInfo: Optional[str] = None
> + homepage: Optional[str] = None
> + primaryPackagePurpose: Optional[str] = None
> + supplier: Optional[str] = None
> + licenseConcluded: Optional[str] = None
> + licenseDeclared: Optional[str] = None
> + copyrightText: Optional[str] = None
> + summary: Optional[str] = None
> + externalRefs: Optional[List[SPDXExternalRef]] = None
> +
> +
> +@dataclass
> +class SPDXCreationInfo:
> + created: str
> + comment: Optional[str] = None
> + creators: List[str] = None
> +
> +
> +@dataclass
> +class SPDXBOM:
> + """Incomplete BOM as of SPDX spec v2.3."""
> +
> + SPDXID: str
> + spdxVersion: str
> + creationInfo: SPDXCreationInfo
> + name: str
> + dataLicense: str
> + documentNamespace: str
> + packages: List[SPDXPackage]
> + relationships: List[SPDXRelationship]
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/e331ec02-9594-41b1-b206-0ea10160543c%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-03-04 11:54 ` 'Niedermayr, BENEDIKT' via isar-users
@ 2025-03-04 12:12 ` 'Christoph Steiger' via isar-users
0 siblings, 0 replies; 15+ messages in thread
From: 'Christoph Steiger' via isar-users @ 2025-03-04 12:12 UTC (permalink / raw)
To: Niedermayr, Benedikt (FT RPD CED OES-DE),
Moessbauer, Felix (FT RPD CED OES-DE),
isar-users
Cc: Kiszka, Jan (FT RPD CED), Hillier, Gernot (FT RPD CED OES-DE)
On 3/4/25 12:54, Niedermayr, Benedikt (FT RPD CED OES-DE) wrote:
> On 20.02.25 10:59, 'Felix Moessbauer' via isar-users wrote:
>> From: Christoph Steiger <christoph.steiger@siemens.com>
>>
>> Add a new class to allow generation of software bill of materials
>> (SBOM). Supported are the two standard SBOM formats CycloneDX and SPDX.
>> SBOM generation is enabled per default for all images.
>>
>> Both formats support the minimal usecase of binary packages information
>> and their dependencies. Unfortunately there is no proper way to express
>> the relationships of debian source packages and their corresponding
>> binary packages in the CDX format, so it is left out there.
>>
>> The information included in the SBOM is parsed from the dpkg status
>> file found in the created image.
>
> AFAIK you're using the cyclonedx-python-lib [1], right?
>
> This package is not packaged by debian, yet and the python package needs
> to be run in python-virtualenv.
>
> Do you have any plans to support this?
>
>
> [1] https://pypi.org/project/cyclonedx-python-lib/
Right now a very minimal version of the SBOM standards is implemented in
pure python so we can include it in meta/lib. This has some
disadvantages of course.
We had an extended internal discussion about this and we have decided
that using the standard tooling is a better approach instead of rolling
our own. Since the generation tooling is likely to grow in the future
(e.g. additional versions and formats required by some people) we can
minimize our development effort that way. So we are planning to actually
use cyclonedx-python-lib and the spdx-tools for the SBOM generation
part. Since these packages are not available yet the plan is to create a
chroot for the SBOM generation and package the tools and dependencies
directly in isar. The end goal is of course to get these packaged in
Debian and simply use them.
>>
>> Signed-off-by: Christoph Steiger <christoph.steiger@siemens.com>
>> ---
>> meta/classes/create-sbom.bbclass | 49 ++++
>> meta/classes/image.bbclass | 2 +
>> meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++
>> meta/lib/sbom_cdx_types.py | 82 ++++++
>> meta/lib/sbom_spdx_types.py | 95 +++++++
>> 5 files changed, 674 insertions(+)
>> create mode 100644 meta/classes/create-sbom.bbclass
>> create mode 100644 meta/lib/sbom.py
>> create mode 100644 meta/lib/sbom_cdx_types.py
>> create mode 100644 meta/lib/sbom_spdx_types.py
>>
>> diff --git a/meta/classes/create-sbom.bbclass b/meta/classes/create-sbom.bbclass
>> new file mode 100644
>> index 00000000..8c647699
>> --- /dev/null
>> +++ b/meta/classes/create-sbom.bbclass
>> @@ -0,0 +1,49 @@
>> +# This software is a part of ISAR.
>> +# Copyright (C) 2025 Siemens AG
>> +#
>> +# SPDX-License-Identifier: MIT
>> +
>> +# sbom type to generate, accepted are "cyclonedx" and "spdx"
>> +SBOM_TYPE ?= "cyclonedx spdx"
>> +
>> +# general user variables
>> +SBOM_DISTRO_SUPPLIER ?= "ISAR"
>> +SBOM_DISTRO_NAME ?= "ISAR-Debian-GNU-Linux"
>> +SBOM_DISTRO_VERSION ?= "1.0.0"
>> +SBOM_DISTRO_SUMMARY ?= "Linux distribution built with ISAR"
>> +SBOM_DOCUMENT_UUID ?= ""
>> +
>> +# SPDX specific user variables
>> +SBOM_SPDX_NAMESPACE_PREFIX ?= "https://spdx.org/spdxdocs
>> +
>> +SBOM_DEPLOY_BASE = "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
>> +
>> +SBOM_GEN_VERSION = "0.1.0"
>> +
>> +# adapted from the isar-cip-core image_uuid.bbclass
>> +def generate_document_uuid(d):
>> + import uuid
>> +
>> + base_hash = d.getVar("BB_TASKHASH")
>> + if base_hash is None:
>> + bb.warn("no BB_TASKHASH available, SBOM UUID is not reproducible")
>> + return uuid.uuid4()
>> + return str(uuid.UUID(base_hash[:32], version=4))
>> +
>> +python do_create_sbom() {
>> + import sbom
>> +
>> + dpkg_status = d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"
>> + packages = sbom.Package.parse_status_file(dpkg_status)
>> +
>> + if not d.getVar("SBOM_DOCUMENT_UUID"):
>> + d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))
>> +
>> + sbom_type = d.getVar("SBOM_TYPE")
>> + if "cyclonedx" in sbom_type:
>> + sbom.generate(d, packages, sbom.SBOMType.CycloneDX, d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
>> + if "spdx" in sbom_type:
>> + sbom.generate(d, packages, sbom.SBOMType.SPDX, d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")
>> +}
>
> Maybe a variable for enabling/disabling this feature would be nice.
> (IMAGE_FEATURES, DISTRO_FEATURES).
>
>
Good idea, this would belong to IMAGE_FEATURES.
>> +
>> +addtask do_create_sbom after do_rootfs before do_build
>> diff --git a/meta/classes/image.bbclass b/meta/classes/image.bbclass
>> index 56eca202..e9da6a61 100644
>> --- a/meta/classes/image.bbclass
>> +++ b/meta/classes/image.bbclass
>> @@ -81,6 +81,8 @@ inherit image-postproc-extension
>> inherit image-locales-extension
>> inherit image-account-extension
>>
>> +inherit create-sbom
>> +
>> # Extra space for rootfs in MB
>> ROOTFS_EXTRA ?= "64"
>>
>> diff --git a/meta/lib/sbom.py b/meta/lib/sbom.py
>> new file mode 100644
>> index 00000000..d7c79e43
>> --- /dev/null
>> +++ b/meta/lib/sbom.py
>> @@ -0,0 +1,446 @@
>> +# This software is part of ISAR.
>> +# Copyright (C) 2025 Siemens AG
>> +#
>> +# SPDX-License-Identifier: MIT
>> +
>> +from dataclasses import dataclass
>> +from datetime import datetime
>> +from enum import Enum
>> +from typing import Dict, List, Type
>> +import json
>> +import re
>> +from uuid import uuid4
>> +
>> +import sbom_cdx_types as cdx
>> +import sbom_spdx_types as spdx
>> +
>> +
>> +class SBOMType(Enum):
>> + CycloneDX = (0,)
>> + SPDX = (1,)
>> +
>> +
>> +@dataclass
>> +class SourcePackage:
>> + name: str
>> + version: str | None
>> +
>> + def purl(self):
>> + """Return the PURL of the package."""
>> + return "pkg:deb/debian/{}@{}?arch=source".format(self.name, self.version)
>> +
>> + def bom_ref(self, sbom_type: SBOMType) -> str:
>> + """Return a unique BOM reference."""
>> + if sbom_type == SBOMType.CycloneDX:
>> + return cdx.CDXREF_PREFIX + "{}-src".format(self.name)
>> + elif sbom_type == SBOMType.SPDX:
>> + return spdx.SPDX_REF_PREFIX + "{}-src".format(self.name)
>> +
>> + def parse(s: str) -> Type["SourcePackage"]:
>
> Using "@classmethod" for this factory method would be more idiomatic.
>
>> + split = s.split(" ")
>> + name = split[0]
>> + try:
>> + version = " ".join(split[1:]).strip("()")
>> + except IndexError:
>> + version = None
>> +
>> + return SourcePackage(name=name, version=version)
>> +
>> +
>> +@dataclass
>> +class Dependency:
>> + name: str
>> + version: str | None
>> +
>> + def bom_ref(self, sbom_type: SBOMType) -> str:
>> + """Return a unique BOM reference."""
>> + if sbom_type == SBOMType.CycloneDX:
>> + return cdx.CDX_REF_PREFIX + "{}".format(self.name)
>> + elif sbom_type == SBOMType.SPDX:
>> + return spdx.SPDX_REF_PREFIX + "{}".format(self.name)
>> +
>> + def parse_multiple(s: str) -> List[Type["Dependency"]]:
> ... Using "@classmethod" for this factory method would be more idiomatic.
>
>> + """Parse a 'Depends' line in the dpkg status file."""
>> + dependencies = []
>> + for entry in s.split(","):
>> + entry = entry.strip()
>> + for entry in entry.split("|"):
>> + split = entry.split("(")
>> + name = split[0].strip()
>> + try:
>> + version = split[1].strip(")")
>> + except IndexError:
>> + version = None
>> + dependencies.append(Dependency(name=name, version=version))
>> +
>> + return dependencies
>> +
>> +
>> +@dataclass
>> +class Package:
>> + """Incomplete representation of a debian package."""
>> +
>> + name: str
>> + section: str
>> + maintainer: str
>> + architecture: str
>> + source: SourcePackage
>> + version: str
>> + depends: List[Dependency]
>> + description: str
>> + homepage: str
>> +
>> + def purl(self) -> str:
>> + """Return the PURL of the package."""
>> + purl = "pkg:deb/debian/{}@{}".format(self.name, self.version)
>> + if self.architecture:
>> + purl = purl + "?arch={}".format(self.architecture)
>> + return purl
>> +
>> + def bom_ref(self, sbom_type: SBOMType) -> str:
>> + """Return a unique BOM reference."""
>> + if sbom_type == SBOMType.CycloneDX:
>> + return cdx.CDX_REF_PREFIX + self.name
>> + elif sbom_type == SBOMType.SPDX:
>> + return spdx.SPDX_REF_PREFIX + self.name
>> +
>> + def parse_status_file(status_file: str) -> List[Type["Package"]]:
> ...Using "@classmethod" for this factory method would be more idiomatic.
>
> Regards,
> Benedikt
>
>> + """Parse a dpkg status file."""
>> + packages = []
>> + with open(status_file, "r") as f:
>> + name = None
>> + section = None
>> + maintainer = None
>> + architecture = None
>> + source = None
>> + version = None
>> + dependencies = None
>> + description = None
>> + homepage = None
>> + for line in f.readlines():
>> + if line.strip():
>> + if line[0] == " ":
>> + # this is a description line, we ignore it
>> + continue
>> + else:
>> + split = line.split(":")
>> + key = split[0]
>> + value = ":".join(split[1:]).strip()
>> + if key == "Package":
>> + name = value
>> + elif key == "Section":
>> + section = value
>> + elif key == "Maintainer":
>> + maintainer = value
>> + elif key == "Architecture":
>> + architecture = value
>> + elif key == "Source":
>> + source = SourcePackage.parse(value)
>> + elif key == "Version":
>> + version = value
>> + elif key == "Depends":
>> + dependencies = Dependency.parse_multiple(value)
>> + elif key == "Description":
>> + description = value
>> + elif key == "Homepage":
>> + homepage = value
>> + else:
>> + # fixup source version, if not specified it is the same
>> + # as the package version
>> + if source and not source.version:
>> + source.version = version
>> + # empty line means new package, so finish the current one
>> + packages.append(
>> + Package(
>> + name=name,
>> + section=section,
>> + maintainer=maintainer,
>> + architecture=architecture,
>> + source=source,
>> + version=version,
>> + depends=dependencies,
>> + description=description,
>> + homepage=homepage,
>> + )
>> + )
>> + name = None
>> + section = None
>> + maintainer = None
>> + architecture = None
>> + source = None
>> + version = None
>> + dependencies = None
>> + description = None
>> + homepage = None
>> +
>> + return packages
>> +
>> +
>> +def cyclonedx_bom(d, packages: List[Package]) -> Dict:
>> + """Return a valid CycloneDX SBOM."""
>> + data = []
>> + dependencies = []
>> +
>> + pattern = re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
>> + for package in packages:
>> + match = pattern.match(package.maintainer)
>> + supplier = cdx.CDXSupplier(name=match["supplier_name"])
>> + supplier_email = match["supplier_email"]
>> + if supplier_email:
>> + supplier.contact = [cdx.CDXSupplierContact(email=supplier_email)]
>> + entry = cdx.CDXComponent(
>> + type=cdx.CDX_COMPONENT_TYPE_LIBRARY,
>> + bom_ref=package.bom_ref(SBOMType.CycloneDX),
>> + supplier=supplier,
>> + name=package.name,
>> + version=package.version,
>> + description=package.description,
>> + purl=package.purl(),
>> + )
>> + if package.homepage:
>> + entry.externalReferences = (
>> + cdx.CDXExternalReference(
>> + url=package.homepage,
>> + type=cdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,
>> + comment="homepage",
>> + ),
>> + )
>> + data.append(entry)
>> +
>> + distro_bom_ref = cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
>> + distro_dependencies = []
>> + # after we have found all packages we can start to resolve dependencies
>> + package_names = [package.name for package in packages]
>> + for package in packages:
>> + distro_dependencies.append(package.bom_ref(SBOMType.CycloneDX))
>> + if package.depends:
>> + deps = []
>> + for dep in package.depends:
>> + dep_bom_ref = dep.bom_ref(SBOMType.CycloneDX)
>> + # it is possibe to specify the same package multiple times, but
>> + # in different versions
>> + if dep.name in package_names and dep_bom_ref not in deps:
>> + deps.append(dep_bom_ref)
>> + else:
>> + # this might happen if we have optional dependencies
>> + continue
>> + dependency = cdx.CDXDependency(
>> + ref=package.bom_ref(SBOMType.CycloneDX),
>> + dependsOn=deps,
>> + )
>> + dependencies.append(dependency)
>> + dependency = cdx.CDXDependency(
>> + ref=distro_bom_ref,
>> + dependsOn=distro_dependencies,
>> + )
>> + dependencies.append(dependency)
>> +
>> + doc_uuid = d.getVar("SBOM_DOCUMENT_UUID")
>> + distro_component = cdx.CDXComponent(
>> + type=cdx.CDX_COMPONENT_TYPE_OS,
>> + bom_ref=cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME"),
>> + supplier=cdx.CDXSupplier(name=d.getVar("SBOM_DISTRO_SUPPLIER")),
>> + name=d.getVar("SBOM_DISTRO_NAME"),
>> + version=d.getVar("SBOM_DISTRO_VERSION"),
>> + description=d.getVar("SBOM_DISTRO_SUMMARY"),
>> + )
>> +
>> + timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
>> + bom = cdx.CDXBOM(
>> + bomFormat=cdx.CDX_BOM_FORMAT,
>> + specVersion=cdx.CDX_SPEC_VERSION,
>> + serialNumber="urn:uuid:{}".format(doc_uuid if doc_uuid else uuid4()),
>> + version=1,
>> + metadata=cdx.CDXBOMMetadata(
>> + timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
>> + component=distro_component,
>> + tools=cdx.CDXBOMMetadataTool(
>> + components=[
>> + cdx.CDXComponent(
>> + type=cdx.CDX_COMPONENT_TYPE_APPLICATION,
>> + name="ISAR SBOM Generator",
>> + version=d.getVar("SBOM_GEN_VERSION"),
>> + )
>> + ],
>> + ),
>> + ),
>> + components=data,
>> + dependencies=dependencies,
>> + )
>> + return bom
>> +
>> +
>> +def spdx_bom(d, packages: List[Package]) -> Dict:
>> + "Return a valid SPDX SBOM."
>> +
>> + data = []
>> + # create a "fake" entry for the distribution
>> + distro_ref = spdx.SPDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
>> + distro_package = spdx.SPDXPackage(
>> + SPDXID=distro_ref,
>> + name=d.getVar("SBOM_DISTRO_NAME"),
>> + versionInfo=d.getVar("SBOM_DISTRO_VERSION"),
>> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_OS,
>> + supplier="Organization: {}".format(d.getVar("SBOM_DISTRO_SUPPLIER")),
>> + downloadLocation=spdx.SPDX_NOASSERTION,
>> + filesAnalyzed=False,
>> + licenseConcluded=spdx.SPDX_NOASSERTION,
>> + licenseDeclared=spdx.SPDX_NOASSERTION,
>> + copyrightText=spdx.SPDX_NOASSERTION,
>> + summary=d.getVar("SBOM_DISTRO_SUMMARY"),
>> + )
>> +
>> + data.append(distro_package)
>> +
>> + pattern = re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
>> + for package in packages:
>> + match = pattern.match(package.maintainer)
>> + supplier_name = match["supplier_name"]
>> + supplier_email = match["supplier_email"]
>> + if any([cue in supplier_name.lower() for cue in spdx.SPDX_SUPPLIER_ORG_CUE]):
>> + supplier = "Organization: {}".format(supplier_name)
>> + else:
>> + supplier = "Person: {}".format(supplier_name)
>> + if supplier_email:
>> + supplier += "({})".format(supplier_email)
>> +
>> + entry = spdx.SPDXPackage(
>> + SPDXID=package.bom_ref(SBOMType.SPDX),
>> + name=package.name,
>> + versionInfo=package.version,
>> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_LIBRARY,
>> + supplier=supplier,
>> + downloadLocation=spdx.SPDX_NOASSERTION,
>> + filesAnalyzed=False,
>> + # TODO: it should be possible to conclude license/copyright
>> + # information, we could look e.g. in /usr/share/doc/*/copyright
>> + licenseConcluded=spdx.SPDX_NOASSERTION,
>> + licenseDeclared=spdx.SPDX_NOASSERTION,
>> + copyrightText=spdx.SPDX_NOASSERTION,
>> + summary=package.description,
>> + externalRefs=[
>> + spdx.SPDXExternalRef(
>> + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
>> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
>> + referenceLocator=package.purl(),
>> + )
>> + ],
>> + )
>> + if package.homepage:
>> + entry.homepage = package.homepage
>> + data.append(entry)
>> +
>> + if package.source:
>> + src_entry = spdx.SPDXPackage(
>> + SPDXID=package.source.bom_ref(SBOMType.SPDX),
>> + name=package.source.name,
>> + versionInfo=package.source.version,
>> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_SRC,
>> + supplier=supplier,
>> + downloadLocation=spdx.SPDX_NOASSERTION,
>> + filesAnalyzed=False,
>> + licenseConcluded=spdx.SPDX_NOASSERTION,
>> + licenseDeclared=spdx.SPDX_NOASSERTION,
>> + copyrightText=spdx.SPDX_NOASSERTION,
>> + summary="debian source code package '{}'".format(package.source.name),
>> + externalRefs=[
>> + spdx.SPDXExternalRef(
>> + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
>> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
>> + referenceLocator=package.source.purl(),
>> + )
>> + ],
>> + )
>> + # source packages might be referenced multiple times
>> + if src_entry not in data:
>> + data.append(src_entry)
>> +
>> + relationships = []
>> + # after we have found all packages we can start to resolve dependencies
>> + package_names = [package.name for package in packages]
>> + for package in packages:
>> + relationships.append(
>> + spdx.SPDXRelationship(
>> + spdxElementId=package.bom_ref(SBOMType.SPDX),
>> + relatedSpdxElement=distro_ref,
>> + relationshipType=spdx.SPDX_RELATIONSHIP_PACKAGE_OF,
>> + )
>> + )
>> + if package.depends:
>> + for dep in package.depends:
>> + if dep.name in package_names:
>> + relationship = spdx.SPDXRelationship(
>> + spdxElementId=package.bom_ref(SBOMType.SPDX),
>> + relatedSpdxElement=dep.bom_ref(SBOMType.SPDX),
>> + relationshipType=spdx.SPDX_RELATIONSHIP_DEPENDS_ON,
>> + )
>> + relationships.append(relationship)
>> + else:
>> + # this might happen if we have optional dependencies
>> + pass
>> + if package.source:
>> + relationship = spdx.SPDXRelationship(
>> + spdxElementId=package.source.bom_ref(SBOMType.SPDX),
>> + relatedSpdxElement=package.bom_ref(SBOMType.SPDX),
>> + relationshipType=spdx.SPDX_RELATIONSHIP_GENERATES,
>> + )
>> + relationships.append(relationship)
>> + relationships.append(
>> + spdx.SPDXRelationship(
>> + spdxElementId=spdx.SPDX_REF_DOCUMENT,
>> + relatedSpdxElement=distro_ref,
>> + relationshipType=spdx.SPDX_RELATIONSHIP_DESCRIBES,
>> + )
>> + )
>> +
>> + namespace_uuid = d.getVar("SBOM_DOCUMENT_UUID")
>> + timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
>> + bom = spdx.SPDXBOM(
>> + SPDXID=spdx.SPDX_REF_DOCUMENT,
>> + spdxVersion=spdx.SPDX_VERSION,
>> + creationInfo=spdx.SPDXCreationInfo(
>> + comment="This document has been generated as part of an ISAR build.",
>> + creators=[
>> + "Tool: ISAR SBOM Generator - {}".format(d.getVar("SBOM_GEN_VERSION"))
>> + ],
>> + created=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
>> + ),
>> + name=d.getVar("SBOM_DISTRO_NAME"),
>> + dataLicense="CC0-1.0",
>> + documentNamespace="{}/{}-{}".format(
>> + d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),
>> + d.getVar("SBOM_DISTRO_NAME"),
>> + namespace_uuid if namespace_uuid else uuid4(),
>> + ),
>> + packages=data,
>> + relationships=relationships,
>> + )
>> + return bom
>> +
>> +
>> +def fixup_dict(o):
>> + """Apply fixups for the BOMs.
>> +
>> + This is necessary for some field names and to remove fields with a None
>> + value.
>> + """
>> + dct = vars(o)
>> + new_dct = {}
>> + for k, v in dct.items():
>> + # remove fields with no content
>> + if v is not None:
>> + # we can not name our fields with dashes, so convert them
>> + k = k.replace("_", "-")
>> + new_dct[k] = v
>> + return new_dct
>> +
>> +
>> +def generate(d, packages: List[Package], sbom_type: SBOMType, out: str):
>> + """Generate a SBOM."""
>> + if sbom_type == SBOMType.CycloneDX:
>> + bom = cyclonedx_bom(d, packages)
>> + elif sbom_type == SBOMType.SPDX:
>> + bom = spdx_bom(d, packages)
>> +
>> + with open(out, "w") as bom_file:
>> + json.dump(bom, bom_file, indent=2, default=fixup_dict, sort_keys=True)
>> diff --git a/meta/lib/sbom_cdx_types.py b/meta/lib/sbom_cdx_types.py
>> new file mode 100644
>> index 00000000..4911cc23
>> --- /dev/null
>> +++ b/meta/lib/sbom_cdx_types.py
>> @@ -0,0 +1,82 @@
>> +# This software is part of ISAR.
>> +# Copyright (C) 2025 Siemens AG
>> +#
>> +# SPDX-License-Identifier: MIT
>> +
>> +from dataclasses import dataclass
>> +from typing import List, Optional
>> +
>> +# Minimal implementation of some CycloneDX SBOM types.
>> +# Please mind that (almost) none of these types are complete, they only
>> +# reflect what was strictly necessary for immediate SBOM creation
>> +
>> +CDX_BOM_FORMAT = "CycloneDX"
>> +CDX_SPEC_VERSION = "1.6"
>> +
>> +CDX_REF_PREFIX = "CDXRef-"
>> +
>> +CDX_PACKAGE_EXTREF_TYPE_WEBSITE = "website"
>> +
>> +CDX_COMPONENT_TYPE_LIBRARY = "library"
>> +CDX_COMPONENT_TYPE_APPLICATION = "application"
>> +CDX_COMPONENT_TYPE_OS = "operating-system"
>> +
>> +
>> +@dataclass
>> +class CDXDependency:
>> + ref: str
>> + dependsOn: Optional[str]
>> +
>> +
>> +@dataclass
>> +class CDXExternalReference:
>> + url: str
>> + type: str
>> + comment: Optional[str] = None
>> +
>> +
>> +@dataclass
>> +class CDXSupplierContact:
>> + email: Optional[str] = None
>> +
>> +
>> +@dataclass
>> +class CDXSupplier:
>> + name: Optional[str] = None
>> + contact: Optional[CDXSupplierContact] = None
>> +
>> +
>> +@dataclass
>> +class CDXComponent:
>> + type: str
>> + name: str
>> + bom_ref: Optional[str] = None
>> + supplier: Optional[str] = None
>> + version: Optional[CDXSupplier] = None
>> + description: Optional[str] = None
>> + purl: Optional[str] = None
>> + externalReferences: Optional[List[CDXExternalReference]] = None
>> + homepage: Optional[str] = None
>> +
>> +
>> +@dataclass
>> +class CDXBOMMetadataTool:
>> + components: Optional[List[CDXComponent]]
>> +
>> +
>> +@dataclass
>> +class CDXBOMMetadata:
>> + timestamp: Optional[str] = None
>> + component: Optional[str] = None
>> + tools: Optional[List[CDXBOMMetadataTool]] = None
>> +
>> +
>> +@dataclass
>> +class CDXBOM:
>> + bomFormat: str
>> + specVersion: str
>> + serialNumber: Optional[str] = None
>> + version: Optional[str] = None
>> + metadata: Optional[CDXBOMMetadata] = None
>> + components: Optional[List[CDXComponent]] = None
>> + dependencies: Optional[List[CDXDependency]] = None
>> diff --git a/meta/lib/sbom_spdx_types.py b/meta/lib/sbom_spdx_types.py
>> new file mode 100644
>> index 00000000..efd7cc0c
>> --- /dev/null
>> +++ b/meta/lib/sbom_spdx_types.py
>> @@ -0,0 +1,95 @@
>> +# This software is part of ISAR.
>> +# Copyright (C) 2025 Siemens AG
>> +#
>> +# SPDX-License-Identifier: MIT
>> +
>> +from dataclasses import dataclass
>> +from typing import List, Optional
>> +
>> +# Minimal implementation of some SPDX SBOM types.
>> +# Please mind that (almost) none of these types are complete, they only
>> +# reflect what was strictly necessary for immediate SBOM creation
>> +
>> +SPDX_VERSION = "SPDX-2.3"
>> +
>> +SPDX_REF_PREFIX = "SPDXRef-"
>> +
>> +SPDX_REF_DOCUMENT = "SPDXRef-DOCUMENT"
>> +
>> +SPDX_PACKAGE_PURPOSE_LIBRARY = "LIBRARY"
>> +SPDX_PACKAGE_PURPOSE_OS = "OPERATING_SYSTEM"
>> +SPDX_PACKAGE_PURPOSE_SRC = "SOURCE"
>> +
>> +SPDX_NOASSERTION = "NOASSERTION"
>> +
>> +SPDX_RELATIONSHIP_DEPENDS_ON = "DEPENDS_ON"
>> +SPDX_RELATIONSHIP_PACKAGE_OF = "PACKAGE_OF"
>> +SPDX_RELATIONSHIP_GENERATES = "GENERATES"
>> +SPDX_RELATIONSHIP_DESCRIBES = "DESCRIBES"
>> +
>> +SPDX_REFERENCE_CATEGORY_PKG_MANAGER = "PACKAGE_MANAGER"
>> +SPDX_REFERENCE_TYPE_PURL = "purl"
>> +
>> +# cues for an organization in the maintainer name
>> +SPDX_SUPPLIER_ORG_CUE = [
>> + "maintainers",
>> + "group",
>> + "developers",
>> + "team",
>> + "project",
>> + "task force",
>> + "strike force",
>> + "packagers",
>> +]
>> +
>> +
>> +@dataclass
>> +class SPDXRelationship:
>> + spdxElementId: str
>> + relatedSpdxElement: str
>> + relationshipType: str
>> +
>> +
>> +@dataclass
>> +class SPDXExternalRef:
>> + referenceCategory: str
>> + referenceType: str
>> + referenceLocator: str
>> +
>> +
>> +@dataclass
>> +class SPDXPackage:
>> + SPDXID: str
>> + name: str
>> + downloadLocation: str
>> + filesAnalyzed: Optional[bool] = False
>> + versionInfo: Optional[str] = None
>> + homepage: Optional[str] = None
>> + primaryPackagePurpose: Optional[str] = None
>> + supplier: Optional[str] = None
>> + licenseConcluded: Optional[str] = None
>> + licenseDeclared: Optional[str] = None
>> + copyrightText: Optional[str] = None
>> + summary: Optional[str] = None
>> + externalRefs: Optional[List[SPDXExternalRef]] = None
>> +
>> +
>> +@dataclass
>> +class SPDXCreationInfo:
>> + created: str
>> + comment: Optional[str] = None
>> + creators: List[str] = None
>> +
>> +
>> +@dataclass
>> +class SPDXBOM:
>> + """Incomplete BOM as of SPDX spec v2.3."""
>> +
>> + SPDXID: str
>> + spdxVersion: str
>> + creationInfo: SPDXCreationInfo
>> + name: str
>> + dataLicense: str
>> + documentNamespace: str
>> + packages: List[SPDXPackage]
>> + relationships: List[SPDXRelationship]
>
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/1076fd9d-6365-4b71-9892-09dd2d932fa9%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-02-20 9:59 ` [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation 'Felix Moessbauer' via isar-users
2025-02-20 18:58 ` 'Gernot Hillier' via isar-users
2025-03-04 11:54 ` 'Niedermayr, BENEDIKT' via isar-users
@ 2025-06-10 12:03 ` 'Christoph Steiger' via isar-users
2025-07-14 12:16 ` 'Simone Weiß' via isar-users
2025-07-28 15:24 ` 'MOESSBAUER, Felix' via isar-users
2025-08-01 7:53 ` 'MOESSBAUER, Felix' via isar-users
4 siblings, 1 reply; 15+ messages in thread
From: 'Christoph Steiger' via isar-users @ 2025-06-10 12:03 UTC (permalink / raw)
To: Felix Moessbauer, isar-users
Cc: schilling.benjamin, cedric.hombourger, mete.bahadir
FYI Benjamin, Cedric and Mete:
We are currently working on a V2 for this with more or less the same
functionality and some internal changes. It might be interesting for you
too. Maybe you could try this version out in your builds and see if
anything important/nice-to-have is missing in the SBOMs?
> From: Christoph Steiger <christoph.steiger@siemens.com>
>
> Add a new class to allow generation of software bill of materials
> (SBOM). Supported are the two standard SBOM formats CycloneDX and SPDX.
> SBOM generation is enabled per default for all images.
>
> Both formats support the minimal usecase of binary packages information
> and their dependencies. Unfortunately there is no proper way to express
> the relationships of debian source packages and their corresponding
> binary packages in the CDX format, so it is left out there.
>
> The information included in the SBOM is parsed from the dpkg status
> file found in the created image.
>
> Signed-off-by: Christoph Steiger <christoph.steiger@siemens.com>
> ---
> meta/classes/create-sbom.bbclass | 49 ++++
> meta/classes/image.bbclass | 2 +
> meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++
> meta/lib/sbom_cdx_types.py | 82 ++++++
> meta/lib/sbom_spdx_types.py | 95 +++++++
> 5 files changed, 674 insertions(+)
> create mode 100644 meta/classes/create-sbom.bbclass
> create mode 100644 meta/lib/sbom.py
> create mode 100644 meta/lib/sbom_cdx_types.py
> create mode 100644 meta/lib/sbom_spdx_types.py
>
> diff --git a/meta/classes/create-sbom.bbclass b/meta/classes/create-sbom.bbclass
> new file mode 100644
> index 00000000..8c647699
> --- /dev/null
> +++ b/meta/classes/create-sbom.bbclass
> @@ -0,0 +1,49 @@
> +# This software is a part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +# sbom type to generate, accepted are "cyclonedx" and "spdx"
> +SBOM_TYPE ?= "cyclonedx spdx"
> +
> +# general user variables
> +SBOM_DISTRO_SUPPLIER ?= "ISAR"
> +SBOM_DISTRO_NAME ?= "ISAR-Debian-GNU-Linux"
> +SBOM_DISTRO_VERSION ?= "1.0.0"
> +SBOM_DISTRO_SUMMARY ?= "Linux distribution built with ISAR"
> +SBOM_DOCUMENT_UUID ?= ""
> +
> +# SPDX specific user variables
> +SBOM_SPDX_NAMESPACE_PREFIX ?= "https://spdx.org/spdxdocs"
> +
> +SBOM_DEPLOY_BASE = "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
> +
> +SBOM_GEN_VERSION = "0.1.0"
> +
> +# adapted from the isar-cip-core image_uuid.bbclass
> +def generate_document_uuid(d):
> + import uuid
> +
> + base_hash = d.getVar("BB_TASKHASH")
> + if base_hash is None:
> + bb.warn("no BB_TASKHASH available, SBOM UUID is not reproducible")
> + return uuid.uuid4()
> + return str(uuid.UUID(base_hash[:32], version=4))
> +
> +python do_create_sbom() {
> + import sbom
> +
> + dpkg_status = d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"
> + packages = sbom.Package.parse_status_file(dpkg_status)
> +
> + if not d.getVar("SBOM_DOCUMENT_UUID"):
> + d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))
> +
> + sbom_type = d.getVar("SBOM_TYPE")
> + if "cyclonedx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.CycloneDX, d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
> + if "spdx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.SPDX, d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")
> +}
> +
> +addtask do_create_sbom after do_rootfs before do_build
> diff --git a/meta/classes/image.bbclass b/meta/classes/image.bbclass
> index 56eca202..e9da6a61 100644
> --- a/meta/classes/image.bbclass
> +++ b/meta/classes/image.bbclass
> @@ -81,6 +81,8 @@ inherit image-postproc-extension
> inherit image-locales-extension
> inherit image-account-extension
>
> +inherit create-sbom
> +
> # Extra space for rootfs in MB
> ROOTFS_EXTRA ?= "64"
>
> diff --git a/meta/lib/sbom.py b/meta/lib/sbom.py
> new file mode 100644
> index 00000000..d7c79e43
> --- /dev/null
> +++ b/meta/lib/sbom.py
> @@ -0,0 +1,446 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from datetime import datetime
> +from enum import Enum
> +from typing import Dict, List, Type
> +import json
> +import re
> +from uuid import uuid4
> +
> +import sbom_cdx_types as cdx
> +import sbom_spdx_types as spdx
> +
> +
> +class SBOMType(Enum):
> + CycloneDX = (0,)
> + SPDX = (1,)
> +
> +
> +@dataclass
> +class SourcePackage:
> + name: str
> + version: str | None
> +
> + def purl(self):
> + """Return the PURL of the package."""
> + return "pkg:deb/debian/{}@{}?arch=source".format(self.name, self.version)
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDXREF_PREFIX + "{}-src".format(self.name)
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}-src".format(self.name)
> +
> + def parse(s: str) -> Type["SourcePackage"]:
> + split = s.split(" ")
> + name = split[0]
> + try:
> + version = " ".join(split[1:]).strip("()")
> + except IndexError:
> + version = None
> +
> + return SourcePackage(name=name, version=version)
> +
> +
> +@dataclass
> +class Dependency:
> + name: str
> + version: str | None
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + "{}".format(self.name)
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}".format(self.name)
> +
> + def parse_multiple(s: str) -> List[Type["Dependency"]]:
> + """Parse a 'Depends' line in the dpkg status file."""
> + dependencies = []
> + for entry in s.split(","):
> + entry = entry.strip()
> + for entry in entry.split("|"):
> + split = entry.split("(")
> + name = split[0].strip()
> + try:
> + version = split[1].strip(")")
> + except IndexError:
> + version = None
> + dependencies.append(Dependency(name=name, version=version))
> +
> + return dependencies
> +
> +
> +@dataclass
> +class Package:
> + """Incomplete representation of a debian package."""
> +
> + name: str
> + section: str
> + maintainer: str
> + architecture: str
> + source: SourcePackage
> + version: str
> + depends: List[Dependency]
> + description: str
> + homepage: str
> +
> + def purl(self) -> str:
> + """Return the PURL of the package."""
> + purl = "pkg:deb/debian/{}@{}".format(self.name, self.version)
> + if self.architecture:
> + purl = purl + "?arch={}".format(self.architecture)
> + return purl
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + self.name
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + self.name
> +
> + def parse_status_file(status_file: str) -> List[Type["Package"]]:
> + """Parse a dpkg status file."""
> + packages = []
> + with open(status_file, "r") as f:
> + name = None
> + section = None
> + maintainer = None
> + architecture = None
> + source = None
> + version = None
> + dependencies = None
> + description = None
> + homepage = None
> + for line in f.readlines():
> + if line.strip():
> + if line[0] == " ":
> + # this is a description line, we ignore it
> + continue
> + else:
> + split = line.split(":")
> + key = split[0]
> + value = ":".join(split[1:]).strip()
> + if key == "Package":
> + name = value
> + elif key == "Section":
> + section = value
> + elif key == "Maintainer":
> + maintainer = value
> + elif key == "Architecture":
> + architecture = value
> + elif key == "Source":
> + source = SourcePackage.parse(value)
> + elif key == "Version":
> + version = value
> + elif key == "Depends":
> + dependencies = Dependency.parse_multiple(value)
> + elif key == "Description":
> + description = value
> + elif key == "Homepage":
> + homepage = value
> + else:
> + # fixup source version, if not specified it is the same
> + # as the package version
> + if source and not source.version:
> + source.version = version
> + # empty line means new package, so finish the current one
> + packages.append(
> + Package(
> + name=name,
> + section=section,
> + maintainer=maintainer,
> + architecture=architecture,
> + source=source,
> + version=version,
> + depends=dependencies,
> + description=description,
> + homepage=homepage,
> + )
> + )
> + name = None
> + section = None
> + maintainer = None
> + architecture = None
> + source = None
> + version = None
> + dependencies = None
> + description = None
> + homepage = None
> +
> + return packages
> +
> +
> +def cyclonedx_bom(d, packages: List[Package]) -> Dict:
> + """Return a valid CycloneDX SBOM."""
> + data = []
> + dependencies = []
> +
> + pattern = re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
> + for package in packages:
> + match = pattern.match(package.maintainer)
> + supplier = cdx.CDXSupplier(name=match["supplier_name"])
> + supplier_email = match["supplier_email"]
> + if supplier_email:
> + supplier.contact = [cdx.CDXSupplierContact(email=supplier_email)]
> + entry = cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_LIBRARY,
> + bom_ref=package.bom_ref(SBOMType.CycloneDX),
> + supplier=supplier,
> + name=package.name,
> + version=package.version,
> + description=package.description,
> + purl=package.purl(),
> + )
> + if package.homepage:
> + entry.externalReferences = (
> + cdx.CDXExternalReference(
> + url=package.homepage,
> + type=cdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,
> + comment="homepage",
> + ),
> + )
> + data.append(entry)
> +
> + distro_bom_ref = cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
> + distro_dependencies = []
> + # after we have found all packages we can start to resolve dependencies
> + package_names = [package.name for package in packages]
> + for package in packages:
> + distro_dependencies.append(package.bom_ref(SBOMType.CycloneDX))
> + if package.depends:
> + deps = []
> + for dep in package.depends:
> + dep_bom_ref = dep.bom_ref(SBOMType.CycloneDX)
> + # it is possibe to specify the same package multiple times, but
> + # in different versions
> + if dep.name in package_names and dep_bom_ref not in deps:
> + deps.append(dep_bom_ref)
> + else:
> + # this might happen if we have optional dependencies
> + continue
> + dependency = cdx.CDXDependency(
> + ref=package.bom_ref(SBOMType.CycloneDX),
> + dependsOn=deps,
> + )
> + dependencies.append(dependency)
> + dependency = cdx.CDXDependency(
> + ref=distro_bom_ref,
> + dependsOn=distro_dependencies,
> + )
> + dependencies.append(dependency)
> +
> + doc_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> + distro_component = cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_OS,
> + bom_ref=cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME"),
> + supplier=cdx.CDXSupplier(name=d.getVar("SBOM_DISTRO_SUPPLIER")),
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + version=d.getVar("SBOM_DISTRO_VERSION"),
> + description=d.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> + bom = cdx.CDXBOM(
> + bomFormat=cdx.CDX_BOM_FORMAT,
> + specVersion=cdx.CDX_SPEC_VERSION,
> + serialNumber="urn:uuid:{}".format(doc_uuid if doc_uuid else uuid4()),
> + version=1,
> + metadata=cdx.CDXBOMMetadata(
> + timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + component=distro_component,
> + tools=cdx.CDXBOMMetadataTool(
> + components=[
> + cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_APPLICATION,
> + name="ISAR SBOM Generator",
> + version=d.getVar("SBOM_GEN_VERSION"),
> + )
> + ],
> + ),
> + ),
> + components=data,
> + dependencies=dependencies,
> + )
> + return bom
> +
> +
> +def spdx_bom(d, packages: List[Package]) -> Dict:
> + "Return a valid SPDX SBOM."
> +
> + data = []
> + # create a "fake" entry for the distribution
> + distro_ref = spdx.SPDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
> + distro_package = spdx.SPDXPackage(
> + SPDXID=distro_ref,
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + versionInfo=d.getVar("SBOM_DISTRO_VERSION"),
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_OS,
> + supplier="Organization: {}".format(d.getVar("SBOM_DISTRO_SUPPLIER")),
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary=d.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + data.append(distro_package)
> +
> + pattern = re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
> + for package in packages:
> + match = pattern.match(package.maintainer)
> + supplier_name = match["supplier_name"]
> + supplier_email = match["supplier_email"]
> + if any([cue in supplier_name.lower() for cue in spdx.SPDX_SUPPLIER_ORG_CUE]):
> + supplier = "Organization: {}".format(supplier_name)
> + else:
> + supplier = "Person: {}".format(supplier_name)
> + if supplier_email:
> + supplier += "({})".format(supplier_email)
> +
> + entry = spdx.SPDXPackage(
> + SPDXID=package.bom_ref(SBOMType.SPDX),
> + name=package.name,
> + versionInfo=package.version,
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_LIBRARY,
> + supplier=supplier,
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + # TODO: it should be possible to conclude license/copyright
> + # information, we could look e.g. in /usr/share/doc/*/copyright
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary=package.description,
> + externalRefs=[
> + spdx.SPDXExternalRef(
> + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> + referenceLocator=package.purl(),
> + )
> + ],
> + )
> + if package.homepage:
> + entry.homepage = package.homepage
> + data.append(entry)
> +
> + if package.source:
> + src_entry = spdx.SPDXPackage(
> + SPDXID=package.source.bom_ref(SBOMType.SPDX),
> + name=package.source.name,
> + versionInfo=package.source.version,
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_SRC,
> + supplier=supplier,
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary="debian source code package '{}'".format(package.source.name),
> + externalRefs=[
> + spdx.SPDXExternalRef(
> + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> + referenceLocator=package.source.purl(),
> + )
> + ],
> + )
> + # source packages might be referenced multiple times
> + if src_entry not in data:
> + data.append(src_entry)
> +
> + relationships = []
> + # after we have found all packages we can start to resolve dependencies
> + package_names = [package.name for package in packages]
> + for package in packages:
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=package.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=distro_ref,
> + relationshipType=spdx.SPDX_RELATIONSHIP_PACKAGE_OF,
> + )
> + )
> + if package.depends:
> + for dep in package.depends:
> + if dep.name in package_names:
> + relationship = spdx.SPDXRelationship(
> + spdxElementId=package.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=dep.bom_ref(SBOMType.SPDX),
> + relationshipType=spdx.SPDX_RELATIONSHIP_DEPENDS_ON,
> + )
> + relationships.append(relationship)
> + else:
> + # this might happen if we have optional dependencies
> + pass
> + if package.source:
> + relationship = spdx.SPDXRelationship(
> + spdxElementId=package.source.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=package.bom_ref(SBOMType.SPDX),
> + relationshipType=spdx.SPDX_RELATIONSHIP_GENERATES,
> + )
> + relationships.append(relationship)
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=spdx.SPDX_REF_DOCUMENT,
> + relatedSpdxElement=distro_ref,
> + relationshipType=spdx.SPDX_RELATIONSHIP_DESCRIBES,
> + )
> + )
> +
> + namespace_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> + timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> + bom = spdx.SPDXBOM(
> + SPDXID=spdx.SPDX_REF_DOCUMENT,
> + spdxVersion=spdx.SPDX_VERSION,
> + creationInfo=spdx.SPDXCreationInfo(
> + comment="This document has been generated as part of an ISAR build.",
> + creators=[
> + "Tool: ISAR SBOM Generator - {}".format(d.getVar("SBOM_GEN_VERSION"))
> + ],
> + created=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + ),
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + dataLicense="CC0-1.0",
> + documentNamespace="{}/{}-{}".format(
> + d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),
> + d.getVar("SBOM_DISTRO_NAME"),
> + namespace_uuid if namespace_uuid else uuid4(),
> + ),
> + packages=data,
> + relationships=relationships,
> + )
> + return bom
> +
> +
> +def fixup_dict(o):
> + """Apply fixups for the BOMs.
> +
> + This is necessary for some field names and to remove fields with a None
> + value.
> + """
> + dct = vars(o)
> + new_dct = {}
> + for k, v in dct.items():
> + # remove fields with no content
> + if v is not None:
> + # we can not name our fields with dashes, so convert them
> + k = k.replace("_", "-")
> + new_dct[k] = v
> + return new_dct
> +
> +
> +def generate(d, packages: List[Package], sbom_type: SBOMType, out: str):
> + """Generate a SBOM."""
> + if sbom_type == SBOMType.CycloneDX:
> + bom = cyclonedx_bom(d, packages)
> + elif sbom_type == SBOMType.SPDX:
> + bom = spdx_bom(d, packages)
> +
> + with open(out, "w") as bom_file:
> + json.dump(bom, bom_file, indent=2, default=fixup_dict, sort_keys=True)
> diff --git a/meta/lib/sbom_cdx_types.py b/meta/lib/sbom_cdx_types.py
> new file mode 100644
> index 00000000..4911cc23
> --- /dev/null
> +++ b/meta/lib/sbom_cdx_types.py
> @@ -0,0 +1,82 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from typing import List, Optional
> +
> +# Minimal implementation of some CycloneDX SBOM types.
> +# Please mind that (almost) none of these types are complete, they only
> +# reflect what was strictly necessary for immediate SBOM creation
> +
> +CDX_BOM_FORMAT = "CycloneDX"
> +CDX_SPEC_VERSION = "1.6"
> +
> +CDX_REF_PREFIX = "CDXRef-"
> +
> +CDX_PACKAGE_EXTREF_TYPE_WEBSITE = "website"
> +
> +CDX_COMPONENT_TYPE_LIBRARY = "library"
> +CDX_COMPONENT_TYPE_APPLICATION = "application"
> +CDX_COMPONENT_TYPE_OS = "operating-system"
> +
> +
> +@dataclass
> +class CDXDependency:
> + ref: str
> + dependsOn: Optional[str]
> +
> +
> +@dataclass
> +class CDXExternalReference:
> + url: str
> + type: str
> + comment: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXSupplierContact:
> + email: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXSupplier:
> + name: Optional[str] = None
> + contact: Optional[CDXSupplierContact] = None
> +
> +
> +@dataclass
> +class CDXComponent:
> + type: str
> + name: str
> + bom_ref: Optional[str] = None
> + supplier: Optional[str] = None
> + version: Optional[CDXSupplier] = None
> + description: Optional[str] = None
> + purl: Optional[str] = None
> + externalReferences: Optional[List[CDXExternalReference]] = None
> + homepage: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXBOMMetadataTool:
> + components: Optional[List[CDXComponent]]
> +
> +
> +@dataclass
> +class CDXBOMMetadata:
> + timestamp: Optional[str] = None
> + component: Optional[str] = None
> + tools: Optional[List[CDXBOMMetadataTool]] = None
> +
> +
> +@dataclass
> +class CDXBOM:
> + bomFormat: str
> + specVersion: str
> + serialNumber: Optional[str] = None
> + version: Optional[str] = None
> + metadata: Optional[CDXBOMMetadata] = None
> + components: Optional[List[CDXComponent]] = None
> + dependencies: Optional[List[CDXDependency]] = None
> diff --git a/meta/lib/sbom_spdx_types.py b/meta/lib/sbom_spdx_types.py
> new file mode 100644
> index 00000000..efd7cc0c
> --- /dev/null
> +++ b/meta/lib/sbom_spdx_types.py
> @@ -0,0 +1,95 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from typing import List, Optional
> +
> +# Minimal implementation of some SPDX SBOM types.
> +# Please mind that (almost) none of these types are complete, they only
> +# reflect what was strictly necessary for immediate SBOM creation
> +
> +SPDX_VERSION = "SPDX-2.3"
> +
> +SPDX_REF_PREFIX = "SPDXRef-"
> +
> +SPDX_REF_DOCUMENT = "SPDXRef-DOCUMENT"
> +
> +SPDX_PACKAGE_PURPOSE_LIBRARY = "LIBRARY"
> +SPDX_PACKAGE_PURPOSE_OS = "OPERATING_SYSTEM"
> +SPDX_PACKAGE_PURPOSE_SRC = "SOURCE"
> +
> +SPDX_NOASSERTION = "NOASSERTION"
> +
> +SPDX_RELATIONSHIP_DEPENDS_ON = "DEPENDS_ON"
> +SPDX_RELATIONSHIP_PACKAGE_OF = "PACKAGE_OF"
> +SPDX_RELATIONSHIP_GENERATES = "GENERATES"
> +SPDX_RELATIONSHIP_DESCRIBES = "DESCRIBES"
> +
> +SPDX_REFERENCE_CATEGORY_PKG_MANAGER = "PACKAGE_MANAGER"
> +SPDX_REFERENCE_TYPE_PURL = "purl"
> +
> +# cues for an organization in the maintainer name
> +SPDX_SUPPLIER_ORG_CUE = [
> + "maintainers",
> + "group",
> + "developers",
> + "team",
> + "project",
> + "task force",
> + "strike force",
> + "packagers",
> +]
> +
> +
> +@dataclass
> +class SPDXRelationship:
> + spdxElementId: str
> + relatedSpdxElement: str
> + relationshipType: str
> +
> +
> +@dataclass
> +class SPDXExternalRef:
> + referenceCategory: str
> + referenceType: str
> + referenceLocator: str
> +
> +
> +@dataclass
> +class SPDXPackage:
> + SPDXID: str
> + name: str
> + downloadLocation: str
> + filesAnalyzed: Optional[bool] = False
> + versionInfo: Optional[str] = None
> + homepage: Optional[str] = None
> + primaryPackagePurpose: Optional[str] = None
> + supplier: Optional[str] = None
> + licenseConcluded: Optional[str] = None
> + licenseDeclared: Optional[str] = None
> + copyrightText: Optional[str] = None
> + summary: Optional[str] = None
> + externalRefs: Optional[List[SPDXExternalRef]] = None
> +
> +
> +@dataclass
> +class SPDXCreationInfo:
> + created: str
> + comment: Optional[str] = None
> + creators: List[str] = None
> +
> +
> +@dataclass
> +class SPDXBOM:
> + """Incomplete BOM as of SPDX spec v2.3."""
> +
> + SPDXID: str
> + spdxVersion: str
> + creationInfo: SPDXCreationInfo
> + name: str
> + dataLicense: str
> + documentNamespace: str
> + packages: List[SPDXPackage]
> + relationships: List[SPDXRelationship]
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/d2a35f01-2eac-4900-861f-c4094c9b9556%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-06-10 12:03 ` 'Christoph Steiger' via isar-users
@ 2025-07-14 12:16 ` 'Simone Weiß' via isar-users
2025-07-28 15:13 ` 'MOESSBAUER, Felix' via isar-users
2025-07-29 9:08 ` 'Christoph' via isar-users
0 siblings, 2 replies; 15+ messages in thread
From: 'Simone Weiß' via isar-users @ 2025-07-14 12:16 UTC (permalink / raw)
To: isar-users
[-- Attachment #1.1: Type: text/plain, Size: 25080 bytes --]
Hi,
Are you planing to add Concludedlicense information as per your comment in
the original? Any plan for SPDX3?
On Tuesday, June 10, 2025 at 2:04:03 PM UTC+2 Christoph Steiger wrote:
> FYI Benjamin, Cedric and Mete:
>
> We are currently working on a V2 for this with more or less the same
> functionality and some internal changes. It might be interesting for you
> too. Maybe you could try this version out in your builds and see if
> anything important/nice-to-have is missing in the SBOMs?
>
> > From: Christoph Steiger <christop...@siemens.com>
> >
> > Add a new class to allow generation of software bill of materials
> > (SBOM). Supported are the two standard SBOM formats CycloneDX and SPDX.
> > SBOM generation is enabled per default for all images.
> >
> > Both formats support the minimal usecase of binary packages information
> > and their dependencies. Unfortunately there is no proper way to express
> > the relationships of debian source packages and their corresponding
> > binary packages in the CDX format, so it is left out there.
> >
> > The information included in the SBOM is parsed from the dpkg status
> > file found in the created image.
> >
> > Signed-off-by: Christoph Steiger <christop...@siemens.com>
> > ---
> > meta/classes/create-sbom.bbclass | 49 ++++
> > meta/classes/image.bbclass | 2 +
> > meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++
> > meta/lib/sbom_cdx_types.py | 82 ++++++
> > meta/lib/sbom_spdx_types.py | 95 +++++++
> > 5 files changed, 674 insertions(+)
> > create mode 100644 meta/classes/create-sbom.bbclass
> > create mode 100644 meta/lib/sbom.py
> > create mode 100644 meta/lib/sbom_cdx_types.py
> > create mode 100644 meta/lib/sbom_spdx_types.py
> >
> > diff --git a/meta/classes/create-sbom.bbclass
> b/meta/classes/create-sbom.bbclass
> > new file mode 100644
> > index 00000000..8c647699
> > --- /dev/null
> > +++ b/meta/classes/create-sbom.bbclass
> > @@ -0,0 +1,49 @@
> > +# This software is a part of ISAR.
> > +# Copyright (C) 2025 Siemens AG
> > +#
> > +# SPDX-License-Identifier: MIT
> > +
> > +# sbom type to generate, accepted are "cyclonedx" and "spdx"
> > +SBOM_TYPE ?= "cyclonedx spdx"
> > +
> > +# general user variables
> > +SBOM_DISTRO_SUPPLIER ?= "ISAR"
> > +SBOM_DISTRO_NAME ?= "ISAR-Debian-GNU-Linux"
> > +SBOM_DISTRO_VERSION ?= "1.0.0"
> > +SBOM_DISTRO_SUMMARY ?= "Linux distribution built with ISAR"
> > +SBOM_DOCUMENT_UUID ?= ""
> > +
> > +# SPDX specific user variables
> > +SBOM_SPDX_NAMESPACE_PREFIX ?= "https://spdx.org/spdxdocs"
> > +
> > +SBOM_DEPLOY_BASE = "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
> > +
> > +SBOM_GEN_VERSION = "0.1.0"
> > +
> > +# adapted from the isar-cip-core image_uuid.bbclass
> > +def generate_document_uuid(d):
> > + import uuid
> > +
> > + base_hash = d.getVar("BB_TASKHASH")
> > + if base_hash is None:
> > + bb.warn("no BB_TASKHASH available, SBOM UUID is not reproducible")
> > + return uuid.uuid4()
> > + return str(uuid.UUID(base_hash[:32], version=4))
> > +
> > +python do_create_sbom() {
> > + import sbom
> > +
> > + dpkg_status = d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"
> > + packages = sbom.Package.parse_status_file(dpkg_status)
> > +
> > + if not d.getVar("SBOM_DOCUMENT_UUID"):
> > + d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))
> > +
> > + sbom_type = d.getVar("SBOM_TYPE")
> > + if "cyclonedx" in sbom_type:
> > + sbom.generate(d, packages, sbom.SBOMType.CycloneDX,
> d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
> > + if "spdx" in sbom_type:
> > + sbom.generate(d, packages, sbom.SBOMType.SPDX,
> d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")
> > +}
> > +
> > +addtask do_create_sbom after do_rootfs before do_build
> > diff --git a/meta/classes/image.bbclass b/meta/classes/image.bbclass
> > index 56eca202..e9da6a61 100644
> > --- a/meta/classes/image.bbclass
> > +++ b/meta/classes/image.bbclass
> > @@ -81,6 +81,8 @@ inherit image-postproc-extension
> > inherit image-locales-extension
> > inherit image-account-extension
> >
> > +inherit create-sbom
> > +
> > # Extra space for rootfs in MB
> > ROOTFS_EXTRA ?= "64"
> >
> > diff --git a/meta/lib/sbom.py b/meta/lib/sbom.py
> > new file mode 100644
> > index 00000000..d7c79e43
> > --- /dev/null
> > +++ b/meta/lib/sbom.py
> > @@ -0,0 +1,446 @@
> > +# This software is part of ISAR.
> > +# Copyright (C) 2025 Siemens AG
> > +#
> > +# SPDX-License-Identifier: MIT
> > +
> > +from dataclasses import dataclass
> > +from datetime import datetime
> > +from enum import Enum
> > +from typing import Dict, List, Type
> > +import json
> > +import re
> > +from uuid import uuid4
> > +
> > +import sbom_cdx_types as cdx
> > +import sbom_spdx_types as spdx
> > +
> > +
> > +class SBOMType(Enum):
> > + CycloneDX = (0,)
> > + SPDX = (1,)
> > +
> > +
> > +@dataclass
> > +class SourcePackage:
> > + name: str
> > + version: str | None
> > +
> > + def purl(self):
> > + """Return the PURL of the package."""
> > + return "pkg:deb/debian/{}@{}?arch=source".format(self.name,
> self.version)
> > +
> > + def bom_ref(self, sbom_type: SBOMType) -> str:
> > + """Return a unique BOM reference."""
> > + if sbom_type == SBOMType.CycloneDX:
> > + return cdx.CDXREF_PREFIX + "{}-src".format(self.name)
> > + elif sbom_type == SBOMType.SPDX:
> > + return spdx.SPDX_REF_PREFIX + "{}-src".format(self.name)
> > +
> > + def parse(s: str) -> Type["SourcePackage"]:
> > + split = s.split(" ")
> > + name = split[0]
> > + try:
> > + version = " ".join(split[1:]).strip("()")
> > + except IndexError:
> > + version = None
> > +
> > + return SourcePackage(name=name, version=version)
> > +
> > +
> > +@dataclass
> > +class Dependency:
> > + name: str
> > + version: str | None
> > +
> > + def bom_ref(self, sbom_type: SBOMType) -> str:
> > + """Return a unique BOM reference."""
> > + if sbom_type == SBOMType.CycloneDX:
> > + return cdx.CDX_REF_PREFIX + "{}".format(self.name)
> > + elif sbom_type == SBOMType.SPDX:
> > + return spdx.SPDX_REF_PREFIX + "{}".format(self.name)
> > +
> > + def parse_multiple(s: str) -> List[Type["Dependency"]]:
> > + """Parse a 'Depends' line in the dpkg status file."""
> > + dependencies = []
> > + for entry in s.split(","):
> > + entry = entry.strip()
> > + for entry in entry.split("|"):
> > + split = entry.split("(")
> > + name = split[0].strip()
> > + try:
> > + version = split[1].strip(")")
> > + except IndexError:
> > + version = None
> > + dependencies.append(Dependency(name=name, version=version))
> > +
> > + return dependencies
> > +
> > +
> > +@dataclass
> > +class Package:
> > + """Incomplete representation of a debian package."""
> > +
> > + name: str
> > + section: str
> > + maintainer: str
> > + architecture: str
> > + source: SourcePackage
> > + version: str
> > + depends: List[Dependency]
> > + description: str
> > + homepage: str
> > +
> > + def purl(self) -> str:
> > + """Return the PURL of the package."""
> > + purl = "pkg:deb/debian/{}@{}".format(self.name, self.version)
> > + if self.architecture:
> > + purl = purl + "?arch={}".format(self.architecture)
> > + return purl
> > +
> > + def bom_ref(self, sbom_type: SBOMType) -> str:
> > + """Return a unique BOM reference."""
> > + if sbom_type == SBOMType.CycloneDX:
> > + return cdx.CDX_REF_PREFIX + self.name
> > + elif sbom_type == SBOMType.SPDX:
> > + return spdx.SPDX_REF_PREFIX + self.name
> > +
> > + def parse_status_file(status_file: str) -> List[Type["Package"]]:
> > + """Parse a dpkg status file."""
> > + packages = []
> > + with open(status_file, "r") as f:
> > + name = None
> > + section = None
> > + maintainer = None
> > + architecture = None
> > + source = None
> > + version = None
> > + dependencies = None
> > + description = None
> > + homepage = None
> > + for line in f.readlines():
> > + if line.strip():
> > + if line[0] == " ":
> > + # this is a description line, we ignore it
> > + continue
> > + else:
> > + split = line.split(":")
> > + key = split[0]
> > + value = ":".join(split[1:]).strip()
> > + if key == "Package":
> > + name = value
> > + elif key == "Section":
> > + section = value
> > + elif key == "Maintainer":
> > + maintainer = value
> > + elif key == "Architecture":
> > + architecture = value
> > + elif key == "Source":
> > + source = SourcePackage.parse(value)
> > + elif key == "Version":
> > + version = value
> > + elif key == "Depends":
> > + dependencies = Dependency.parse_multiple(value)
> > + elif key == "Description":
> > + description = value
> > + elif key == "Homepage":
> > + homepage = value
> > + else:
> > + # fixup source version, if not specified it is the same
> > + # as the package version
> > + if source and not source.version:
> > + source.version = version
> > + # empty line means new package, so finish the current one
> > + packages.append(
> > + Package(
> > + name=name,
> > + section=section,
> > + maintainer=maintainer,
> > + architecture=architecture,
> > + source=source,
> > + version=version,
> > + depends=dependencies,
> > + description=description,
> > + homepage=homepage,
> > + )
> > + )
> > + name = None
> > + section = None
> > + maintainer = None
> > + architecture = None
> > + source = None
> > + version = None
> > + dependencies = None
> > + description = None
> > + homepage = None
> > +
> > + return packages
> > +
> > +
> > +def cyclonedx_bom(d, packages: List[Package]) -> Dict:
> > + """Return a valid CycloneDX SBOM."""
> > + data = []
> > + dependencies = []
> > +
> > + pattern =
> re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
> > + for package in packages:
> > + match = pattern.match(package.maintainer)
> > + supplier = cdx.CDXSupplier(name=match["supplier_name"])
> > + supplier_email = match["supplier_email"]
> > + if supplier_email:
> > + supplier.contact = [cdx.CDXSupplierContact(email=supplier_email)]
> > + entry = cdx.CDXComponent(
> > + type=cdx.CDX_COMPONENT_TYPE_LIBRARY,
> > + bom_ref=package.bom_ref(SBOMType.CycloneDX),
> > + supplier=supplier,
> > + name=package.name,
> > + version=package.version,
> > + description=package.description,
> > + purl=package.purl(),
> > + )
> > + if package.homepage:
> > + entry.externalReferences = (
> > + cdx.CDXExternalReference(
> > + url=package.homepage,
> > + type=cdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,
> > + comment="homepage",
> > + ),
> > + )
> > + data.append(entry)
> > +
> > + distro_bom_ref = cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
> > + distro_dependencies = []
> > + # after we have found all packages we can start to resolve dependencies
> > + package_names = [package.name for package in packages]
> > + for package in packages:
> > + distro_dependencies.append(package.bom_ref(SBOMType.CycloneDX))
> > + if package.depends:
> > + deps = []
> > + for dep in package.depends:
> > + dep_bom_ref = dep.bom_ref(SBOMType.CycloneDX)
> > + # it is possibe to specify the same package multiple times, but
> > + # in different versions
> > + if dep.name in package_names and dep_bom_ref not in deps:
> > + deps.append(dep_bom_ref)
> > + else:
> > + # this might happen if we have optional dependencies
> > + continue
> > + dependency = cdx.CDXDependency(
> > + ref=package.bom_ref(SBOMType.CycloneDX),
> > + dependsOn=deps,
> > + )
> > + dependencies.append(dependency)
> > + dependency = cdx.CDXDependency(
> > + ref=distro_bom_ref,
> > + dependsOn=distro_dependencies,
> > + )
> > + dependencies.append(dependency)
> > +
> > + doc_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> > + distro_component = cdx.CDXComponent(
> > + type=cdx.CDX_COMPONENT_TYPE_OS,
> > + bom_ref=cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME"),
> > + supplier=cdx.CDXSupplier(name=d.getVar("SBOM_DISTRO_SUPPLIER")),
> > + name=d.getVar("SBOM_DISTRO_NAME"),
> > + version=d.getVar("SBOM_DISTRO_VERSION"),
> > + description=d.getVar("SBOM_DISTRO_SUMMARY"),
> > + )
> > +
> > + timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> > + bom = cdx.CDXBOM(
> > + bomFormat=cdx.CDX_BOM_FORMAT,
> > + specVersion=cdx.CDX_SPEC_VERSION,
> > + serialNumber="urn:uuid:{}".format(doc_uuid if doc_uuid else uuid4()),
> > + version=1,
> > + metadata=cdx.CDXBOMMetadata(
> > + timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> > + component=distro_component,
> > + tools=cdx.CDXBOMMetadataTool(
> > + components=[
> > + cdx.CDXComponent(
> > + type=cdx.CDX_COMPONENT_TYPE_APPLICATION,
> > + name="ISAR SBOM Generator",
> > + version=d.getVar("SBOM_GEN_VERSION"),
> > + )
> > + ],
> > + ),
> > + ),
> > + components=data,
> > + dependencies=dependencies,
> > + )
> > + return bom
> > +
> > +
> > +def spdx_bom(d, packages: List[Package]) -> Dict:
> > + "Return a valid SPDX SBOM."
> > +
> > + data = []
> > + # create a "fake" entry for the distribution
> > + distro_ref = spdx.SPDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
> > + distro_package = spdx.SPDXPackage(
> > + SPDXID=distro_ref,
> > + name=d.getVar("SBOM_DISTRO_NAME"),
> > + versionInfo=d.getVar("SBOM_DISTRO_VERSION"),
> > + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_OS,
> > + supplier="Organization: {}".format(d.getVar("SBOM_DISTRO_SUPPLIER")),
> > + downloadLocation=spdx.SPDX_NOASSERTION,
> > + filesAnalyzed=False,
> > + licenseConcluded=spdx.SPDX_NOASSERTION,
> > + licenseDeclared=spdx.SPDX_NOASSERTION,
> > + copyrightText=spdx.SPDX_NOASSERTION,
> > + summary=d.getVar("SBOM_DISTRO_SUMMARY"),
> > + )
> > +
> > + data.append(distro_package)
> > +
> > + pattern =
> re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
> > + for package in packages:
> > + match = pattern.match(package.maintainer)
> > + supplier_name = match["supplier_name"]
> > + supplier_email = match["supplier_email"]
> > + if any([cue in supplier_name.lower() for cue in
> spdx.SPDX_SUPPLIER_ORG_CUE]):
> > + supplier = "Organization: {}".format(supplier_name)
> > + else:
> > + supplier = "Person: {}".format(supplier_name)
> > + if supplier_email:
> > + supplier += "({})".format(supplier_email)
> > +
> > + entry = spdx.SPDXPackage(
> > + SPDXID=package.bom_ref(SBOMType.SPDX),
> > + name=package.name,
> > + versionInfo=package.version,
> > + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_LIBRARY,
> > + supplier=supplier,
> > + downloadLocation=spdx.SPDX_NOASSERTION,
> > + filesAnalyzed=False,
> > + # TODO: it should be possible to conclude license/copyright
> > + # information, we could look e.g. in /usr/share/doc/*/copyright
> > + licenseConcluded=spdx.SPDX_NOASSERTION,
> > + licenseDeclared=spdx.SPDX_NOASSERTION,
> > + copyrightText=spdx.SPDX_NOASSERTION,
> > + summary=package.description,
> > + externalRefs=[
> > + spdx.SPDXExternalRef(
> > + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> > + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> > + referenceLocator=package.purl(),
> > + )
> > + ],
> > + )
> > + if package.homepage:
> > + entry.homepage = package.homepage
> > + data.append(entry)
> > +
> > + if package.source:
> > + src_entry = spdx.SPDXPackage(
> > + SPDXID=package.source.bom_ref(SBOMType.SPDX),
> > + name=package.source.name,
> > + versionInfo=package.source.version,
> > + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_SRC,
> > + supplier=supplier,
> > + downloadLocation=spdx.SPDX_NOASSERTION,
> > + filesAnalyzed=False,
> > + licenseConcluded=spdx.SPDX_NOASSERTION,
> > + licenseDeclared=spdx.SPDX_NOASSERTION,
> > + copyrightText=spdx.SPDX_NOASSERTION,
> > + summary="debian source code package '{}'".format(package.source.name),
> > + externalRefs=[
> > + spdx.SPDXExternalRef(
> > + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> > + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> > + referenceLocator=package.source.purl(),
> > + )
> > + ],
> > + )
> > + # source packages might be referenced multiple times
> > + if src_entry not in data:
> > + data.append(src_entry)
> > +
> > + relationships = []
> > + # after we have found all packages we can start to resolve dependencies
> > + package_names = [package.name for package in packages]
> > + for package in packages:
> > + relationships.append(
> > + spdx.SPDXRelationship(
> > + spdxElementId=package.bom_ref(SBOMType.SPDX),
> > + relatedSpdxElement=distro_ref,
> > + relationshipType=spdx.SPDX_RELATIONSHIP_PACKAGE_OF,
> > + )
> > + )
> > + if package.depends:
> > + for dep in package.depends:
> > + if dep.name in package_names:
> > + relationship = spdx.SPDXRelationship(
> > + spdxElementId=package.bom_ref(SBOMType.SPDX),
> > + relatedSpdxElement=dep.bom_ref(SBOMType.SPDX),
> > + relationshipType=spdx.SPDX_RELATIONSHIP_DEPENDS_ON,
> > + )
> > + relationships.append(relationship)
> > + else:
> > + # this might happen if we have optional dependencies
> > + pass
> > + if package.source:
> > + relationship = spdx.SPDXRelationship(
> > + spdxElementId=package.source.bom_ref(SBOMType.SPDX),
> > + relatedSpdxElement=package.bom_ref(SBOMType.SPDX),
> > + relationshipType=spdx.SPDX_RELATIONSHIP_GENERATES,
> > + )
> > + relationships.append(relationship)
> > + relationships.append(
> > + spdx.SPDXRelationship(
> > + spdxElementId=spdx.SPDX_REF_DOCUMENT,
> > + relatedSpdxElement=distro_ref,
> > + relationshipType=spdx.SPDX_RELATIONSHIP_DESCRIBES,
> > + )
> > + )
> > +
> > + namespace_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> > + timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> > + bom = spdx.SPDXBOM(
> > + SPDXID=spdx.SPDX_REF_DOCUMENT,
> > + spdxVersion=spdx.SPDX_VERSION,
> > + creationInfo=spdx.SPDXCreationInfo(
> > + comment="This document has been generated as part of an ISAR build.",
> > + creators=[
> > + "Tool: ISAR SBOM Generator - {}".format(d.getVar("SBOM_GEN_VERSION"))
> > + ],
> > + created=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> > + ),
> > + name=d.getVar("SBOM_DISTRO_NAME"),
> > + dataLicense="CC0-1.0",
> > + documentNamespace="{}/{}-{}".format(
> > + d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),
> > + d.getVar("SBOM_DISTRO_NAME"),
> > + namespace_uuid if namespace_uuid else uuid4(),
> > + ),
> > + packages=data,
> > + relationships=relationships,
> > + )
> > + return bom
> > +
> > +
> > +def fixup_dict(o):
> > + """Apply fixups for the BOMs.
> > +
> > + This is necessary for some field names and to remove fields with a None
> > + value.
> > + """
> > + dct = vars(o)
> > + new_dct = {}
> > + for k, v in dct.items():
> > + # remove fields with no content
> > + if v is not None:
> > + # we can not name our fields with dashes, so convert them
> > + k = k.replace("_", "-")
> > + new_dct[k] = v
> > + return new_dct
> > +
> > +
> > +def generate(d, packages: List[Package], sbom_type: SBOMType, out: str):
> > + """Generate a SBOM."""
> > + if sbom_type == SBOMType.CycloneDX:
> > + bom = cyclonedx_bom(d, packages)
> > + elif sbom_type == SBOMType.SPDX:
> > + bom = spdx_bom(d, packages)
> > +
> > + with open(out, "w") as bom_file:
> > + json.dump(bom, bom_file, indent=2, default=fixup_dict, sort_keys=True)
> > diff --git a/meta/lib/sbom_cdx_types.py b/meta/lib/sbom_cdx_types.py
> > new file mode 100644
> > index 00000000..4911cc23
> > --- /dev/null
> > +++ b/meta/lib/sbom_cdx_types.py
> > @@ -0,0 +1,82 @@
> > +# This software is part of ISAR.
> > +# Copyright (C) 2025 Siemens AG
> > +#
> > +# SPDX-License-Identifier: MIT
> > +
> > +from dataclasses import dataclass
> > +from typing import List, Optional
> > +
> > +# Minimal implementation of some CycloneDX SBOM types.
> > +# Please mind that (almost) none of these types are complete, they only
> > +# reflect what was strictly necessary for immediate SBOM creation
> > +
> > +CDX_BOM_FORMAT = "CycloneDX"
> > +CDX_SPEC_VERSION = "1.6"
> > +
> > +CDX_REF_PREFIX = "CDXRef-"
> > +
> > +CDX_PACKAGE_EXTREF_TYPE_WEBSITE = "website"
> > +
> > +CDX_COMPONENT_TYPE_LIBRARY = "library"
> > +CDX_COMPONENT_TYPE_APPLICATION = "application"
> > +CDX_COMPONENT_TYPE_OS = "operating-system"
> > +
> > +
> > +@dataclass
> > +class CDXDependency:
> > + ref: str
> > + dependsOn: Optional[str]
> > +
> > +
> > +@dataclass
> > +class CDXExternalReference:
> > + url: str
> > + type: str
> > + comment: Optional[str] = None
> > +
> > +
> > +@dataclass
> > +class CDXSupplierContact:
> > + email: Optional[str] = None
> > +
> > +
> > +@dataclass
> > +class CDXSupplier:
> > + name: Optional[str] = None
> > + contact: Optional[CDXSupplierContact] = None
> > +
> > +
> > +@dataclass
> > +class CDXComponent:
> > + type: str
> > + name: str
> > + bom_ref: Optional[str] = None
> > + supplier: Optional[str] = None
> > + version: Optional[CDXSupplier] = None
> > + description: Optional[str] = None
> > + purl: Optional[str] = None
> > + externalReferences: Optional[List[CDXExternalReference]] = None
> > + homepage: Optional[str] = None
> > +
> > +
> > +@dataclass
> > +class CDXBOMMetadataTool:
> > + components: Optional[List[CDXComponent]]
> > +
> > +
> > +@dataclass
> > +class CDXBOMMetadata:
> > + timestamp: Optional[str] = None
> > + component: Optional[str] = None
> > + tools: Optional[List[CDXBOMMetadataTool]] = None
> > +
> > +
> > +@dataclass
> > +class CDXBOM:
> > + bomFormat: str
> > + specVersion: str
> > + serialNumber: Optional[str] = None
> > + version: Optional[str] = None
> > + metadata: Optional[CDXBOMMetadata] = None
> > + components: Optional[List[CDXComponent]] = None
> > + dependencies: Optional[List[CDXDependency]] = None
> > diff --git a/meta/lib/sbom_spdx_types.py b/meta/lib/sbom_spdx_types.py
> > new file mode 100644
> > index 00000000..efd7cc0c
> > --- /dev/null
> > +++ b/meta/lib/sbom_spdx_types.py
> > @@ -0,0 +1,95 @@
> > +# This software is part of ISAR.
> > +# Copyright (C) 2025 Siemens AG
> > +#
> > +# SPDX-License-Identifier: MIT
> > +
> > +from dataclasses import dataclass
> > +from typing import List, Optional
> > +
> > +# Minimal implementation of some SPDX SBOM types.
> > +# Please mind that (almost) none of these types are complete, they only
> > +# reflect what was strictly necessary for immediate SBOM creation
> > +
> > +SPDX_VERSION = "SPDX-2.3"
> > +
> > +SPDX_REF_PREFIX = "SPDXRef-"
> > +
> > +SPDX_REF_DOCUMENT = "SPDXRef-DOCUMENT"
> > +
> > +SPDX_PACKAGE_PURPOSE_LIBRARY = "LIBRARY"
> > +SPDX_PACKAGE_PURPOSE_OS = "OPERATING_SYSTEM"
> > +SPDX_PACKAGE_PURPOSE_SRC = "SOURCE"
> > +
> > +SPDX_NOASSERTION = "NOASSERTION"
> > +
> > +SPDX_RELATIONSHIP_DEPENDS_ON = "DEPENDS_ON"
> > +SPDX_RELATIONSHIP_PACKAGE_OF = "PACKAGE_OF"
> > +SPDX_RELATIONSHIP_GENERATES = "GENERATES"
> > +SPDX_RELATIONSHIP_DESCRIBES = "DESCRIBES"
> > +
> > +SPDX_REFERENCE_CATEGORY_PKG_MANAGER = "PACKAGE_MANAGER"
> > +SPDX_REFERENCE_TYPE_PURL = "purl"
> > +
> > +# cues for an organization in the maintainer name
> > +SPDX_SUPPLIER_ORG_CUE = [
> > + "maintainers",
> > + "group",
> > + "developers",
> > + "team",
> > + "project",
> > + "task force",
> > + "strike force",
> > + "packagers",
> > +]
> > +
> > +
> > +@dataclass
> > +class SPDXRelationship:
> > + spdxElementId: str
> > + relatedSpdxElement: str
> > + relationshipType: str
> > +
> > +
> > +@dataclass
> > +class SPDXExternalRef:
> > + referenceCategory: str
> > + referenceType: str
> > + referenceLocator: str
> > +
> > +
> > +@dataclass
> > +class SPDXPackage:
> > + SPDXID: str
> > + name: str
> > + downloadLocation: str
> > + filesAnalyzed: Optional[bool] = False
> > + versionInfo: Optional[str] = None
> > + homepage: Optional[str] = None
> > + primaryPackagePurpose: Optional[str] = None
> > + supplier: Optional[str] = None
> > + licenseConcluded: Optional[str] = None
> > + licenseDeclared: Optional[str] = None
> > + copyrightText: Optional[str] = None
> > + summary: Optional[str] = None
> > + externalRefs: Optional[List[SPDXExternalRef]] = None
> > +
> > +
> > +@dataclass
> > +class SPDXCreationInfo:
> > + created: str
> > + comment: Optional[str] = None
> > + creators: List[str] = None
> > +
> > +
> > +@dataclass
> > +class SPDXBOM:
> > + """Incomplete BOM as of SPDX spec v2.3."""
> > +
> > + SPDXID: str
> > + spdxVersion: str
> > + creationInfo: SPDXCreationInfo
> > + name: str
> > + dataLicense: str
> > + documentNamespace: str
> > + packages: List[SPDXPackage]
> > + relationships: List[SPDXRelationship]
>
>
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/0f4c261e-305d-467d-92a2-2fee7848571fn%40googlegroups.com.
[-- Attachment #1.2: Type: text/html, Size: 39503 bytes --]
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-07-14 12:16 ` 'Simone Weiß' via isar-users
@ 2025-07-28 15:13 ` 'MOESSBAUER, Felix' via isar-users
2025-07-29 9:08 ` 'Christoph' via isar-users
1 sibling, 0 replies; 15+ messages in thread
From: 'MOESSBAUER, Felix' via isar-users @ 2025-07-28 15:13 UTC (permalink / raw)
To: simone.weiss, isar-users
On Mon, 2025-07-14 at 05:16 -0700, 'Simone Weiß' via isar-users wrote:
> Hi,
>
> Are you planing to add Concludedlicense information as per your
> comment in the original? Any plan for SPDX3?
Hi, we are still collecting user feedback regarding what is needed (and
possible to acquire in an automated way). As already written in the
initial commit message, providing license information is tricky - if
not impossible:
Source components are often not licensed under a single license, but on
a per-file basis. The debian/copyright file precisely describes this,
but it is far more complex than putting in an SPDX identifier for the
whole package.
For binary packages, Debian does not declare a dedicated license, but
just includes the licenses (and copyright information) of the source
package. However, Debian does not track which source file is used to
produce a binary. By that, the end-user needs to make the final
conclusion.
The whole licensing topic is anyways way beyond what ISAR can do and
questions regarding this should likely better be asked on
debian-legal@lists.debian.org. The SPDX generator of ISAR just extracts
the data from the dpkg status file and reworks that into a (valid) SPDX
/ CycloneDX document.
Best regards,
Felix
>
>
> On Tuesday, June 10, 2025 at 2:04:03 PM UTC+2 Christoph Steiger
> wrote:
> > FYI Benjamin, Cedric and Mete:
> >
> > We are currently working on a V2 for this with more or less the
> > same
> > functionality and some internal changes. It might be interesting
> > for you
> > too. Maybe you could try this version out in your builds and see if
> > anything important/nice-to-have is missing in the SBOMs?
> >
> > > From: Christoph Steiger <christop...@siemens.com>
> > >
> > > Add a new class to allow generation of software bill of materials
> > > (SBOM). Supported are the two standard SBOM formats CycloneDX and
> > > SPDX.
> > > SBOM generation is enabled per default for all images.
> > >
> > > Both formats support the minimal usecase of binary packages
> > > information
> > > and their dependencies. Unfortunately there is no proper way to
> > > express
> > > the relationships of debian source packages and their
> > > corresponding
> > > binary packages in the CDX format, so it is left out there.
> > >
> > > The information included in the SBOM is parsed from the dpkg
> > > status
> > > file found in the created image.
> > >
> > > Signed-off-by: Christoph Steiger <christop...@siemens.com>
> > > ---
> > > meta/classes/create-sbom.bbclass | 49 ++++
> > > meta/classes/image.bbclass | 2 +
> > > meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++
> > > meta/lib/sbom_cdx_types.py | 82 ++++++
> > > meta/lib/sbom_spdx_types.py | 95 +++++++
> > > 5 files changed, 674 insertions(+)
> > > create mode 100644 meta/classes/create-sbom.bbclass
> > > create mode 100644 meta/lib/sbom.py
> > > create mode 100644 meta/lib/sbom_cdx_types.py
> > > create mode 100644 meta/lib/sbom_spdx_types.py
> > >
> > > diff --git a/meta/classes/create-sbom.bbclass
> > > b/meta/classes/create-sbom.bbclass
> > > new file mode 100644
> > > index 00000000..8c647699
> > > --- /dev/null
> > > +++ b/meta/classes/create-sbom.bbclass
> > > @@ -0,0 +1,49 @@
> > > +# This software is a part of ISAR.
> > > +# Copyright (C) 2025 Siemens AG
> > > +#
> > > +# SPDX-License-Identifier: MIT
> > > +
> > > +# sbom type to generate, accepted are "cyclonedx" and "spdx"
> > > +SBOM_TYPE ?= "cyclonedx spdx"
> > > +
> > > +# general user variables
> > > +SBOM_DISTRO_SUPPLIER ?= "ISAR"
> > > +SBOM_DISTRO_NAME ?= "ISAR-Debian-GNU-Linux"
> > > +SBOM_DISTRO_VERSION ?= "1.0.0"
> > > +SBOM_DISTRO_SUMMARY ?= "Linux distribution built with ISAR"
> > > +SBOM_DOCUMENT_UUID ?= ""
> > > +
> > > +# SPDX specific user variables
> > > +SBOM_SPDX_NAMESPACE_PREFIX ?= "https://spdx.org/spdxdocs"
> > > +
> > > +SBOM_DEPLOY_BASE = "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
> > > +
> > > +SBOM_GEN_VERSION = "0.1.0"
> > > +
> > > +# adapted from the isar-cip-core image_uuid.bbclass
> > > +def generate_document_uuid(d):
> > > + import uuid
> > > +
> > > + base_hash = d.getVar("BB_TASKHASH")
> > > + if base_hash is None:
> > > + bb.warn("no BB_TASKHASH available, SBOM UUID is not
> > > reproducible")
> > > + return uuid.uuid4()
> > > + return str(uuid.UUID(base_hash[:32], version=4))
> > > +
> > > +python do_create_sbom() {
> > > + import sbom
> > > +
> > > + dpkg_status = d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"
> > > + packages = sbom.Package.parse_status_file(dpkg_status)
> > > +
> > > + if not d.getVar("SBOM_DOCUMENT_UUID"):
> > > + d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))
> > > +
> > > + sbom_type = d.getVar("SBOM_TYPE")
> > > + if "cyclonedx" in sbom_type:
> > > + sbom.generate(d, packages, sbom.SBOMType.CycloneDX,
> > > d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
> > > + if "spdx" in sbom_type:
> > > + sbom.generate(d, packages, sbom.SBOMType.SPDX,
> > > d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")
> > > +}
> > > +
> > > +addtask do_create_sbom after do_rootfs before do_build
> > > diff --git a/meta/classes/image.bbclass
> > > b/meta/classes/image.bbclass
> > > index 56eca202..e9da6a61 100644
> > > --- a/meta/classes/image.bbclass
> > > +++ b/meta/classes/image.bbclass
> > > @@ -81,6 +81,8 @@ inherit image-postproc-extension
> > > inherit image-locales-extension
> > > inherit image-account-extension
> > >
> > > +inherit create-sbom
> > > +
> > > # Extra space for rootfs in MB
> > > ROOTFS_EXTRA ?= "64"
> > >
> > > diff --git a/meta/lib/sbom.py b/meta/lib/sbom.py
> > > new file mode 100644
> > > index 00000000..d7c79e43
> > > --- /dev/null
> > > +++ b/meta/lib/sbom.py
> > > @@ -0,0 +1,446 @@
> > > +# This software is part of ISAR.
> > > +# Copyright (C) 2025 Siemens AG
> > > +#
> > > +# SPDX-License-Identifier: MIT
> > > +
> > > +from dataclasses import dataclass
> > > +from datetime import datetime
> > > +from enum import Enum
> > > +from typing import Dict, List, Type
> > > +import json
> > > +import re
> > > +from uuid import uuid4
> > > +
> > > +import sbom_cdx_types as cdx
> > > +import sbom_spdx_types as spdx
> > > +
> > > +
> > > +class SBOMType(Enum):
> > > + CycloneDX = (0,)
> > > + SPDX = (1,)
> > > +
> > > +
> > > +@dataclass
> > > +class SourcePackage:
> > > + name: str
> > > + version: str | None
> > > +
> > > + def purl(self):
> > > + """Return the PURL of the package."""
> > > + return
> > > "pkg:deb/debian/{}@{}?arch=source".format(self.name,
> > > self.version)
> > > +
> > > + def bom_ref(self, sbom_type: SBOMType) -> str:
> > > + """Return a unique BOM reference."""
> > > + if sbom_type == SBOMType.CycloneDX:
> > > + return cdx.CDXREF_PREFIX + "{}-
> > > src".format(self.name)
> > > + elif sbom_type == SBOMType.SPDX:
> > > + return spdx.SPDX_REF_PREFIX + "{}-
> > > src".format(self.name)
> > > +
> > > + def parse(s: str) -> Type["SourcePackage"]:
> > > + split = s.split(" ")
> > > + name = split[0]
> > > + try:
> > > + version = " ".join(split[1:]).strip("()")
> > > + except IndexError:
> > > + version = None
> > > +
> > > + return SourcePackage(name=name, version=version)
> > > +
> > > +
> > > +@dataclass
> > > +class Dependency:
> > > + name: str
> > > + version: str | None
> > > +
> > > + def bom_ref(self, sbom_type: SBOMType) -> str:
> > > + """Return a unique BOM reference."""
> > > + if sbom_type == SBOMType.CycloneDX:
> > > + return cdx.CDX_REF_PREFIX + "{}".format(self.name)
> > > + elif sbom_type == SBOMType.SPDX:
> > > + return spdx.SPDX_REF_PREFIX + "{}".format(self.name)
> > > +
> > > + def parse_multiple(s: str) -> List[Type["Dependency"]]:
> > > + """Parse a 'Depends' line in the dpkg status file."""
> > > + dependencies = []
> > > + for entry in s.split(","):
> > > + entry = entry.strip()
> > > + for entry in entry.split("|"):
> > > + split = entry.split("(")
> > > + name = split[0].strip()
> > > + try:
> > > + version = split[1].strip(")")
> > > + except IndexError:
> > > + version = None
> > > + dependencies.append(Dependency(name=name, version=version))
> > > +
> > > + return dependencies
> > > +
> > > +
> > > +@dataclass
> > > +class Package:
> > > + """Incomplete representation of a debian package."""
> > > +
> > > + name: str
> > > + section: str
> > > + maintainer: str
> > > + architecture: str
> > > + source: SourcePackage
> > > + version: str
> > > + depends: List[Dependency]
> > > + description: str
> > > + homepage: str
> > > +
> > > + def purl(self) -> str:
> > > + """Return the PURL of the package."""
> > > + purl = "pkg:deb/debian/{}@{}".format(self.name,
> > > self.version)
> > > + if self.architecture:
> > > + purl = purl + "?arch={}".format(self.architecture)
> > > + return purl
> > > +
> > > + def bom_ref(self, sbom_type: SBOMType) -> str:
> > > + """Return a unique BOM reference."""
> > > + if sbom_type == SBOMType.CycloneDX:
> > > + return cdx.CDX_REF_PREFIX + self.name
> >
> >
> > > + elif sbom_type == SBOMType.SPDX:
> > > + return spdx.SPDX_REF_PREFIX + self.name
> >
> >
> > > +
> > > + def parse_status_file(status_file: str) ->
> > > List[Type["Package"]]:
> > > + """Parse a dpkg status file."""
> > > + packages = []
> > > + with open(status_file, "r") as f:
> > > + name = None
> > > + section = None
> > > + maintainer = None
> > > + architecture = None
> > > + source = None
> > > + version = None
> > > + dependencies = None
> > > + description = None
> > > + homepage = None
> > > + for line in f.readlines():
> > > + if line.strip():
> > > + if line[0] == " ":
> > > + # this is a description line, we ignore it
> > > + continue
> > > + else:
> > > + split = line.split(":")
> > > + key = split[0]
> > > + value = ":".join(split[1:]).strip()
> > > + if key == "Package":
> > > + name = value
> > > + elif key == "Section":
> > > + section = value
> > > + elif key == "Maintainer":
> > > + maintainer = value
> > > + elif key == "Architecture":
> > > + architecture = value
> > > + elif key == "Source":
> > > + source = SourcePackage.parse(value)
> > > + elif key == "Version":
> > > + version = value
> > > + elif key == "Depends":
> > > + dependencies = Dependency.parse_multiple(value)
> > > + elif key == "Description":
> > > + description = value
> > > + elif key == "Homepage":
> > > + homepage = value
> > > + else:
> > > + # fixup source version, if not specified it is the same
> > > + # as the package version
> > > + if source and not source.version:
> > > + source.version = version
> > > + # empty line means new package, so finish the current one
> > > + packages.append(
> > > + Package(
> > > + name=name,
> > > + section=section,
> > > + maintainer=maintainer,
> > > + architecture=architecture,
> > > + source=source,
> > > + version=version,
> > > + depends=dependencies,
> > > + description=description,
> > > + homepage=homepage,
> > > + )
> > > + )
> > > + name = None
> > > + section = None
> > > + maintainer = None
> > > + architecture = None
> > > + source = None
> > > + version = None
> > > + dependencies = None
> > > + description = None
> > > + homepage = None
> > > +
> > > + return packages
> > > +
> > > +
> > > +def cyclonedx_bom(d, packages: List[Package]) -> Dict:
> > > + """Return a valid CycloneDX SBOM."""
> > > + data = []
> > > + dependencies = []
> > > +
> > > + pattern =
> > > re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\
> > > >)?")
> > > + for package in packages:
> > > + match = pattern.match(package.maintainer)
> > > + supplier = cdx.CDXSupplier(name=match["supplier_name"])
> > > + supplier_email = match["supplier_email"]
> > > + if supplier_email:
> > > + supplier.contact =
> > > [cdx.CDXSupplierContact(email=supplier_email)]
> > > + entry = cdx.CDXComponent(
> > > + type=cdx.CDX_COMPONENT_TYPE_LIBRARY,
> > > + bom_ref=package.bom_ref(SBOMType.CycloneDX),
> > > + supplier=supplier,
> > > + name=package.name,
> > > + version=package.version,
> > > + description=package.description,
> > > + purl=package.purl(),
> > > + )
> > > + if package.homepage:
> > > + entry.externalReferences = (
> > > + cdx.CDXExternalReference(
> > > + url=package.homepage,
> > > + type=cdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,
> > > + comment="homepage",
> > > + ),
> > > + )
> > > + data.append(entry)
> > > +
> > > + distro_bom_ref = cdx.CDX_REF_PREFIX +
> > > d.getVar("SBOM_DISTRO_NAME")
> > > + distro_dependencies = []
> > > + # after we have found all packages we can start to resolve
> > > dependencies
> > > + package_names = [package.name for package in packages]
> > > + for package in packages:
> > > + distro_dependencies.append(package.bom_ref(SBOMType.CycloneDX))
> > > + if package.depends:
> > > + deps = []
> > > + for dep in package.depends:
> > > + dep_bom_ref = dep.bom_ref(SBOMType.CycloneDX)
> > > + # it is possibe to specify the same package multiple times, but
> > > + # in different versions
> > > + if dep.name in package_names and dep_bom_ref not
> > > in deps:
> > > + deps.append(dep_bom_ref)
> > > + else:
> > > + # this might happen if we have optional dependencies
> > > + continue
> > > + dependency = cdx.CDXDependency(
> > > + ref=package.bom_ref(SBOMType.CycloneDX),
> > > + dependsOn=deps,
> > > + )
> > > + dependencies.append(dependency)
> > > + dependency = cdx.CDXDependency(
> > > + ref=distro_bom_ref,
> > > + dependsOn=distro_dependencies,
> > > + )
> > > + dependencies.append(dependency)
> > > +
> > > + doc_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> > > + distro_component = cdx.CDXComponent(
> > > + type=cdx.CDX_COMPONENT_TYPE_OS,
> > > + bom_ref=cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME"),
> > > +
> > > supplier=cdx.CDXSupplier(name=d.getVar("SBOM_DISTRO_SUPPLIER")),
> > > + name=d.getVar("SBOM_DISTRO_NAME"),
> > > + version=d.getVar("SBOM_DISTRO_VERSION"),
> > > + description=d.getVar("SBOM_DISTRO_SUMMARY"),
> > > + )
> > > +
> > > + timestamp =
> > > datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> > > + bom = cdx.CDXBOM(
> > > + bomFormat=cdx.CDX_BOM_FORMAT,
> > > + specVersion=cdx.CDX_SPEC_VERSION,
> > > + serialNumber="urn:uuid:{}".format(doc_uuid if doc_uuid else
> > > uuid4()),
> > > + version=1,
> > > + metadata=cdx.CDXBOMMetadata(
> > > + timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> > > + component=distro_component,
> > > + tools=cdx.CDXBOMMetadataTool(
> > > + components=[
> > > + cdx.CDXComponent(
> > > + type=cdx.CDX_COMPONENT_TYPE_APPLICATION,
> > > + name="ISAR SBOM Generator",
> > > + version=d.getVar("SBOM_GEN_VERSION"),
> > > + )
> > > + ],
> > > + ),
> > > + ),
> > > + components=data,
> > > + dependencies=dependencies,
> > > + )
> > > + return bom
> > > +
> > > +
> > > +def spdx_bom(d, packages: List[Package]) -> Dict:
> > > + "Return a valid SPDX SBOM."
> > > +
> > > + data = []
> > > + # create a "fake" entry for the distribution
> > > + distro_ref = spdx.SPDX_REF_PREFIX +
> > > d.getVar("SBOM_DISTRO_NAME")
> > > + distro_package = spdx.SPDXPackage(
> > > + SPDXID=distro_ref,
> > > + name=d.getVar("SBOM_DISTRO_NAME"),
> > > + versionInfo=d.getVar("SBOM_DISTRO_VERSION"),
> > > + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_OS,
> > > + supplier="Organization:
> > > {}".format(d.getVar("SBOM_DISTRO_SUPPLIER")),
> > > + downloadLocation=spdx.SPDX_NOASSERTION,
> > > + filesAnalyzed=False,
> > > + licenseConcluded=spdx.SPDX_NOASSERTION,
> > > + licenseDeclared=spdx.SPDX_NOASSERTION,
> > > + copyrightText=spdx.SPDX_NOASSERTION,
> > > + summary=d.getVar("SBOM_DISTRO_SUMMARY"),
> > > + )
> > > +
> > > + data.append(distro_package)
> > > +
> > > + pattern =
> > > re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\
> > > >)?")
> > > + for package in packages:
> > > + match = pattern.match(package.maintainer)
> > > + supplier_name = match["supplier_name"]
> > > + supplier_email = match["supplier_email"]
> > > + if any([cue in supplier_name.lower() for cue in
> > > spdx.SPDX_SUPPLIER_ORG_CUE]):
> > > + supplier = "Organization: {}".format(supplier_name)
> > > + else:
> > > + supplier = "Person: {}".format(supplier_name)
> > > + if supplier_email:
> > > + supplier += "({})".format(supplier_email)
> > > +
> > > + entry = spdx.SPDXPackage(
> > > + SPDXID=package.bom_ref(SBOMType.SPDX),
> > > + name=package.name,
> > > + versionInfo=package.version,
> > > + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_LIBRARY,
> > > + supplier=supplier,
> > > + downloadLocation=spdx.SPDX_NOASSERTION,
> > > + filesAnalyzed=False,
> > > + # TODO: it should be possible to conclude license/copyright
> > > + # information, we could look e.g. in /usr/share/doc/*/copyright
> > > + licenseConcluded=spdx.SPDX_NOASSERTION,
> > > + licenseDeclared=spdx.SPDX_NOASSERTION,
> > > + copyrightText=spdx.SPDX_NOASSERTION,
> > > + summary=package.description,
> > > + externalRefs=[
> > > + spdx.SPDXExternalRef(
> > > + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> > > + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> > > + referenceLocator=package.purl(),
> > > + )
> > > + ],
> > > + )
> > > + if package.homepage:
> > > + entry.homepage = package.homepage
> > > + data.append(entry)
> > > +
> > > + if package.source:
> > > + src_entry = spdx.SPDXPackage(
> > > + SPDXID=package.source.bom_ref(SBOMType.SPDX),
> > > + name=package.source.name,
> > > + versionInfo=package.source.version,
> > > + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_SRC,
> > > + supplier=supplier,
> > > + downloadLocation=spdx.SPDX_NOASSERTION,
> > > + filesAnalyzed=False,
> > > + licenseConcluded=spdx.SPDX_NOASSERTION,
> > > + licenseDeclared=spdx.SPDX_NOASSERTION,
> > > + copyrightText=spdx.SPDX_NOASSERTION,
> > > + summary="debian source code package
> > > '{}'".format(package.source.name),
> > > + externalRefs=[
> > > + spdx.SPDXExternalRef(
> > > + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> > > + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> > > + referenceLocator=package.source.purl(),
> > > + )
> > > + ],
> > > + )
> > > + # source packages might be referenced multiple times
> > > + if src_entry not in data:
> > > + data.append(src_entry)
> > > +
> > > + relationships = []
> > > + # after we have found all packages we can start to resolve
> > > dependencies
> > > + package_names = [package.name for package in packages]
> > > + for package in packages:
> > > + relationships.append(
> > > + spdx.SPDXRelationship(
> > > + spdxElementId=package.bom_ref(SBOMType.SPDX),
> > > + relatedSpdxElement=distro_ref,
> > > + relationshipType=spdx.SPDX_RELATIONSHIP_PACKAGE_OF,
> > > + )
> > > + )
> > > + if package.depends:
> > > + for dep in package.depends:
> > > + if dep.name in package_names:
> > > + relationship = spdx.SPDXRelationship(
> > > + spdxElementId=package.bom_ref(SBOMType.SPDX),
> > > + relatedSpdxElement=dep.bom_ref(SBOMType.SPDX),
> > > + relationshipType=spdx.SPDX_RELATIONSHIP_DEPENDS_ON,
> > > + )
> > > + relationships.append(relationship)
> > > + else:
> > > + # this might happen if we have optional dependencies
> > > + pass
> > > + if package.source:
> > > + relationship = spdx.SPDXRelationship(
> > > + spdxElementId=package.source.bom_ref(SBOMType.SPDX),
> > > + relatedSpdxElement=package.bom_ref(SBOMType.SPDX),
> > > + relationshipType=spdx.SPDX_RELATIONSHIP_GENERATES,
> > > + )
> > > + relationships.append(relationship)
> > > + relationships.append(
> > > + spdx.SPDXRelationship(
> > > + spdxElementId=spdx.SPDX_REF_DOCUMENT,
> > > + relatedSpdxElement=distro_ref,
> > > + relationshipType=spdx.SPDX_RELATIONSHIP_DESCRIBES,
> > > + )
> > > + )
> > > +
> > > + namespace_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> > > + timestamp =
> > > datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> > > + bom = spdx.SPDXBOM(
> > > + SPDXID=spdx.SPDX_REF_DOCUMENT,
> > > + spdxVersion=spdx.SPDX_VERSION,
> > > + creationInfo=spdx.SPDXCreationInfo(
> > > + comment="This document has been generated as part of an ISAR
> > > build.",
> > > + creators=[
> > > + "Tool: ISAR SBOM Generator -
> > > {}".format(d.getVar("SBOM_GEN_VERSION"))
> > > + ],
> > > + created=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> > > + ),
> > > + name=d.getVar("SBOM_DISTRO_NAME"),
> > > + dataLicense="CC0-1.0",
> > > + documentNamespace="{}/{}-{}".format(
> > > + d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),
> > > + d.getVar("SBOM_DISTRO_NAME"),
> > > + namespace_uuid if namespace_uuid else uuid4(),
> > > + ),
> > > + packages=data,
> > > + relationships=relationships,
> > > + )
> > > + return bom
> > > +
> > > +
> > > +def fixup_dict(o):
> > > + """Apply fixups for the BOMs.
> > > +
> > > + This is necessary for some field names and to remove fields
> > > with a None
> > > + value.
> > > + """
> > > + dct = vars(o)
> > > + new_dct = {}
> > > + for k, v in dct.items():
> > > + # remove fields with no content
> > > + if v is not None:
> > > + # we can not name our fields with dashes, so convert them
> > > + k = k.replace("_", "-")
> > > + new_dct[k] = v
> > > + return new_dct
> > > +
> > > +
> > > +def generate(d, packages: List[Package], sbom_type: SBOMType,
> > > out: str):
> > > + """Generate a SBOM."""
> > > + if sbom_type == SBOMType.CycloneDX:
> > > + bom = cyclonedx_bom(d, packages)
> > > + elif sbom_type == SBOMType.SPDX:
> > > + bom = spdx_bom(d, packages)
> > > +
> > > + with open(out, "w") as bom_file:
> > > + json.dump(bom, bom_file, indent=2, default=fixup_dict,
> > > sort_keys=True)
> > > diff --git a/meta/lib/sbom_cdx_types.py
> > > b/meta/lib/sbom_cdx_types.py
> > > new file mode 100644
> > > index 00000000..4911cc23
> > > --- /dev/null
> > > +++ b/meta/lib/sbom_cdx_types.py
> > > @@ -0,0 +1,82 @@
> > > +# This software is part of ISAR.
> > > +# Copyright (C) 2025 Siemens AG
> > > +#
> > > +# SPDX-License-Identifier: MIT
> > > +
> > > +from dataclasses import dataclass
> > > +from typing import List, Optional
> > > +
> > > +# Minimal implementation of some CycloneDX SBOM types.
> > > +# Please mind that (almost) none of these types are complete,
> > > they only
> > > +# reflect what was strictly necessary for immediate SBOM
> > > creation
> > > +
> > > +CDX_BOM_FORMAT = "CycloneDX"
> > > +CDX_SPEC_VERSION = "1.6"
> > > +
> > > +CDX_REF_PREFIX = "CDXRef-"
> > > +
> > > +CDX_PACKAGE_EXTREF_TYPE_WEBSITE = "website"
> > > +
> > > +CDX_COMPONENT_TYPE_LIBRARY = "library"
> > > +CDX_COMPONENT_TYPE_APPLICATION = "application"
> > > +CDX_COMPONENT_TYPE_OS = "operating-system"
> > > +
> > > +
> > > +@dataclass
> > > +class CDXDependency:
> > > + ref: str
> > > + dependsOn: Optional[str]
> > > +
> > > +
> > > +@dataclass
> > > +class CDXExternalReference:
> > > + url: str
> > > + type: str
> > > + comment: Optional[str] = None
> > > +
> > > +
> > > +@dataclass
> > > +class CDXSupplierContact:
> > > + email: Optional[str] = None
> > > +
> > > +
> > > +@dataclass
> > > +class CDXSupplier:
> > > + name: Optional[str] = None
> > > + contact: Optional[CDXSupplierContact] = None
> > > +
> > > +
> > > +@dataclass
> > > +class CDXComponent:
> > > + type: str
> > > + name: str
> > > + bom_ref: Optional[str] = None
> > > + supplier: Optional[str] = None
> > > + version: Optional[CDXSupplier] = None
> > > + description: Optional[str] = None
> > > + purl: Optional[str] = None
> > > + externalReferences: Optional[List[CDXExternalReference]] = None
> > > + homepage: Optional[str] = None
> > > +
> > > +
> > > +@dataclass
> > > +class CDXBOMMetadataTool:
> > > + components: Optional[List[CDXComponent]]
> > > +
> > > +
> > > +@dataclass
> > > +class CDXBOMMetadata:
> > > + timestamp: Optional[str] = None
> > > + component: Optional[str] = None
> > > + tools: Optional[List[CDXBOMMetadataTool]] = None
> > > +
> > > +
> > > +@dataclass
> > > +class CDXBOM:
> > > + bomFormat: str
> > > + specVersion: str
> > > + serialNumber: Optional[str] = None
> > > + version: Optional[str] = None
> > > + metadata: Optional[CDXBOMMetadata] = None
> > > + components: Optional[List[CDXComponent]] = None
> > > + dependencies: Optional[List[CDXDependency]] = None
> > > diff --git a/meta/lib/sbom_spdx_types.py
> > > b/meta/lib/sbom_spdx_types.py
> > > new file mode 100644
> > > index 00000000..efd7cc0c
> > > --- /dev/null
> > > +++ b/meta/lib/sbom_spdx_types.py
> > > @@ -0,0 +1,95 @@
> > > +# This software is part of ISAR.
> > > +# Copyright (C) 2025 Siemens AG
> > > +#
> > > +# SPDX-License-Identifier: MIT
> > > +
> > > +from dataclasses import dataclass
> > > +from typing import List, Optional
> > > +
> > > +# Minimal implementation of some SPDX SBOM types.
> > > +# Please mind that (almost) none of these types are complete,
> > > they only
> > > +# reflect what was strictly necessary for immediate SBOM
> > > creation
> > > +
> > > +SPDX_VERSION = "SPDX-2.3"
> > > +
> > > +SPDX_REF_PREFIX = "SPDXRef-"
> > > +
> > > +SPDX_REF_DOCUMENT = "SPDXRef-DOCUMENT"
> > > +
> > > +SPDX_PACKAGE_PURPOSE_LIBRARY = "LIBRARY"
> > > +SPDX_PACKAGE_PURPOSE_OS = "OPERATING_SYSTEM"
> > > +SPDX_PACKAGE_PURPOSE_SRC = "SOURCE"
> > > +
> > > +SPDX_NOASSERTION = "NOASSERTION"
> > > +
> > > +SPDX_RELATIONSHIP_DEPENDS_ON = "DEPENDS_ON"
> > > +SPDX_RELATIONSHIP_PACKAGE_OF = "PACKAGE_OF"
> > > +SPDX_RELATIONSHIP_GENERATES = "GENERATES"
> > > +SPDX_RELATIONSHIP_DESCRIBES = "DESCRIBES"
> > > +
> > > +SPDX_REFERENCE_CATEGORY_PKG_MANAGER = "PACKAGE_MANAGER"
> > > +SPDX_REFERENCE_TYPE_PURL = "purl"
> > > +
> > > +# cues for an organization in the maintainer name
> > > +SPDX_SUPPLIER_ORG_CUE = [
> > > + "maintainers",
> > > + "group",
> > > + "developers",
> > > + "team",
> > > + "project",
> > > + "task force",
> > > + "strike force",
> > > + "packagers",
> > > +]
> > > +
> > > +
> > > +@dataclass
> > > +class SPDXRelationship:
> > > + spdxElementId: str
> > > + relatedSpdxElement: str
> > > + relationshipType: str
> > > +
> > > +
> > > +@dataclass
> > > +class SPDXExternalRef:
> > > + referenceCategory: str
> > > + referenceType: str
> > > + referenceLocator: str
> > > +
> > > +
> > > +@dataclass
> > > +class SPDXPackage:
> > > + SPDXID: str
> > > + name: str
> > > + downloadLocation: str
> > > + filesAnalyzed: Optional[bool] = False
> > > + versionInfo: Optional[str] = None
> > > + homepage: Optional[str] = None
> > > + primaryPackagePurpose: Optional[str] = None
> > > + supplier: Optional[str] = None
> > > + licenseConcluded: Optional[str] = None
> > > + licenseDeclared: Optional[str] = None
> > > + copyrightText: Optional[str] = None
> > > + summary: Optional[str] = None
> > > + externalRefs: Optional[List[SPDXExternalRef]] = None
> > > +
> > > +
> > > +@dataclass
> > > +class SPDXCreationInfo:
> > > + created: str
> > > + comment: Optional[str] = None
> > > + creators: List[str] = None
> > > +
> > > +
> > > +@dataclass
> > > +class SPDXBOM:
> > > + """Incomplete BOM as of SPDX spec v2.3."""
> > > +
> > > + SPDXID: str
> > > + spdxVersion: str
> > > + creationInfo: SPDXCreationInfo
> > > + name: str
> > > + dataLicense: str
> > > + documentNamespace: str
> > > + packages: List[SPDXPackage]
> > > + relationships: List[SPDXRelationship]
> >
--
Siemens AG
Linux Expert Center
Friedrich-Ludwig-Bauer-Str. 3
85748 Garching, Germany
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/89ec41fdb2ebe972204339a7d6c17da527f1899c.camel%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-02-20 9:59 ` [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation 'Felix Moessbauer' via isar-users
` (2 preceding siblings ...)
2025-06-10 12:03 ` 'Christoph Steiger' via isar-users
@ 2025-07-28 15:24 ` 'MOESSBAUER, Felix' via isar-users
2025-07-29 8:49 ` 'Christoph' via isar-users
2025-08-01 7:53 ` 'MOESSBAUER, Felix' via isar-users
4 siblings, 1 reply; 15+ messages in thread
From: 'MOESSBAUER, Felix' via isar-users @ 2025-07-28 15:24 UTC (permalink / raw)
To: isar-users; +Cc: Steiger, Christoph, Kiszka, Jan, Hillier, Gernot
On Thu, 2025-02-20 at 10:59 +0100, 'Felix Moessbauer' via isar-users
wrote:
> From: Christoph Steiger <christoph.steiger@siemens.com>
>
> Add a new class to allow generation of software bill of materials
> (SBOM). Supported are the two standard SBOM formats CycloneDX and
> SPDX.
> SBOM generation is enabled per default for all images.
>
> Both formats support the minimal usecase of binary packages
> information
> and their dependencies. Unfortunately there is no proper way to
> express
> the relationships of debian source packages and their corresponding
> binary packages in the CDX format, so it is left out there.
>
> The information included in the SBOM is parsed from the dpkg status
> file found in the created image.
Hi,
while discussing various use-cases of the SBOM, I noticed, that the
component description might not be precise enough to identify a debian
package: while the purl SHOULD be sufficient to globally identify the
package, there is no cryptographic hash provided that guarantees the
integrity of the package. By that, different mirrors could
theoretically offer different packages under the same purl. This works,
as long as the repositories themselves are signed correctly and the key
is added to the ISAR build.
How about adding the "hashes" field for each binary package with the
data from apt?
Best regards,
Felix
>
> Signed-off-by: Christoph Steiger <christoph.steiger@siemens.com>
> ---
> meta/classes/create-sbom.bbclass | 49 ++++
> meta/classes/image.bbclass | 2 +
> meta/lib/sbom.py | 446
> +++++++++++++++++++++++++++++++
> meta/lib/sbom_cdx_types.py | 82 ++++++
> meta/lib/sbom_spdx_types.py | 95 +++++++
> 5 files changed, 674 insertions(+)
> create mode 100644 meta/classes/create-sbom.bbclass
> create mode 100644 meta/lib/sbom.py
> create mode 100644 meta/lib/sbom_cdx_types.py
> create mode 100644 meta/lib/sbom_spdx_types.py
>
> diff --git a/meta/classes/create-sbom.bbclass b/meta/classes/create-
> sbom.bbclass
> new file mode 100644
> index 00000000..8c647699
> --- /dev/null
> +++ b/meta/classes/create-sbom.bbclass
> @@ -0,0 +1,49 @@
> +# This software is a part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +# sbom type to generate, accepted are "cyclonedx" and "spdx"
> +SBOM_TYPE ?= "cyclonedx spdx"
> +
> +# general user variables
> +SBOM_DISTRO_SUPPLIER ?= "ISAR"
> +SBOM_DISTRO_NAME ?= "ISAR-Debian-GNU-Linux"
> +SBOM_DISTRO_VERSION ?= "1.0.0"
> +SBOM_DISTRO_SUMMARY ?= "Linux distribution built with ISAR"
> +SBOM_DOCUMENT_UUID ?= ""
> +
> +# SPDX specific user variables
> +SBOM_SPDX_NAMESPACE_PREFIX ?= "https://spdx.org/spdxdocs"
> +
> +SBOM_DEPLOY_BASE = "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
> +
> +SBOM_GEN_VERSION = "0.1.0"
> +
> +# adapted from the isar-cip-core image_uuid.bbclass
> +def generate_document_uuid(d):
> + import uuid
> +
> + base_hash = d.getVar("BB_TASKHASH")
> + if base_hash is None:
> + bb.warn("no BB_TASKHASH available, SBOM UUID is not
> reproducible")
> + return uuid.uuid4()
> + return str(uuid.UUID(base_hash[:32], version=4))
> +
> +python do_create_sbom() {
> + import sbom
> +
> + dpkg_status = d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"
> + packages = sbom.Package.parse_status_file(dpkg_status)
> +
> + if not d.getVar("SBOM_DOCUMENT_UUID"):
> + d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))
> +
> + sbom_type = d.getVar("SBOM_TYPE")
> + if "cyclonedx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.CycloneDX,
> d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
> + if "spdx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.SPDX,
> d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")
> +}
> +
> +addtask do_create_sbom after do_rootfs before do_build
> diff --git a/meta/classes/image.bbclass b/meta/classes/image.bbclass
> index 56eca202..e9da6a61 100644
> --- a/meta/classes/image.bbclass
> +++ b/meta/classes/image.bbclass
> @@ -81,6 +81,8 @@ inherit image-postproc-extension
> inherit image-locales-extension
> inherit image-account-extension
>
> +inherit create-sbom
> +
> # Extra space for rootfs in MB
> ROOTFS_EXTRA ?= "64"
>
> diff --git a/meta/lib/sbom.py b/meta/lib/sbom.py
> new file mode 100644
> index 00000000..d7c79e43
> --- /dev/null
> +++ b/meta/lib/sbom.py
> @@ -0,0 +1,446 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from datetime import datetime
> +from enum import Enum
> +from typing import Dict, List, Type
> +import json
> +import re
> +from uuid import uuid4
> +
> +import sbom_cdx_types as cdx
> +import sbom_spdx_types as spdx
> +
> +
> +class SBOMType(Enum):
> + CycloneDX = (0,)
> + SPDX = (1,)
> +
> +
> +@dataclass
> +class SourcePackage:
> + name: str
> + version: str | None
> +
> + def purl(self):
> + """Return the PURL of the package."""
> + return "pkg:deb/debian/{}@{}?arch=source".format(self.name,
> self.version)
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDXREF_PREFIX + "{}-src".format(self.name)
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}-src".format(self.name)
> +
> + def parse(s: str) -> Type["SourcePackage"]:
> + split = s.split(" ")
> + name = split[0]
> + try:
> + version = " ".join(split[1:]).strip("()")
> + except IndexError:
> + version = None
> +
> + return SourcePackage(name=name, version=version)
> +
> +
> +@dataclass
> +class Dependency:
> + name: str
> + version: str | None
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + "{}".format(self.name)
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}".format(self.name)
> +
> + def parse_multiple(s: str) -> List[Type["Dependency"]]:
> + """Parse a 'Depends' line in the dpkg status file."""
> + dependencies = []
> + for entry in s.split(","):
> + entry = entry.strip()
> + for entry in entry.split("|"):
> + split = entry.split("(")
> + name = split[0].strip()
> + try:
> + version = split[1].strip(")")
> + except IndexError:
> + version = None
> + dependencies.append(Dependency(name=name,
> version=version))
> +
> + return dependencies
> +
> +
> +@dataclass
> +class Package:
> + """Incomplete representation of a debian package."""
> +
> + name: str
> + section: str
> + maintainer: str
> + architecture: str
> + source: SourcePackage
> + version: str
> + depends: List[Dependency]
> + description: str
> + homepage: str
> +
> + def purl(self) -> str:
> + """Return the PURL of the package."""
> + purl = "pkg:deb/debian/{}@{}".format(self.name,
> self.version)
> + if self.architecture:
> + purl = purl + "?arch={}".format(self.architecture)
> + return purl
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + self.name
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + self.name
> +
> + def parse_status_file(status_file: str) ->
> List[Type["Package"]]:
> + """Parse a dpkg status file."""
> + packages = []
> + with open(status_file, "r") as f:
> + name = None
> + section = None
> + maintainer = None
> + architecture = None
> + source = None
> + version = None
> + dependencies = None
> + description = None
> + homepage = None
> + for line in f.readlines():
> + if line.strip():
> + if line[0] == " ":
> + # this is a description line, we ignore it
> + continue
> + else:
> + split = line.split(":")
> + key = split[0]
> + value = ":".join(split[1:]).strip()
> + if key == "Package":
> + name = value
> + elif key == "Section":
> + section = value
> + elif key == "Maintainer":
> + maintainer = value
> + elif key == "Architecture":
> + architecture = value
> + elif key == "Source":
> + source = SourcePackage.parse(value)
> + elif key == "Version":
> + version = value
> + elif key == "Depends":
> + dependencies =
> Dependency.parse_multiple(value)
> + elif key == "Description":
> + description = value
> + elif key == "Homepage":
> + homepage = value
> + else:
> + # fixup source version, if not specified it is
> the same
> + # as the package version
> + if source and not source.version:
> + source.version = version
> + # empty line means new package, so finish the
> current one
> + packages.append(
> + Package(
> + name=name,
> + section=section,
> + maintainer=maintainer,
> + architecture=architecture,
> + source=source,
> + version=version,
> + depends=dependencies,
> + description=description,
> + homepage=homepage,
> + )
> + )
> + name = None
> + section = None
> + maintainer = None
> + architecture = None
> + source = None
> + version = None
> + dependencies = None
> + description = None
> + homepage = None
> +
> + return packages
> +
> +
> +def cyclonedx_bom(d, packages: List[Package]) -> Dict:
> + """Return a valid CycloneDX SBOM."""
> + data = []
> + dependencies = []
> +
> + pattern =
> re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?"
> )
> + for package in packages:
> + match = pattern.match(package.maintainer)
> + supplier = cdx.CDXSupplier(name=match["supplier_name"])
> + supplier_email = match["supplier_email"]
> + if supplier_email:
> + supplier.contact =
> [cdx.CDXSupplierContact(email=supplier_email)]
> + entry = cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_LIBRARY,
> + bom_ref=package.bom_ref(SBOMType.CycloneDX),
> + supplier=supplier,
> + name=package.name,
> + version=package.version,
> + description=package.description,
> + purl=package.purl(),
> + )
> + if package.homepage:
> + entry.externalReferences = (
> + cdx.CDXExternalReference(
> + url=package.homepage,
> + type=cdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,
> + comment="homepage",
> + ),
> + )
> + data.append(entry)
> +
> + distro_bom_ref = cdx.CDX_REF_PREFIX +
> d.getVar("SBOM_DISTRO_NAME")
> + distro_dependencies = []
> + # after we have found all packages we can start to resolve
> dependencies
> + package_names = [package.name for package in packages]
> + for package in packages:
> +
> distro_dependencies.append(package.bom_ref(SBOMType.CycloneDX))
> + if package.depends:
> + deps = []
> + for dep in package.depends:
> + dep_bom_ref = dep.bom_ref(SBOMType.CycloneDX)
> + # it is possibe to specify the same package multiple
> times, but
> + # in different versions
> + if dep.name in package_names and dep_bom_ref not in
> deps:
> + deps.append(dep_bom_ref)
> + else:
> + # this might happen if we have optional
> dependencies
> + continue
> + dependency = cdx.CDXDependency(
> + ref=package.bom_ref(SBOMType.CycloneDX),
> + dependsOn=deps,
> + )
> + dependencies.append(dependency)
> + dependency = cdx.CDXDependency(
> + ref=distro_bom_ref,
> + dependsOn=distro_dependencies,
> + )
> + dependencies.append(dependency)
> +
> + doc_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> + distro_component = cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_OS,
> + bom_ref=cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME"),
> +
> supplier=cdx.CDXSupplier(name=d.getVar("SBOM_DISTRO_SUPPLIER")),
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + version=d.getVar("SBOM_DISTRO_VERSION"),
> + description=d.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + timestamp =
> datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> + bom = cdx.CDXBOM(
> + bomFormat=cdx.CDX_BOM_FORMAT,
> + specVersion=cdx.CDX_SPEC_VERSION,
> + serialNumber="urn:uuid:{}".format(doc_uuid if doc_uuid else
> uuid4()),
> + version=1,
> + metadata=cdx.CDXBOMMetadata(
> + timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + component=distro_component,
> + tools=cdx.CDXBOMMetadataTool(
> + components=[
> + cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_APPLICATION,
> + name="ISAR SBOM Generator",
> + version=d.getVar("SBOM_GEN_VERSION"),
> + )
> + ],
> + ),
> + ),
> + components=data,
> + dependencies=dependencies,
> + )
> + return bom
> +
> +
> +def spdx_bom(d, packages: List[Package]) -> Dict:
> + "Return a valid SPDX SBOM."
> +
> + data = []
> + # create a "fake" entry for the distribution
> + distro_ref = spdx.SPDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
> + distro_package = spdx.SPDXPackage(
> + SPDXID=distro_ref,
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + versionInfo=d.getVar("SBOM_DISTRO_VERSION"),
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_OS,
> + supplier="Organization:
> {}".format(d.getVar("SBOM_DISTRO_SUPPLIER")),
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary=d.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + data.append(distro_package)
> +
> + pattern =
> re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?"
> )
> + for package in packages:
> + match = pattern.match(package.maintainer)
> + supplier_name = match["supplier_name"]
> + supplier_email = match["supplier_email"]
> + if any([cue in supplier_name.lower() for cue in
> spdx.SPDX_SUPPLIER_ORG_CUE]):
> + supplier = "Organization: {}".format(supplier_name)
> + else:
> + supplier = "Person: {}".format(supplier_name)
> + if supplier_email:
> + supplier += "({})".format(supplier_email)
> +
> + entry = spdx.SPDXPackage(
> + SPDXID=package.bom_ref(SBOMType.SPDX),
> + name=package.name,
> + versionInfo=package.version,
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_LIBRARY,
> + supplier=supplier,
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + # TODO: it should be possible to conclude
> license/copyright
> + # information, we could look e.g. in
> /usr/share/doc/*/copyright
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary=package.description,
> + externalRefs=[
> + spdx.SPDXExternalRef(
> +
> referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> + referenceLocator=package.purl(),
> + )
> + ],
> + )
> + if package.homepage:
> + entry.homepage = package.homepage
> + data.append(entry)
> +
> + if package.source:
> + src_entry = spdx.SPDXPackage(
> + SPDXID=package.source.bom_ref(SBOMType.SPDX),
> + name=package.source.name,
> + versionInfo=package.source.version,
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_SRC,
> + supplier=supplier,
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary="debian source code package
> '{}'".format(package.source.name),
> + externalRefs=[
> + spdx.SPDXExternalRef(
> +
> referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> + referenceLocator=package.source.purl(),
> + )
> + ],
> + )
> + # source packages might be referenced multiple times
> + if src_entry not in data:
> + data.append(src_entry)
> +
> + relationships = []
> + # after we have found all packages we can start to resolve
> dependencies
> + package_names = [package.name for package in packages]
> + for package in packages:
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=package.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=distro_ref,
> + relationshipType=spdx.SPDX_RELATIONSHIP_PACKAGE_OF,
> + )
> + )
> + if package.depends:
> + for dep in package.depends:
> + if dep.name in package_names:
> + relationship = spdx.SPDXRelationship(
> +
> spdxElementId=package.bom_ref(SBOMType.SPDX),
> +
> relatedSpdxElement=dep.bom_ref(SBOMType.SPDX),
> +
> relationshipType=spdx.SPDX_RELATIONSHIP_DEPENDS_ON,
> + )
> + relationships.append(relationship)
> + else:
> + # this might happen if we have optional
> dependencies
> + pass
> + if package.source:
> + relationship = spdx.SPDXRelationship(
> + spdxElementId=package.source.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=package.bom_ref(SBOMType.SPDX),
> + relationshipType=spdx.SPDX_RELATIONSHIP_GENERATES,
> + )
> + relationships.append(relationship)
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=spdx.SPDX_REF_DOCUMENT,
> + relatedSpdxElement=distro_ref,
> + relationshipType=spdx.SPDX_RELATIONSHIP_DESCRIBES,
> + )
> + )
> +
> + namespace_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> + timestamp =
> datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> + bom = spdx.SPDXBOM(
> + SPDXID=spdx.SPDX_REF_DOCUMENT,
> + spdxVersion=spdx.SPDX_VERSION,
> + creationInfo=spdx.SPDXCreationInfo(
> + comment="This document has been generated as part of an
> ISAR build.",
> + creators=[
> + "Tool: ISAR SBOM Generator -
> {}".format(d.getVar("SBOM_GEN_VERSION"))
> + ],
> + created=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + ),
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + dataLicense="CC0-1.0",
> + documentNamespace="{}/{}-{}".format(
> + d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),
> + d.getVar("SBOM_DISTRO_NAME"),
> + namespace_uuid if namespace_uuid else uuid4(),
> + ),
> + packages=data,
> + relationships=relationships,
> + )
> + return bom
> +
> +
> +def fixup_dict(o):
> + """Apply fixups for the BOMs.
> +
> + This is necessary for some field names and to remove fields with
> a None
> + value.
> + """
> + dct = vars(o)
> + new_dct = {}
> + for k, v in dct.items():
> + # remove fields with no content
> + if v is not None:
> + # we can not name our fields with dashes, so convert
> them
> + k = k.replace("_", "-")
> + new_dct[k] = v
> + return new_dct
> +
> +
> +def generate(d, packages: List[Package], sbom_type: SBOMType, out:
> str):
> + """Generate a SBOM."""
> + if sbom_type == SBOMType.CycloneDX:
> + bom = cyclonedx_bom(d, packages)
> + elif sbom_type == SBOMType.SPDX:
> + bom = spdx_bom(d, packages)
> +
> + with open(out, "w") as bom_file:
> + json.dump(bom, bom_file, indent=2, default=fixup_dict,
> sort_keys=True)
> diff --git a/meta/lib/sbom_cdx_types.py b/meta/lib/sbom_cdx_types.py
> new file mode 100644
> index 00000000..4911cc23
> --- /dev/null
> +++ b/meta/lib/sbom_cdx_types.py
> @@ -0,0 +1,82 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from typing import List, Optional
> +
> +# Minimal implementation of some CycloneDX SBOM types.
> +# Please mind that (almost) none of these types are complete, they
> only
> +# reflect what was strictly necessary for immediate SBOM creation
> +
> +CDX_BOM_FORMAT = "CycloneDX"
> +CDX_SPEC_VERSION = "1.6"
> +
> +CDX_REF_PREFIX = "CDXRef-"
> +
> +CDX_PACKAGE_EXTREF_TYPE_WEBSITE = "website"
> +
> +CDX_COMPONENT_TYPE_LIBRARY = "library"
> +CDX_COMPONENT_TYPE_APPLICATION = "application"
> +CDX_COMPONENT_TYPE_OS = "operating-system"
> +
> +
> +@dataclass
> +class CDXDependency:
> + ref: str
> + dependsOn: Optional[str]
> +
> +
> +@dataclass
> +class CDXExternalReference:
> + url: str
> + type: str
> + comment: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXSupplierContact:
> + email: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXSupplier:
> + name: Optional[str] = None
> + contact: Optional[CDXSupplierContact] = None
> +
> +
> +@dataclass
> +class CDXComponent:
> + type: str
> + name: str
> + bom_ref: Optional[str] = None
> + supplier: Optional[str] = None
> + version: Optional[CDXSupplier] = None
> + description: Optional[str] = None
> + purl: Optional[str] = None
> + externalReferences: Optional[List[CDXExternalReference]] = None
> + homepage: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXBOMMetadataTool:
> + components: Optional[List[CDXComponent]]
> +
> +
> +@dataclass
> +class CDXBOMMetadata:
> + timestamp: Optional[str] = None
> + component: Optional[str] = None
> + tools: Optional[List[CDXBOMMetadataTool]] = None
> +
> +
> +@dataclass
> +class CDXBOM:
> + bomFormat: str
> + specVersion: str
> + serialNumber: Optional[str] = None
> + version: Optional[str] = None
> + metadata: Optional[CDXBOMMetadata] = None
> + components: Optional[List[CDXComponent]] = None
> + dependencies: Optional[List[CDXDependency]] = None
> diff --git a/meta/lib/sbom_spdx_types.py
> b/meta/lib/sbom_spdx_types.py
> new file mode 100644
> index 00000000..efd7cc0c
> --- /dev/null
> +++ b/meta/lib/sbom_spdx_types.py
> @@ -0,0 +1,95 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from typing import List, Optional
> +
> +# Minimal implementation of some SPDX SBOM types.
> +# Please mind that (almost) none of these types are complete, they
> only
> +# reflect what was strictly necessary for immediate SBOM creation
> +
> +SPDX_VERSION = "SPDX-2.3"
> +
> +SPDX_REF_PREFIX = "SPDXRef-"
> +
> +SPDX_REF_DOCUMENT = "SPDXRef-DOCUMENT"
> +
> +SPDX_PACKAGE_PURPOSE_LIBRARY = "LIBRARY"
> +SPDX_PACKAGE_PURPOSE_OS = "OPERATING_SYSTEM"
> +SPDX_PACKAGE_PURPOSE_SRC = "SOURCE"
> +
> +SPDX_NOASSERTION = "NOASSERTION"
> +
> +SPDX_RELATIONSHIP_DEPENDS_ON = "DEPENDS_ON"
> +SPDX_RELATIONSHIP_PACKAGE_OF = "PACKAGE_OF"
> +SPDX_RELATIONSHIP_GENERATES = "GENERATES"
> +SPDX_RELATIONSHIP_DESCRIBES = "DESCRIBES"
> +
> +SPDX_REFERENCE_CATEGORY_PKG_MANAGER = "PACKAGE_MANAGER"
> +SPDX_REFERENCE_TYPE_PURL = "purl"
> +
> +# cues for an organization in the maintainer name
> +SPDX_SUPPLIER_ORG_CUE = [
> + "maintainers",
> + "group",
> + "developers",
> + "team",
> + "project",
> + "task force",
> + "strike force",
> + "packagers",
> +]
> +
> +
> +@dataclass
> +class SPDXRelationship:
> + spdxElementId: str
> + relatedSpdxElement: str
> + relationshipType: str
> +
> +
> +@dataclass
> +class SPDXExternalRef:
> + referenceCategory: str
> + referenceType: str
> + referenceLocator: str
> +
> +
> +@dataclass
> +class SPDXPackage:
> + SPDXID: str
> + name: str
> + downloadLocation: str
> + filesAnalyzed: Optional[bool] = False
> + versionInfo: Optional[str] = None
> + homepage: Optional[str] = None
> + primaryPackagePurpose: Optional[str] = None
> + supplier: Optional[str] = None
> + licenseConcluded: Optional[str] = None
> + licenseDeclared: Optional[str] = None
> + copyrightText: Optional[str] = None
> + summary: Optional[str] = None
> + externalRefs: Optional[List[SPDXExternalRef]] = None
> +
> +
> +@dataclass
> +class SPDXCreationInfo:
> + created: str
> + comment: Optional[str] = None
> + creators: List[str] = None
> +
> +
> +@dataclass
> +class SPDXBOM:
> + """Incomplete BOM as of SPDX spec v2.3."""
> +
> + SPDXID: str
> + spdxVersion: str
> + creationInfo: SPDXCreationInfo
> + name: str
> + dataLicense: str
> + documentNamespace: str
> + packages: List[SPDXPackage]
> + relationships: List[SPDXRelationship]
> --
> 2.39.5
--
Siemens AG
Linux Expert Center
Friedrich-Ludwig-Bauer-Str. 3
85748 Garching, Germany
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/8e74ae25f4e8add72fd17ce2284a48df7994edaa.camel%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-07-28 15:24 ` 'MOESSBAUER, Felix' via isar-users
@ 2025-07-29 8:49 ` 'Christoph' via isar-users
0 siblings, 0 replies; 15+ messages in thread
From: 'Christoph' via isar-users @ 2025-07-29 8:49 UTC (permalink / raw)
To: isar-users
[-- Attachment #1.1: Type: text/plain, Size: 28026 bytes --]
On Thu, 2025-02-20 at 10:59 +0100, 'Felix Moessbauer' via isar-users
wrote:
> From: Christoph Steiger <christop...@siemens.com>
>
> Add a new class to allow generation of software bill of materials
> (SBOM). Supported are the two standard SBOM formats CycloneDX and
> SPDX.
> SBOM generation is enabled per default for all images.
>
> Both formats support the minimal usecase of binary packages
> information
> and their dependencies. Unfortunately there is no proper way to
> express
> the relationships of debian source packages and their corresponding
> binary packages in the CDX format, so it is left out there.
>
> The information included in the SBOM is parsed from the dpkg status
> file found in the created image.
Hi,
while discussing various use-cases of the SBOM, I noticed, that the
component description might not be precise enough to identify a debian
package: while the purl SHOULD be sufficient to globally identify the
package, there is no cryptographic hash provided that guarantees the
integrity of the package. By that, different mirrors could
theoretically offer different packages under the same purl. This works,
as long as the repositories themselves are signed correctly and the key
is added to the ISAR build.
How about adding the "hashes" field for each binary package with the
data from apt?
Best regards,
Felix
Do you mean the "hashes" field in a CycloneDX component? It is a very
good idea to add that. There is a checksum field for SPDX packages too
which should semantically be the same.
I will add it to the (long) list of features we want ;)
>
> Signed-off-by: Christoph Steiger <christop...@siemens.com>
> ---
> meta/classes/create-sbom.bbclass | 49 ++++
> meta/classes/image.bbclass | 2 +
> meta/lib/sbom.py | 446
> +++++++++++++++++++++++++++++++
> meta/lib/sbom_cdx_types.py | 82 ++++++
> meta/lib/sbom_spdx_types.py | 95 +++++++
> 5 files changed, 674 insertions(+)
> create mode 100644 meta/classes/create-sbom.bbclass
> create mode 100644 meta/lib/sbom.py
> create mode 100644 meta/lib/sbom_cdx_types.py
> create mode 100644 meta/lib/sbom_spdx_types.py
>
> diff --git a/meta/classes/create-sbom.bbclass b/meta/classes/create-
> sbom.bbclass
> new file mode 100644
> index 00000000..8c647699
> --- /dev/null
> +++ b/meta/classes/create-sbom.bbclass
> @@ -0,0 +1,49 @@
> +# This software is a part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +# sbom type to generate, accepted are "cyclonedx" and "spdx"
> +SBOM_TYPE ?= "cyclonedx spdx"
> +
> +# general user variables
> +SBOM_DISTRO_SUPPLIER ?= "ISAR"
> +SBOM_DISTRO_NAME ?= "ISAR-Debian-GNU-Linux"
> +SBOM_DISTRO_VERSION ?= "1.0.0"
> +SBOM_DISTRO_SUMMARY ?= "Linux distribution built with ISAR"
> +SBOM_DOCUMENT_UUID ?= ""
> +
> +# SPDX specific user variables
> +SBOM_SPDX_NAMESPACE_PREFIX ?= "https://spdx.org/spdxdocs"
> +
> +SBOM_DEPLOY_BASE = "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
> +
> +SBOM_GEN_VERSION = "0.1.0"
> +
> +# adapted from the isar-cip-core image_uuid.bbclass
> +def generate_document_uuid(d):
> + import uuid
> +
> + base_hash = d.getVar("BB_TASKHASH")
> + if base_hash is None:
> + bb.warn("no BB_TASKHASH available, SBOM UUID is not
> reproducible")
> + return uuid.uuid4()
> + return str(uuid.UUID(base_hash[:32], version=4))
> +
> +python do_create_sbom() {
> + import sbom
> +
> + dpkg_status = d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"
> + packages = sbom.Package.parse_status_file(dpkg_status)
> +
> + if not d.getVar("SBOM_DOCUMENT_UUID"):
> + d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))
> +
> + sbom_type = d.getVar("SBOM_TYPE")
> + if "cyclonedx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.CycloneDX,
> d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
> + if "spdx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.SPDX,
> d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")
> +}
> +
> +addtask do_create_sbom after do_rootfs before do_build
> diff --git a/meta/classes/image.bbclass b/meta/classes/image.bbclass
> index 56eca202..e9da6a61 100644
> --- a/meta/classes/image.bbclass
> +++ b/meta/classes/image.bbclass
> @@ -81,6 +81,8 @@ inherit image-postproc-extension
> inherit image-locales-extension
> inherit image-account-extension
>
> +inherit create-sbom
> +
> # Extra space for rootfs in MB
> ROOTFS_EXTRA ?= "64"
>
> diff --git a/meta/lib/sbom.py b/meta/lib/sbom.py
> new file mode 100644
> index 00000000..d7c79e43
> --- /dev/null
> +++ b/meta/lib/sbom.py
> @@ -0,0 +1,446 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from datetime import datetime
> +from enum import Enum
> +from typing import Dict, List, Type
> +import json
> +import re
> +from uuid import uuid4
> +
> +import sbom_cdx_types as cdx
> +import sbom_spdx_types as spdx
> +
> +
> +class SBOMType(Enum):
> + CycloneDX = (0,)
> + SPDX = (1,)
> +
> +
> +@dataclass
> +class SourcePackage:
> + name: str
> + version: str | None
> +
> + def purl(self):
> + """Return the PURL of the package."""
> + return "pkg:deb/debian/{}@{}?arch=source".format(self.name,
> self.version)
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDXREF_PREFIX + "{}-src".format(self.name)
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}-src".format(self.name)
> +
> + def parse(s: str) -> Type["SourcePackage"]:
> + split = s.split(" ")
> + name = split[0]
> + try:
> + version = " ".join(split[1:]).strip("()")
> + except IndexError:
> + version = None
> +
> + return SourcePackage(name=name, version=version)
> +
> +
> +@dataclass
> +class Dependency:
> + name: str
> + version: str | None
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + "{}".format(self.name)
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}".format(self.name)
> +
> + def parse_multiple(s: str) -> List[Type["Dependency"]]:
> + """Parse a 'Depends' line in the dpkg status file."""
> + dependencies = []
> + for entry in s.split(","):
> + entry = entry.strip()
> + for entry in entry.split("|"):
> + split = entry.split("(")
> + name = split[0].strip()
> + try:
> + version = split[1].strip(")")
> + except IndexError:
> + version = None
> + dependencies.append(Dependency(name=name,
> version=version))
> +
> + return dependencies
> +
> +
> +@dataclass
> +class Package:
> + """Incomplete representation of a debian package."""
> +
> + name: str
> + section: str
> + maintainer: str
> + architecture: str
> + source: SourcePackage
> + version: str
> + depends: List[Dependency]
> + description: str
> + homepage: str
> +
> + def purl(self) -> str:
> + """Return the PURL of the package."""
> + purl = "pkg:deb/debian/{}@{}".format(self.name,
> self.version)
> + if self.architecture:
> + purl = purl + "?arch={}".format(self.architecture)
> + return purl
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + self.name
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + self.name
> +
> + def parse_status_file(status_file: str) ->
> List[Type["Package"]]:
> + """Parse a dpkg status file."""
> + packages = []
> + with open(status_file, "r") as f:
> + name = None
> + section = None
> + maintainer = None
> + architecture = None
> + source = None
> + version = None
> + dependencies = None
> + description = None
> + homepage = None
> + for line in f.readlines():
> + if line.strip():
> + if line[0] == " ":
> + # this is a description line, we ignore it
> + continue
> + else:
> + split = line.split(":")
> + key = split[0]
> + value = ":".join(split[1:]).strip()
> + if key == "Package":
> + name = value
> + elif key == "Section":
> + section = value
> + elif key == "Maintainer":
> + maintainer = value
> + elif key == "Architecture":
> + architecture = value
> + elif key == "Source":
> + source = SourcePackage.parse(value)
> + elif key == "Version":
> + version = value
> + elif key == "Depends":
> + dependencies =
> Dependency.parse_multiple(value)
> + elif key == "Description":
> + description = value
> + elif key == "Homepage":
> + homepage = value
> + else:
> + # fixup source version, if not specified it is
> the same
> + # as the package version
> + if source and not source.version:
> + source.version = version
> + # empty line means new package, so finish the
> current one
> + packages.append(
> + Package(
> + name=name,
> + section=section,
> + maintainer=maintainer,
> + architecture=architecture,
> + source=source,
> + version=version,
> + depends=dependencies,
> + description=description,
> + homepage=homepage,
> + )
> + )
> + name = None
> + section = None
> + maintainer = None
> + architecture = None
> + source = None
> + version = None
> + dependencies = None
> + description = None
> + homepage = None
> +
> + return packages
> +
> +
> +def cyclonedx_bom(d, packages: List[Package]) -> Dict:
> + """Return a valid CycloneDX SBOM."""
> + data = []
> + dependencies = []
> +
> + pattern =
> re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?"
> )
> + for package in packages:
> + match = pattern.match(package.maintainer)
> + supplier = cdx.CDXSupplier(name=match["supplier_name"])
> + supplier_email = match["supplier_email"]
> + if supplier_email:
> + supplier.contact =
> [cdx.CDXSupplierContact(email=supplier_email)]
> + entry = cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_LIBRARY,
> + bom_ref=package.bom_ref(SBOMType.CycloneDX),
> + supplier=supplier,
> + name=package.name,
> + version=package.version,
> + description=package.description,
> + purl=package.purl(),
> + )
> + if package.homepage:
> + entry.externalReferences = (
> + cdx.CDXExternalReference(
> + url=package.homepage,
> + type=cdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,
> + comment="homepage",
> + ),
> + )
> + data.append(entry)
> +
> + distro_bom_ref = cdx.CDX_REF_PREFIX +
> d.getVar("SBOM_DISTRO_NAME")
> + distro_dependencies = []
> + # after we have found all packages we can start to resolve
> dependencies
> + package_names = [package.name for package in packages]
> + for package in packages:
> +
> distro_dependencies.append(package.bom_ref(SBOMType.CycloneDX))
> + if package.depends:
> + deps = []
> + for dep in package.depends:
> + dep_bom_ref = dep.bom_ref(SBOMType.CycloneDX)
> + # it is possibe to specify the same package multiple
> times, but
> + # in different versions
> + if dep.name in package_names and dep_bom_ref not in
> deps:
> + deps.append(dep_bom_ref)
> + else:
> + # this might happen if we have optional
> dependencies
> + continue
> + dependency = cdx.CDXDependency(
> + ref=package.bom_ref(SBOMType.CycloneDX),
> + dependsOn=deps,
> + )
> + dependencies.append(dependency)
> + dependency = cdx.CDXDependency(
> + ref=distro_bom_ref,
> + dependsOn=distro_dependencies,
> + )
> + dependencies.append(dependency)
> +
> + doc_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> + distro_component = cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_OS,
> + bom_ref=cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME"),
> +
> supplier=cdx.CDXSupplier(name=d.getVar("SBOM_DISTRO_SUPPLIER")),
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + version=d.getVar("SBOM_DISTRO_VERSION"),
> + description=d.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + timestamp =
> datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> + bom = cdx.CDXBOM(
> + bomFormat=cdx.CDX_BOM_FORMAT,
> + specVersion=cdx.CDX_SPEC_VERSION,
> + serialNumber="urn:uuid:{}".format(doc_uuid if doc_uuid else
> uuid4()),
> + version=1,
> + metadata=cdx.CDXBOMMetadata(
> + timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + component=distro_component,
> + tools=cdx.CDXBOMMetadataTool(
> + components=[
> + cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_APPLICATION,
> + name="ISAR SBOM Generator",
> + version=d.getVar("SBOM_GEN_VERSION"),
> + )
> + ],
> + ),
> + ),
> + components=data,
> + dependencies=dependencies,
> + )
> + return bom
> +
> +
> +def spdx_bom(d, packages: List[Package]) -> Dict:
> + "Return a valid SPDX SBOM."
> +
> + data = []
> + # create a "fake" entry for the distribution
> + distro_ref = spdx.SPDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
> + distro_package = spdx.SPDXPackage(
> + SPDXID=distro_ref,
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + versionInfo=d.getVar("SBOM_DISTRO_VERSION"),
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_OS,
> + supplier="Organization:
> {}".format(d.getVar("SBOM_DISTRO_SUPPLIER")),
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary=d.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + data.append(distro_package)
> +
> + pattern =
> re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?"
> )
> + for package in packages:
> + match = pattern.match(package.maintainer)
> + supplier_name = match["supplier_name"]
> + supplier_email = match["supplier_email"]
> + if any([cue in supplier_name.lower() for cue in
> spdx.SPDX_SUPPLIER_ORG_CUE]):
> + supplier = "Organization: {}".format(supplier_name)
> + else:
> + supplier = "Person: {}".format(supplier_name)
> + if supplier_email:
> + supplier += "({})".format(supplier_email)
> +
> + entry = spdx.SPDXPackage(
> + SPDXID=package.bom_ref(SBOMType.SPDX),
> + name=package.name,
> + versionInfo=package.version,
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_LIBRARY,
> + supplier=supplier,
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + # TODO: it should be possible to conclude
> license/copyright
> + # information, we could look e.g. in
> /usr/share/doc/*/copyright
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary=package.description,
> + externalRefs=[
> + spdx.SPDXExternalRef(
> +
> referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> + referenceLocator=package.purl(),
> + )
> + ],
> + )
> + if package.homepage:
> + entry.homepage = package.homepage
> + data.append(entry)
> +
> + if package.source:
> + src_entry = spdx.SPDXPackage(
> + SPDXID=package.source.bom_ref(SBOMType.SPDX),
> + name=package.source.name,
> + versionInfo=package.source.version,
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_SRC,
> + supplier=supplier,
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary="debian source code package
> '{}'".format(package.source.name),
> + externalRefs=[
> + spdx.SPDXExternalRef(
> +
> referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> + referenceLocator=package.source.purl(),
> + )
> + ],
> + )
> + # source packages might be referenced multiple times
> + if src_entry not in data:
> + data.append(src_entry)
> +
> + relationships = []
> + # after we have found all packages we can start to resolve
> dependencies
> + package_names = [package.name for package in packages]
> + for package in packages:
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=package.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=distro_ref,
> + relationshipType=spdx.SPDX_RELATIONSHIP_PACKAGE_OF,
> + )
> + )
> + if package.depends:
> + for dep in package.depends:
> + if dep.name in package_names:
> + relationship = spdx.SPDXRelationship(
> +
> spdxElementId=package.bom_ref(SBOMType.SPDX),
> +
> relatedSpdxElement=dep.bom_ref(SBOMType.SPDX),
> +
> relationshipType=spdx.SPDX_RELATIONSHIP_DEPENDS_ON,
> + )
> + relationships.append(relationship)
> + else:
> + # this might happen if we have optional
> dependencies
> + pass
> + if package.source:
> + relationship = spdx.SPDXRelationship(
> + spdxElementId=package.source.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=package.bom_ref(SBOMType.SPDX),
> + relationshipType=spdx.SPDX_RELATIONSHIP_GENERATES,
> + )
> + relationships.append(relationship)
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=spdx.SPDX_REF_DOCUMENT,
> + relatedSpdxElement=distro_ref,
> + relationshipType=spdx.SPDX_RELATIONSHIP_DESCRIBES,
> + )
> + )
> +
> + namespace_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> + timestamp =
> datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> + bom = spdx.SPDXBOM(
> + SPDXID=spdx.SPDX_REF_DOCUMENT,
> + spdxVersion=spdx.SPDX_VERSION,
> + creationInfo=spdx.SPDXCreationInfo(
> + comment="This document has been generated as part of an
> ISAR build.",
> + creators=[
> + "Tool: ISAR SBOM Generator -
> {}".format(d.getVar("SBOM_GEN_VERSION"))
> + ],
> + created=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + ),
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + dataLicense="CC0-1.0",
> + documentNamespace="{}/{}-{}".format(
> + d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),
> + d.getVar("SBOM_DISTRO_NAME"),
> + namespace_uuid if namespace_uuid else uuid4(),
> + ),
> + packages=data,
> + relationships=relationships,
> + )
> + return bom
> +
> +
> +def fixup_dict(o):
> + """Apply fixups for the BOMs.
> +
> + This is necessary for some field names and to remove fields with
> a None
> + value.
> + """
> + dct = vars(o)
> + new_dct = {}
> + for k, v in dct.items():
> + # remove fields with no content
> + if v is not None:
> + # we can not name our fields with dashes, so convert
> them
> + k = k.replace("_", "-")
> + new_dct[k] = v
> + return new_dct
> +
> +
> +def generate(d, packages: List[Package], sbom_type: SBOMType, out:
> str):
> + """Generate a SBOM."""
> + if sbom_type == SBOMType.CycloneDX:
> + bom = cyclonedx_bom(d, packages)
> + elif sbom_type == SBOMType.SPDX:
> + bom = spdx_bom(d, packages)
> +
> + with open(out, "w") as bom_file:
> + json.dump(bom, bom_file, indent=2, default=fixup_dict,
> sort_keys=True)
> diff --git a/meta/lib/sbom_cdx_types.py b/meta/lib/sbom_cdx_types.py
> new file mode 100644
> index 00000000..4911cc23
> --- /dev/null
> +++ b/meta/lib/sbom_cdx_types.py
> @@ -0,0 +1,82 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from typing import List, Optional
> +
> +# Minimal implementation of some CycloneDX SBOM types.
> +# Please mind that (almost) none of these types are complete, they
> only
> +# reflect what was strictly necessary for immediate SBOM creation
> +
> +CDX_BOM_FORMAT = "CycloneDX"
> +CDX_SPEC_VERSION = "1.6"
> +
> +CDX_REF_PREFIX = "CDXRef-"
> +
> +CDX_PACKAGE_EXTREF_TYPE_WEBSITE = "website"
> +
> +CDX_COMPONENT_TYPE_LIBRARY = "library"
> +CDX_COMPONENT_TYPE_APPLICATION = "application"
> +CDX_COMPONENT_TYPE_OS = "operating-system"
> +
> +
> +@dataclass
> +class CDXDependency:
> + ref: str
> + dependsOn: Optional[str]
> +
> +
> +@dataclass
> +class CDXExternalReference:
> + url: str
> + type: str
> + comment: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXSupplierContact:
> + email: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXSupplier:
> + name: Optional[str] = None
> + contact: Optional[CDXSupplierContact] = None
> +
> +
> +@dataclass
> +class CDXComponent:
> + type: str
> + name: str
> + bom_ref: Optional[str] = None
> + supplier: Optional[str] = None
> + version: Optional[CDXSupplier] = None
> + description: Optional[str] = None
> + purl: Optional[str] = None
> + externalReferences: Optional[List[CDXExternalReference]] = None
> + homepage: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXBOMMetadataTool:
> + components: Optional[List[CDXComponent]]
> +
> +
> +@dataclass
> +class CDXBOMMetadata:
> + timestamp: Optional[str] = None
> + component: Optional[str] = None
> + tools: Optional[List[CDXBOMMetadataTool]] = None
> +
> +
> +@dataclass
> +class CDXBOM:
> + bomFormat: str
> + specVersion: str
> + serialNumber: Optional[str] = None
> + version: Optional[str] = None
> + metadata: Optional[CDXBOMMetadata] = None
> + components: Optional[List[CDXComponent]] = None
> + dependencies: Optional[List[CDXDependency]] = None
> diff --git a/meta/lib/sbom_spdx_types.py
> b/meta/lib/sbom_spdx_types.py
> new file mode 100644
> index 00000000..efd7cc0c
> --- /dev/null
> +++ b/meta/lib/sbom_spdx_types.py
> @@ -0,0 +1,95 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from typing import List, Optional
> +
> +# Minimal implementation of some SPDX SBOM types.
> +# Please mind that (almost) none of these types are complete, they
> only
> +# reflect what was strictly necessary for immediate SBOM creation
> +
> +SPDX_VERSION = "SPDX-2.3"
> +
> +SPDX_REF_PREFIX = "SPDXRef-"
> +
> +SPDX_REF_DOCUMENT = "SPDXRef-DOCUMENT"
> +
> +SPDX_PACKAGE_PURPOSE_LIBRARY = "LIBRARY"
> +SPDX_PACKAGE_PURPOSE_OS = "OPERATING_SYSTEM"
> +SPDX_PACKAGE_PURPOSE_SRC = "SOURCE"
> +
> +SPDX_NOASSERTION = "NOASSERTION"
> +
> +SPDX_RELATIONSHIP_DEPENDS_ON = "DEPENDS_ON"
> +SPDX_RELATIONSHIP_PACKAGE_OF = "PACKAGE_OF"
> +SPDX_RELATIONSHIP_GENERATES = "GENERATES"
> +SPDX_RELATIONSHIP_DESCRIBES = "DESCRIBES"
> +
> +SPDX_REFERENCE_CATEGORY_PKG_MANAGER = "PACKAGE_MANAGER"
> +SPDX_REFERENCE_TYPE_PURL = "purl"
> +
> +# cues for an organization in the maintainer name
> +SPDX_SUPPLIER_ORG_CUE = [
> + "maintainers",
> + "group",
> + "developers",
> + "team",
> + "project",
> + "task force",
> + "strike force",
> + "packagers",
> +]
> +
> +
> +@dataclass
> +class SPDXRelationship:
> + spdxElementId: str
> + relatedSpdxElement: str
> + relationshipType: str
> +
> +
> +@dataclass
> +class SPDXExternalRef:
> + referenceCategory: str
> + referenceType: str
> + referenceLocator: str
> +
> +
> +@dataclass
> +class SPDXPackage:
> + SPDXID: str
> + name: str
> + downloadLocation: str
> + filesAnalyzed: Optional[bool] = False
> + versionInfo: Optional[str] = None
> + homepage: Optional[str] = None
> + primaryPackagePurpose: Optional[str] = None
> + supplier: Optional[str] = None
> + licenseConcluded: Optional[str] = None
> + licenseDeclared: Optional[str] = None
> + copyrightText: Optional[str] = None
> + summary: Optional[str] = None
> + externalRefs: Optional[List[SPDXExternalRef]] = None
> +
> +
> +@dataclass
> +class SPDXCreationInfo:
> + created: str
> + comment: Optional[str] = None
> + creators: List[str] = None
> +
> +
> +@dataclass
> +class SPDXBOM:
> + """Incomplete BOM as of SPDX spec v2.3."""
> +
> + SPDXID: str
> + spdxVersion: str
> + creationInfo: SPDXCreationInfo
> + name: str
> + dataLicense: str
> + documentNamespace: str
> + packages: List[SPDXPackage]
> + relationships: List[SPDXRelationship]
> --
> 2.39.5
--
Siemens AG
Linux Expert Center
Friedrich-Ludwig-Bauer-Str. 3
85748 Garching, Germany
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/dc9b7cec-3bb6-4ae7-9d74-e7b496d592f3n%40googlegroups.com.
[-- Attachment #1.2: Type: text/html, Size: 41038 bytes --]
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-07-14 12:16 ` 'Simone Weiß' via isar-users
2025-07-28 15:13 ` 'MOESSBAUER, Felix' via isar-users
@ 2025-07-29 9:08 ` 'Christoph' via isar-users
1 sibling, 0 replies; 15+ messages in thread
From: 'Christoph' via isar-users @ 2025-07-29 9:08 UTC (permalink / raw)
To: isar-users
[-- Attachment #1.1: Type: text/plain, Size: 24883 bytes --]
Hi,
Are you planing to add Concludedlicense information as per your comment in
the original? Any plan for SPDX3?
Support for SPDX3 is not currently planned. But, as already mentioned, we
are
rewriting the SBOM generator to be standalone and rely on the offical
libraries
for SPDX [1] and CycloneDX [2].
As it stands SPDX3 is listed with experimental support. Looking through
what is
available there right now, it should be enough for our use-case. The
additional
work should not be too much then. Once the second iteration of this RFC hits
patches would be very welcome :)
[1] https://pypi.org/project/spdx-tools/
[2] https://pypi.org/project/cyclonedx-bom/
On Tuesday, June 10, 2025 at 2:04:03 PM UTC+2 Christoph Steiger wrote:
FYI Benjamin, Cedric and Mete:
We are currently working on a V2 for this with more or less the same
functionality and some internal changes. It might be interesting for you
too. Maybe you could try this version out in your builds and see if
anything important/nice-to-have is missing in the SBOMs?
> From: Christoph Steiger <christop...@siemens.com>
>
> Add a new class to allow generation of software bill of materials
> (SBOM). Supported are the two standard SBOM formats CycloneDX and SPDX.
> SBOM generation is enabled per default for all images.
>
> Both formats support the minimal usecase of binary packages information
> and their dependencies. Unfortunately there is no proper way to express
> the relationships of debian source packages and their corresponding
> binary packages in the CDX format, so it is left out there.
>
> The information included in the SBOM is parsed from the dpkg status
> file found in the created image.
>
> Signed-off-by: Christoph Steiger <christop...@siemens.com>
> ---
> meta/classes/create-sbom.bbclass | 49 ++++
> meta/classes/image.bbclass | 2 +
> meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++
> meta/lib/sbom_cdx_types.py | 82 ++++++
> meta/lib/sbom_spdx_types.py | 95 +++++++
> 5 files changed, 674 insertions(+)
> create mode 100644 meta/classes/create-sbom.bbclass
> create mode 100644 meta/lib/sbom.py
> create mode 100644 meta/lib/sbom_cdx_types.py
> create mode 100644 meta/lib/sbom_spdx_types.py
>
> diff --git a/meta/classes/create-sbom.bbclass
b/meta/classes/create-sbom.bbclass
> new file mode 100644
> index 00000000..8c647699
> --- /dev/null
> +++ b/meta/classes/create-sbom.bbclass
> @@ -0,0 +1,49 @@
> +# This software is a part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +# sbom type to generate, accepted are "cyclonedx" and "spdx"
> +SBOM_TYPE ?= "cyclonedx spdx"
> +
> +# general user variables
> +SBOM_DISTRO_SUPPLIER ?= "ISAR"
> +SBOM_DISTRO_NAME ?= "ISAR-Debian-GNU-Linux"
> +SBOM_DISTRO_VERSION ?= "1.0.0"
> +SBOM_DISTRO_SUMMARY ?= "Linux distribution built with ISAR"
> +SBOM_DOCUMENT_UUID ?= ""
> +
> +# SPDX specific user variables
> +SBOM_SPDX_NAMESPACE_PREFIX ?= "https://spdx.org/spdxdocs"
> +
> +SBOM_DEPLOY_BASE = "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
> +
> +SBOM_GEN_VERSION = "0.1.0"
> +
> +# adapted from the isar-cip-core image_uuid.bbclass
> +def generate_document_uuid(d):
> + import uuid
> +
> + base_hash = d.getVar("BB_TASKHASH")
> + if base_hash is None:
> + bb.warn("no BB_TASKHASH available, SBOM UUID is not reproducible")
> + return uuid.uuid4()
> + return str(uuid.UUID(base_hash[:32], version=4))
> +
> +python do_create_sbom() {
> + import sbom
> +
> + dpkg_status = d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"
> + packages = sbom.Package.parse_status_file(dpkg_status)
> +
> + if not d.getVar("SBOM_DOCUMENT_UUID"):
> + d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))
> +
> + sbom_type = d.getVar("SBOM_TYPE")
> + if "cyclonedx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.CycloneDX,
d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
> + if "spdx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.SPDX,
d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")
> +}
> +
> +addtask do_create_sbom after do_rootfs before do_build
> diff --git a/meta/classes/image.bbclass b/meta/classes/image.bbclass
> index 56eca202..e9da6a61 100644
> --- a/meta/classes/image.bbclass
> +++ b/meta/classes/image.bbclass
> @@ -81,6 +81,8 @@ inherit image-postproc-extension
> inherit image-locales-extension
> inherit image-account-extension
>
> +inherit create-sbom
> +
> # Extra space for rootfs in MB
> ROOTFS_EXTRA ?= "64"
>
> diff --git a/meta/lib/sbom.py b/meta/lib/sbom.py
> new file mode 100644
> index 00000000..d7c79e43
> --- /dev/null
> +++ b/meta/lib/sbom.py
> @@ -0,0 +1,446 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from datetime import datetime
> +from enum import Enum
> +from typing import Dict, List, Type
> +import json
> +import re
> +from uuid import uuid4
> +
> +import sbom_cdx_types as cdx
> +import sbom_spdx_types as spdx
> +
> +
> +class SBOMType(Enum):
> + CycloneDX = (0,)
> + SPDX = (1,)
> +
> +
> +@dataclass
> +class SourcePackage:
> + name: str
> + version: str | None
> +
> + def purl(self):
> + """Return the PURL of the package."""
> + return "pkg:deb/debian/{}@{}?arch=source".format(self.name,
self.version)
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDXREF_PREFIX + "{}-src".format(self.name)
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}-src".format(self.name)
> +
> + def parse(s: str) -> Type["SourcePackage"]:
> + split = s.split(" ")
> + name = split[0]
> + try:
> + version = " ".join(split[1:]).strip("()")
> + except IndexError:
> + version = None
> +
> + return SourcePackage(name=name, version=version)
> +
> +
> +@dataclass
> +class Dependency:
> + name: str
> + version: str | None
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + "{}".format(self.name)
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}".format(self.name)
> +
> + def parse_multiple(s: str) -> List[Type["Dependency"]]:
> + """Parse a 'Depends' line in the dpkg status file."""
> + dependencies = []
> + for entry in s.split(","):
> + entry = entry.strip()
> + for entry in entry.split("|"):
> + split = entry.split("(")
> + name = split[0].strip()
> + try:
> + version = split[1].strip(")")
> + except IndexError:
> + version = None
> + dependencies.append(Dependency(name=name, version=version))
> +
> + return dependencies
> +
> +
> +@dataclass
> +class Package:
> + """Incomplete representation of a debian package."""
> +
> + name: str
> + section: str
> + maintainer: str
> + architecture: str
> + source: SourcePackage
> + version: str
> + depends: List[Dependency]
> + description: str
> + homepage: str
> +
> + def purl(self) -> str:
> + """Return the PURL of the package."""
> + purl = "pkg:deb/debian/{}@{}".format(self.name, self.version)
> + if self.architecture:
> + purl = purl + "?arch={}".format(self.architecture)
> + return purl
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type == SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + self.name
> + elif sbom_type == SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + self.name
> +
> + def parse_status_file(status_file: str) -> List[Type["Package"]]:
> + """Parse a dpkg status file."""
> + packages = []
> + with open(status_file, "r") as f:
> + name = None
> + section = None
> + maintainer = None
> + architecture = None
> + source = None
> + version = None
> + dependencies = None
> + description = None
> + homepage = None
> + for line in f.readlines():
> + if line.strip():
> + if line[0] == " ":
> + # this is a description line, we ignore it
> + continue
> + else:
> + split = line.split(":")
> + key = split[0]
> + value = ":".join(split[1:]).strip()
> + if key == "Package":
> + name = value
> + elif key == "Section":
> + section = value
> + elif key == "Maintainer":
> + maintainer = value
> + elif key == "Architecture":
> + architecture = value
> + elif key == "Source":
> + source = SourcePackage.parse(value)
> + elif key == "Version":
> + version = value
> + elif key == "Depends":
> + dependencies = Dependency.parse_multiple(value)
> + elif key == "Description":
> + description = value
> + elif key == "Homepage":
> + homepage = value
> + else:
> + # fixup source version, if not specified it is the same
> + # as the package version
> + if source and not source.version:
> + source.version = version
> + # empty line means new package, so finish the current one
> + packages.append(
> + Package(
> + name=name,
> + section=section,
> + maintainer=maintainer,
> + architecture=architecture,
> + source=source,
> + version=version,
> + depends=dependencies,
> + description=description,
> + homepage=homepage,
> + )
> + )
> + name = None
> + section = None
> + maintainer = None
> + architecture = None
> + source = None
> + version = None
> + dependencies = None
> + description = None
> + homepage = None
> +
> + return packages
> +
> +
> +def cyclonedx_bom(d, packages: List[Package]) -> Dict:
> + """Return a valid CycloneDX SBOM."""
> + data = []
> + dependencies = []
> +
> + pattern =
re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
> + for package in packages:
> + match = pattern.match(package.maintainer)
> + supplier = cdx.CDXSupplier(name=match["supplier_name"])
> + supplier_email = match["supplier_email"]
> + if supplier_email:
> + supplier.contact = [cdx.CDXSupplierContact(email=supplier_email)]
> + entry = cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_LIBRARY,
> + bom_ref=package.bom_ref(SBOMType.CycloneDX),
> + supplier=supplier,
> + name=package.name,
> + version=package.version,
> + description=package.description,
> + purl=package.purl(),
> + )
> + if package.homepage:
> + entry.externalReferences = (
> + cdx.CDXExternalReference(
> + url=package.homepage,
> + type=cdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,
> + comment="homepage",
> + ),
> + )
> + data.append(entry)
> +
> + distro_bom_ref = cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
> + distro_dependencies = []
> + # after we have found all packages we can start to resolve dependencies
> + package_names = [package.name for package in packages]
> + for package in packages:
> + distro_dependencies.append(package.bom_ref(SBOMType.CycloneDX))
> + if package.depends:
> + deps = []
> + for dep in package.depends:
> + dep_bom_ref = dep.bom_ref(SBOMType.CycloneDX)
> + # it is possibe to specify the same package multiple times, but
> + # in different versions
> + if dep.name in package_names and dep_bom_ref not in deps:
> + deps.append(dep_bom_ref)
> + else:
> + # this might happen if we have optional dependencies
> + continue
> + dependency = cdx.CDXDependency(
> + ref=package.bom_ref(SBOMType.CycloneDX),
> + dependsOn=deps,
> + )
> + dependencies.append(dependency)
> + dependency = cdx.CDXDependency(
> + ref=distro_bom_ref,
> + dependsOn=distro_dependencies,
> + )
> + dependencies.append(dependency)
> +
> + doc_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> + distro_component = cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_OS,
> + bom_ref=cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME"),
> + supplier=cdx.CDXSupplier(name=d.getVar("SBOM_DISTRO_SUPPLIER")),
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + version=d.getVar("SBOM_DISTRO_VERSION"),
> + description=d.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> + bom = cdx.CDXBOM(
> + bomFormat=cdx.CDX_BOM_FORMAT,
> + specVersion=cdx.CDX_SPEC_VERSION,
> + serialNumber="urn:uuid:{}".format(doc_uuid if doc_uuid else uuid4()),
> + version=1,
> + metadata=cdx.CDXBOMMetadata(
> + timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + component=distro_component,
> + tools=cdx.CDXBOMMetadataTool(
> + components=[
> + cdx.CDXComponent(
> + type=cdx.CDX_COMPONENT_TYPE_APPLICATION,
> + name="ISAR SBOM Generator",
> + version=d.getVar("SBOM_GEN_VERSION"),
> + )
> + ],
> + ),
> + ),
> + components=data,
> + dependencies=dependencies,
> + )
> + return bom
> +
> +
> +def spdx_bom(d, packages: List[Package]) -> Dict:
> + "Return a valid SPDX SBOM."
> +
> + data = []
> + # create a "fake" entry for the distribution
> + distro_ref = spdx.SPDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")
> + distro_package = spdx.SPDXPackage(
> + SPDXID=distro_ref,
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + versionInfo=d.getVar("SBOM_DISTRO_VERSION"),
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_OS,
> + supplier="Organization: {}".format(d.getVar("SBOM_DISTRO_SUPPLIER")),
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary=d.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + data.append(distro_package)
> +
> + pattern =
re.compile("(?P<supplier_name>^[^<]*)(\\<(?P<supplier_email>.*)\\>)?")
> + for package in packages:
> + match = pattern.match(package.maintainer)
> + supplier_name = match["supplier_name"]
> + supplier_email = match["supplier_email"]
> + if any([cue in supplier_name.lower() for cue in
spdx.SPDX_SUPPLIER_ORG_CUE]):
> + supplier = "Organization: {}".format(supplier_name)
> + else:
> + supplier = "Person: {}".format(supplier_name)
> + if supplier_email:
> + supplier += "({})".format(supplier_email)
> +
> + entry = spdx.SPDXPackage(
> + SPDXID=package.bom_ref(SBOMType.SPDX),
> + name=package.name,
> + versionInfo=package.version,
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_LIBRARY,
> + supplier=supplier,
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + # TODO: it should be possible to conclude license/copyright
> + # information, we could look e.g. in /usr/share/doc/*/copyright
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary=package.description,
> + externalRefs=[
> + spdx.SPDXExternalRef(
> + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> + referenceLocator=package.purl(),
> + )
> + ],
> + )
> + if package.homepage:
> + entry.homepage = package.homepage
> + data.append(entry)
> +
> + if package.source:
> + src_entry = spdx.SPDXPackage(
> + SPDXID=package.source.bom_ref(SBOMType.SPDX),
> + name=package.source.name,
> + versionInfo=package.source.version,
> + primaryPackagePurpose=spdx.SPDX_PACKAGE_PURPOSE_SRC,
> + supplier=supplier,
> + downloadLocation=spdx.SPDX_NOASSERTION,
> + filesAnalyzed=False,
> + licenseConcluded=spdx.SPDX_NOASSERTION,
> + licenseDeclared=spdx.SPDX_NOASSERTION,
> + copyrightText=spdx.SPDX_NOASSERTION,
> + summary="debian source code package '{}'".format(package.source.name),
> + externalRefs=[
> + spdx.SPDXExternalRef(
> + referenceCategory=spdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,
> + referenceType=spdx.SPDX_REFERENCE_TYPE_PURL,
> + referenceLocator=package.source.purl(),
> + )
> + ],
> + )
> + # source packages might be referenced multiple times
> + if src_entry not in data:
> + data.append(src_entry)
> +
> + relationships = []
> + # after we have found all packages we can start to resolve dependencies
> + package_names = [package.name for package in packages]
> + for package in packages:
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=package.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=distro_ref,
> + relationshipType=spdx.SPDX_RELATIONSHIP_PACKAGE_OF,
> + )
> + )
> + if package.depends:
> + for dep in package.depends:
> + if dep.name in package_names:
> + relationship = spdx.SPDXRelationship(
> + spdxElementId=package.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=dep.bom_ref(SBOMType.SPDX),
> + relationshipType=spdx.SPDX_RELATIONSHIP_DEPENDS_ON,
> + )
> + relationships.append(relationship)
> + else:
> + # this might happen if we have optional dependencies
> + pass
> + if package.source:
> + relationship = spdx.SPDXRelationship(
> + spdxElementId=package.source.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=package.bom_ref(SBOMType.SPDX),
> + relationshipType=spdx.SPDX_RELATIONSHIP_GENERATES,
> + )
> + relationships.append(relationship)
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=spdx.SPDX_REF_DOCUMENT,
> + relatedSpdxElement=distro_ref,
> + relationshipType=spdx.SPDX_RELATIONSHIP_DESCRIBES,
> + )
> + )
> +
> + namespace_uuid = d.getVar("SBOM_DOCUMENT_UUID")
> + timestamp = datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH")))
> + bom = spdx.SPDXBOM(
> + SPDXID=spdx.SPDX_REF_DOCUMENT,
> + spdxVersion=spdx.SPDX_VERSION,
> + creationInfo=spdx.SPDXCreationInfo(
> + comment="This document has been generated as part of an ISAR build.",
> + creators=[
> + "Tool: ISAR SBOM Generator - {}".format(d.getVar("SBOM_GEN_VERSION"))
> + ],
> + created=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + ),
> + name=d.getVar("SBOM_DISTRO_NAME"),
> + dataLicense="CC0-1.0",
> + documentNamespace="{}/{}-{}".format(
> + d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),
> + d.getVar("SBOM_DISTRO_NAME"),
> + namespace_uuid if namespace_uuid else uuid4(),
> + ),
> + packages=data,
> + relationships=relationships,
> + )
> + return bom
> +
> +
> +def fixup_dict(o):
> + """Apply fixups for the BOMs.
> +
> + This is necessary for some field names and to remove fields with a None
> + value.
> + """
> + dct = vars(o)
> + new_dct = {}
> + for k, v in dct.items():
> + # remove fields with no content
> + if v is not None:
> + # we can not name our fields with dashes, so convert them
> + k = k.replace("_", "-")
> + new_dct[k] = v
> + return new_dct
> +
> +
> +def generate(d, packages: List[Package], sbom_type: SBOMType, out: str):
> + """Generate a SBOM."""
> + if sbom_type == SBOMType.CycloneDX:
> + bom = cyclonedx_bom(d, packages)
> + elif sbom_type == SBOMType.SPDX:
> + bom = spdx_bom(d, packages)
> +
> + with open(out, "w") as bom_file:
> + json.dump(bom, bom_file, indent=2, default=fixup_dict, sort_keys=True)
> diff --git a/meta/lib/sbom_cdx_types.py b/meta/lib/sbom_cdx_types.py
> new file mode 100644
> index 00000000..4911cc23
> --- /dev/null
> +++ b/meta/lib/sbom_cdx_types.py
> @@ -0,0 +1,82 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from typing import List, Optional
> +
> +# Minimal implementation of some CycloneDX SBOM types.
> +# Please mind that (almost) none of these types are complete, they only
> +# reflect what was strictly necessary for immediate SBOM creation
> +
> +CDX_BOM_FORMAT = "CycloneDX"
> +CDX_SPEC_VERSION = "1.6"
> +
> +CDX_REF_PREFIX = "CDXRef-"
> +
> +CDX_PACKAGE_EXTREF_TYPE_WEBSITE = "website"
> +
> +CDX_COMPONENT_TYPE_LIBRARY = "library"
> +CDX_COMPONENT_TYPE_APPLICATION = "application"
> +CDX_COMPONENT_TYPE_OS = "operating-system"
> +
> +
> +@dataclass
> +class CDXDependency:
> + ref: str
> + dependsOn: Optional[str]
> +
> +
> +@dataclass
> +class CDXExternalReference:
> + url: str
> + type: str
> + comment: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXSupplierContact:
> + email: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXSupplier:
> + name: Optional[str] = None
> + contact: Optional[CDXSupplierContact] = None
> +
> +
> +@dataclass
> +class CDXComponent:
> + type: str
> + name: str
> + bom_ref: Optional[str] = None
> + supplier: Optional[str] = None
> + version: Optional[CDXSupplier] = None
> + description: Optional[str] = None
> + purl: Optional[str] = None
> + externalReferences: Optional[List[CDXExternalReference]] = None
> + homepage: Optional[str] = None
> +
> +
> +@dataclass
> +class CDXBOMMetadataTool:
> + components: Optional[List[CDXComponent]]
> +
> +
> +@dataclass
> +class CDXBOMMetadata:
> + timestamp: Optional[str] = None
> + component: Optional[str] = None
> + tools: Optional[List[CDXBOMMetadataTool]] = None
> +
> +
> +@dataclass
> +class CDXBOM:
> + bomFormat: str
> + specVersion: str
> + serialNumber: Optional[str] = None
> + version: Optional[str] = None
> + metadata: Optional[CDXBOMMetadata] = None
> + components: Optional[List[CDXComponent]] = None
> + dependencies: Optional[List[CDXDependency]] = None
> diff --git a/meta/lib/sbom_spdx_types.py b/meta/lib/sbom_spdx_types.py
> new file mode 100644
> index 00000000..efd7cc0c
> --- /dev/null
> +++ b/meta/lib/sbom_spdx_types.py
> @@ -0,0 +1,95 @@
> +# This software is part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +from dataclasses import dataclass
> +from typing import List, Optional
> +
> +# Minimal implementation of some SPDX SBOM types.
> +# Please mind that (almost) none of these types are complete, they only
> +# reflect what was strictly necessary for immediate SBOM creation
> +
> +SPDX_VERSION = "SPDX-2.3"
> +
> +SPDX_REF_PREFIX = "SPDXRef-"
> +
> +SPDX_REF_DOCUMENT = "SPDXRef-DOCUMENT"
> +
> +SPDX_PACKAGE_PURPOSE_LIBRARY = "LIBRARY"
> +SPDX_PACKAGE_PURPOSE_OS = "OPERATING_SYSTEM"
> +SPDX_PACKAGE_PURPOSE_SRC = "SOURCE"
> +
> +SPDX_NOASSERTION = "NOASSERTION"
> +
> +SPDX_RELATIONSHIP_DEPENDS_ON = "DEPENDS_ON"
> +SPDX_RELATIONSHIP_PACKAGE_OF = "PACKAGE_OF"
> +SPDX_RELATIONSHIP_GENERATES = "GENERATES"
> +SPDX_RELATIONSHIP_DESCRIBES = "DESCRIBES"
> +
> +SPDX_REFERENCE_CATEGORY_PKG_MANAGER = "PACKAGE_MANAGER"
> +SPDX_REFERENCE_TYPE_PURL = "purl"
> +
> +# cues for an organization in the maintainer name
> +SPDX_SUPPLIER_ORG_CUE = [
> + "maintainers",
> + "group",
> + "developers",
> + "team",
> + "project",
> + "task force",
> + "strike force",
> + "packagers",
> +]
> +
> +
> +@dataclass
> +class SPDXRelationship:
> + spdxElementId: str
> + relatedSpdxElement: str
> + relationshipType: str
> +
> +
> +@dataclass
> +class SPDXExternalRef:
> + referenceCategory: str
> + referenceType: str
> + referenceLocator: str
> +
> +
> +@dataclass
> +class SPDXPackage:
> + SPDXID: str
> + name: str
> + downloadLocation: str
> + filesAnalyzed: Optional[bool] = False
> + versionInfo: Optional[str] = None
> + homepage: Optional[str] = None
> + primaryPackagePurpose: Optional[str] = None
> + supplier: Optional[str] = None
> + licenseConcluded: Optional[str] = None
> + licenseDeclared: Optional[str] = None
> + copyrightText: Optional[str] = None
> + summary: Optional[str] = None
> + externalRefs: Optional[List[SPDXExternalRef]] = None
> +
> +
> +@dataclass
> +class SPDXCreationInfo:
> + created: str
> + comment: Optional[str] = None
> + creators: List[str] = None
> +
> +
> +@dataclass
> +class SPDXBOM:
> + """Incomplete BOM as of SPDX spec v2.3."""
> +
> + SPDXID: str
> + spdxVersion: str
> + creationInfo: SPDXCreationInfo
> + name: str
> + dataLicense: str
> + documentNamespace: str
> + packages: List[SPDXPackage]
> + relationships: List[SPDXRelationship]
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/1e09f457-e673-4f13-a5c3-066440a8e115n%40googlegroups.com.
[-- Attachment #1.2: Type: text/html, Size: 37439 bytes --]
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 0/1] SBOM Generation for isar
2025-02-20 9:59 [RFC PATCH 0/1] SBOM Generation for isar 'Felix Moessbauer' via isar-users
2025-02-20 9:59 ` [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation 'Felix Moessbauer' via isar-users
@ 2025-07-31 5:38 ` Syeda Shagufta Naaz
2025-07-31 6:50 ` 'Christoph Steiger' via isar-users
2025-07-31 7:24 ` 'Jan Kiszka' via isar-users
1 sibling, 2 replies; 15+ messages in thread
From: Syeda Shagufta Naaz @ 2025-07-31 5:38 UTC (permalink / raw)
To: isar-users
[-- Attachment #1.1: Type: text/plain, Size: 4233 bytes --]
Hi,
Based on the patch, it seems that the externalReferences field is added
when the homepage is available for the package. Would it be feasible to use
our own scripts to populate the externalReferences field for the packages
where it is currently missing?
For the two packages considered below, we can see that the
externalReferences field is added for one component (apparmor) and missing
from the other (adduser).
```
{
"bom-ref": "CDXRef-adduser",
"description": "add and remove users and groups",
"name": "adduser",
"purl": "pkg:deb/debian/adduser@3.134?arch=all",
"supplier": {
"contact": [
{
"email": "adduser@packages.debian.org"
}
],
"name": "Debian Adduser Developers "
},
"type": "library",
"version": "3.134"
},
{
"bom-ref": "CDXRef-apparmor",
"description": "user-space parser utility for AppArmor",
"externalReferences": [
{
"comment": "homepage",
"type": "website",
"url": "https://apparmor.net/"
}
],
"name": "apparmor",
"purl": "pkg:deb/debian/apparmor@3.0.8-3?arch=amd64",
"supplier": {
"contact": [
{
"email": "pkg-apparmor-team@lists.alioth.debian.org"
}
],
"name": "Debian AppArmor Team "
},
"type": "library",
"version": "3.0.8-3"
},
```
Thanks,
Syeda Shagufta Naaz
syedashagufta.naaz@siemens.com
On Thursday, February 20, 2025 at 3:31:38 PM UTC+5:30 Felix Moessbauer
wrote:
From: Christoph Steiger <christop...@siemens.com>
This patch would add SBOM generation support for isar.
We already generate a manifest as part of the do_rootfs task which is
used by some people internally at Siemens to create SBOMs, but it has
a proprietary format and is not documented. It also has become apparent
that more information than in the manifest is required.
To create the SBOMs we parse the dpkg status file in a given image and
have some python scripts to build a valid SBOM for the two standard
formats (CycloneDX and SPDX).
The python scripts are a very minimal implementation to generate SBOMs,
as all other tools have heavier dependencies that are not packaged in
debian. As we also require only a small subset of these libraries (we
only generate a specific version and format, using also only a small
part of the data structures) I chose to quickly implement this myself.
The current implementation also emits source package information in the
SPDX format. Unfortunately the CDX standard does not allow to map the
relationship between a debian source and binary package in a
satisfactory way, so I omitted it for now. There is talks internally
about how to represent this relationship, but it is probably a good idea
to leave it empty for now.
TODOs/next steps:
- license/copyright parsing: debian has no machine-readable format for
these, but they are very valuable for clearing purposes
- tigther bitbake integration: if we hook into each recipe we could add
more information and correctly represent vendor packages
Please tell me what you think and how we could land SBOM generation
here :-)
Christoph Steiger (1):
meta: add CycloneDX/SPDX SBOM generation
meta/classes/create-sbom.bbclass | 49 ++++
meta/classes/image.bbclass | 2 +
meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++
meta/lib/sbom_cdx_types.py | 82 ++++++
meta/lib/sbom_spdx_types.py | 95 +++++++
5 files changed, 674 insertions(+)
create mode 100644 meta/classes/create-sbom.bbclass
create mode 100644 meta/lib/sbom.py
create mode 100644 meta/lib/sbom_cdx_types.py
create mode 100644 meta/lib/sbom_spdx_types.py
--
2.39.5
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/39f0bde3-fac8-48a9-a393-2566c17831e9n%40googlegroups.com.
[-- Attachment #1.2: Type: text/html, Size: 5410 bytes --]
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 0/1] SBOM Generation for isar
2025-07-31 5:38 ` [RFC PATCH 0/1] SBOM Generation for isar Syeda Shagufta Naaz
@ 2025-07-31 6:50 ` 'Christoph Steiger' via isar-users
2025-07-31 7:24 ` 'Jan Kiszka' via isar-users
1 sibling, 0 replies; 15+ messages in thread
From: 'Christoph Steiger' via isar-users @ 2025-07-31 6:50 UTC (permalink / raw)
To: isar-users; +Cc: shaguftanaazhashmi
> Hi,
>
> Based on the patch, it seems that the externalReferences field is added
> when the homepage is available for the package. Would it be feasible to
> use our own scripts to populate the externalReferences field for the
> packages where it is currently missing?
Your assumption is correct. Nothing stops you from adding additional
external references after generation of the SBOM. This script only
considers information available in the dpkg databases, which only
contains a link to the homepage afaik.
If you know of other things in the packages that could be added to the
externalReferences field let us know so we can incorporate it.
>
> For the two packages considered below, we can see that the
> externalReferences field is added for one component (apparmor) and
> missing from the other (adduser).
> ```
> {
> "bom-ref": "CDXRef-adduser",
> "description": "add and remove users and groups",
> "name": "adduser",
> "purl": "pkg:deb/debian/adduser@3.134?arch=all",
> "supplier": {
> "contact": [
> {
> "email": "adduser@packages.debian.org"
> }
> ],
> "name": "Debian Adduser Developers "
> },
> "type": "library",
> "version": "3.134"
> },
> {
> "bom-ref": "CDXRef-apparmor",
> "description": "user-space parser utility for AppArmor",
> "externalReferences": [
> {
> "comment": "homepage",
> "type": "website",
> "url": "https://apparmor.net/"
> }
> ],
> "name": "apparmor",
> "purl": "pkg:deb/debian/apparmor@3.0.8-3?arch=amd64",
> "supplier": {
> "contact": [
> {
> "email": "pkg-apparmor-team@lists.alioth.debian.org"
> }
> ],
> "name": "Debian AppArmor Team "
> },
> "type": "library",
> "version": "3.0.8-3"
> },
> ```
>
> Thanks,
> Syeda Shagufta Naaz
> syedashagufta.naaz@siemens.com
>
> On Thursday, February 20, 2025 at 3:31:38 PM UTC+5:30 Felix Moessbauer
> wrote:
>
> From: Christoph Steiger <christop...@siemens.com>
>
> This patch would add SBOM generation support for isar.
>
> We already generate a manifest as part of the do_rootfs task which is
> used by some people internally at Siemens to create SBOMs, but it has
> a proprietary format and is not documented. It also has become apparent
> that more information than in the manifest is required.
>
> To create the SBOMs we parse the dpkg status file in a given image and
> have some python scripts to build a valid SBOM for the two standard
> formats (CycloneDX and SPDX).
>
> The python scripts are a very minimal implementation to generate SBOMs,
> as all other tools have heavier dependencies that are not packaged in
> debian. As we also require only a small subset of these libraries (we
> only generate a specific version and format, using also only a small
> part of the data structures) I chose to quickly implement this myself.
>
> The current implementation also emits source package information in the
> SPDX format. Unfortunately the CDX standard does not allow to map the
> relationship between a debian source and binary package in a
> satisfactory way, so I omitted it for now. There is talks internally
> about how to represent this relationship, but it is probably a good
> idea
> to leave it empty for now.
>
> TODOs/next steps:
> - license/copyright parsing: debian has no machine-readable format for
> these, but they are very valuable for clearing purposes
> - tigther bitbake integration: if we hook into each recipe we could add
> more information and correctly represent vendor packages
>
> Please tell me what you think and how we could land SBOM generation
> here :-)
>
> Christoph Steiger (1):
> meta: add CycloneDX/SPDX SBOM generation
>
> meta/classes/create-sbom.bbclass | 49 ++++
> meta/classes/image.bbclass | 2 +
> meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++
> meta/lib/sbom_cdx_types.py | 82 ++++++
> meta/lib/sbom_spdx_types.py | 95 +++++++
> 5 files changed, 674 insertions(+)
> create mode 100644 meta/classes/create-sbom.bbclass
> create mode 100644 meta/lib/sbom.py
> create mode 100644 meta/lib/sbom_cdx_types.py
> create mode 100644 meta/lib/sbom_spdx_types.py
>
> --
> 2.39.5
>
> --
> You received this message because you are subscribed to a topic in the
> Google Groups "isar-users" group.
> To unsubscribe from this topic, visit https://groups.google.com/d/topic/
> isar-users/8L-CF4BJY0I/unsubscribe <https://
> eur01.safelinks.protection.outlook.com/?
> url=https%3A%2F%2Fgroups.google.com%2Fd%2Ftopic%2Fisar-users%2F8L-
> CF4BJY0I%2Funsubscribe&data=05%7C02%7Cchristoph.steiger%40siemens.com%7C38fbfcce41dc4115eb7708ddcff60649%7C38ae3bcd95794fd4addab42e1495d55a%7C1%7C0%7C638895377762504213%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C0%7C%7C%7C&sdata=ZJLWNsyTk9pxvt0sF6yuQ5TBcpvp%2B1Wet%2FUFss%2BFnnQ%3D&reserved=0>.
> To unsubscribe from this group and all its topics, send an email to
> isar-users+unsubscribe@googlegroups.com <mailto:isar-
> users+unsubscribe@googlegroups.com>.
> To view this discussion visit https://groups.google.com/d/msgid/isar-
> users/39f0bde3-fac8-48a9-a393-2566c17831e9n%40googlegroups.com <https://
> eur01.safelinks.protection.outlook.com/?
> url=https%3A%2F%2Fgroups.google.com%2Fd%2Fmsgid%2Fisar-users%2F39f0bde3-
> fac8-48a9-
> a393-2566c17831e9n%2540googlegroups.com%3Futm_medium%3Demail%26utm_source%3Dfooter&data=05%7C02%7Cchristoph.steiger%40siemens.com%7C38fbfcce41dc4115eb7708ddcff60649%7C38ae3bcd95794fd4addab42e1495d55a%7C1%7C0%7C638895377762533091%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C0%7C%7C%7C&sdata=0DSm3jmR%2F1tI0a%2Bct3D2juZGQ4UmmnyNh%2FwHeEJX8u0%3D&reserved=0>.
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/2c12f6b5-a5c6-4656-99e0-5fae2043d7a4%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 0/1] SBOM Generation for isar
2025-07-31 5:38 ` [RFC PATCH 0/1] SBOM Generation for isar Syeda Shagufta Naaz
2025-07-31 6:50 ` 'Christoph Steiger' via isar-users
@ 2025-07-31 7:24 ` 'Jan Kiszka' via isar-users
1 sibling, 0 replies; 15+ messages in thread
From: 'Jan Kiszka' via isar-users @ 2025-07-31 7:24 UTC (permalink / raw)
To: Syeda Shagufta Naaz, isar-users
On 31.07.25 07:38, Syeda Shagufta Naaz wrote:
> Hi,
>
> Based on the patch, it seems that the externalReferences field is added
> when the homepage is available for the package. Would it be feasible to
> use our own scripts to populate the externalReferences field for the
> packages where it is currently missing?
At this stage, better use your time to contribute those missing entries
to the original package data. That's more sustainable.
Jan
--
Siemens AG, Foundational Technologies
Linux Expert Center
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/9473dcde-8ca6-40da-9f05-e008882c4211%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
* Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation
2025-02-20 9:59 ` [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation 'Felix Moessbauer' via isar-users
` (3 preceding siblings ...)
2025-07-28 15:24 ` 'MOESSBAUER, Felix' via isar-users
@ 2025-08-01 7:53 ` 'MOESSBAUER, Felix' via isar-users
4 siblings, 0 replies; 15+ messages in thread
From: 'MOESSBAUER, Felix' via isar-users @ 2025-08-01 7:53 UTC (permalink / raw)
To: isar-users
Cc: Steiger, Christoph, Kiszka, Jan, cedric.hombourger, Hillier, Gernot
On Thu, 2025-02-20 at 10:59 +0100, 'Felix Moessbauer' via isar-users
wrote:
> From: Christoph Steiger <christoph.steiger@siemens.com>
>
> Add a new class to allow generation of software bill of materials
> (SBOM). Supported are the two standard SBOM formats CycloneDX and
> SPDX.
> SBOM generation is enabled per default for all images.
>
> Both formats support the minimal usecase of binary packages
> information
> and their dependencies. Unfortunately there is no proper way to
> express
> the relationships of debian source packages and their corresponding
> binary packages in the CDX format, so it is left out there.
>
> The information included in the SBOM is parsed from the dpkg status
> file found in the created image.
>
> Signed-off-by: Christoph Steiger <christoph.steiger@siemens.com>
> ---
> meta/classes/create-sbom.bbclass | 49 ++++
> meta/classes/image.bbclass | 2 +
> meta/lib/sbom.py | 446
> +++++++++++++++++++++++++++++++
> meta/lib/sbom_cdx_types.py | 82 ++++++
> meta/lib/sbom_spdx_types.py | 95 +++++++
> 5 files changed, 674 insertions(+)
> create mode 100644 meta/classes/create-sbom.bbclass
> create mode 100644 meta/lib/sbom.py
> create mode 100644 meta/lib/sbom_cdx_types.py
> create mode 100644 meta/lib/sbom_spdx_types.py
>
> diff --git a/meta/classes/create-sbom.bbclass b/meta/classes/create-
> sbom.bbclass
> new file mode 100644
> index 00000000..8c647699
> --- /dev/null
> +++ b/meta/classes/create-sbom.bbclass
> @@ -0,0 +1,49 @@
> +# This software is a part of ISAR.
> +# Copyright (C) 2025 Siemens AG
> +#
> +# SPDX-License-Identifier: MIT
> +
> +# sbom type to generate, accepted are "cyclonedx" and "spdx"
> +SBOM_TYPE ?= "cyclonedx spdx"
> +
> +# general user variables
> +SBOM_DISTRO_SUPPLIER ?= "ISAR"
> +SBOM_DISTRO_NAME ?= "ISAR-Debian-GNU-Linux"
> +SBOM_DISTRO_VERSION ?= "1.0.0"
> +SBOM_DISTRO_SUMMARY ?= "Linux distribution built with ISAR"
> +SBOM_DOCUMENT_UUID ?= ""
> +
> +# SPDX specific user variables
> +SBOM_SPDX_NAMESPACE_PREFIX ?= "https://spdx.org/spdxdocs"
> +
> +SBOM_DEPLOY_BASE = "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
> +
> +SBOM_GEN_VERSION = "0.1.0"
> +
> +# adapted from the isar-cip-core image_uuid.bbclass
> +def generate_document_uuid(d):
> + import uuid
> +
> + base_hash = d.getVar("BB_TASKHASH")
> + if base_hash is None:
> + bb.warn("no BB_TASKHASH available, SBOM UUID is not
> reproducible")
> + return uuid.uuid4()
> + return str(uuid.UUID(base_hash[:32], version=4))
> +
> +python do_create_sbom() {
> + import sbom
> +
> + dpkg_status = d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"
> + packages = sbom.Package.parse_status_file(dpkg_status)
> +
> + if not d.getVar("SBOM_DOCUMENT_UUID"):
> + d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))
> +
> + sbom_type = d.getVar("SBOM_TYPE")
> + if "cyclonedx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.CycloneDX,
> d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")
> + if "spdx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.SPDX,
> d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")
> +}
> +
> +addtask do_create_sbom after do_rootfs before do_build
> diff --git a/meta/classes/image.bbclass b/meta/classes/image.bbclass
> index 56eca202..e9da6a61 100644
> --- a/meta/classes/image.bbclass
> +++ b/meta/classes/image.bbclass
> @@ -81,6 +81,8 @@ inherit image-postproc-extension
> inherit image-locales-extension
> inherit image-account-extension
>
> +inherit create-sbom
Hi,
is there a particular reasons, why we add the SBOM generation to the
image class instead of the rootfs? I'm also wondering if we could model
the SBOM generation as a rootfs feature. By that, we could easily
switch it on / off for relevant rootfs' like the external initrd or a
container rootfs.
In the image class, we could add an additional sbom-merger, that merges
the rootfs SBOMs into one big one that contains all data which finally
ends up in the image.
Felix
> +
> # Extra space for rootfs in MB
> ROOTFS_EXTRA ?= "64"
>
>
--
Siemens AG
Linux Expert Center
Friedrich-Ludwig-Bauer-Str. 3
85748 Garching, Germany
--
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/9c17b432a88fe4a1154b90213a872531b5309ed6.camel%40siemens.com.
^ permalink raw reply [flat|nested] 15+ messages in thread
end of thread, other threads:[~2025-08-01 7:54 UTC | newest]
Thread overview: 15+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-02-20 9:59 [RFC PATCH 0/1] SBOM Generation for isar 'Felix Moessbauer' via isar-users
2025-02-20 9:59 ` [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation 'Felix Moessbauer' via isar-users
2025-02-20 18:58 ` 'Gernot Hillier' via isar-users
2025-03-04 11:54 ` 'Niedermayr, BENEDIKT' via isar-users
2025-03-04 12:12 ` 'Christoph Steiger' via isar-users
2025-06-10 12:03 ` 'Christoph Steiger' via isar-users
2025-07-14 12:16 ` 'Simone Weiß' via isar-users
2025-07-28 15:13 ` 'MOESSBAUER, Felix' via isar-users
2025-07-29 9:08 ` 'Christoph' via isar-users
2025-07-28 15:24 ` 'MOESSBAUER, Felix' via isar-users
2025-07-29 8:49 ` 'Christoph' via isar-users
2025-08-01 7:53 ` 'MOESSBAUER, Felix' via isar-users
2025-07-31 5:38 ` [RFC PATCH 0/1] SBOM Generation for isar Syeda Shagufta Naaz
2025-07-31 6:50 ` 'Christoph Steiger' via isar-users
2025-07-31 7:24 ` 'Jan Kiszka' via isar-users
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox