From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from shymkent.ilbers.de ([unix socket]) by shymkent (Cyrus 2.5.10-Debian-2.5.10-3+deb9u2) with LMTPA; Tue, 29 Jul 2025 11:09:04 +0200 X-Sieve: CMU Sieve 2.4 Received: from mail-qt1-f183.google.com (mail-qt1-f183.google.com [209.85.160.183]) by shymkent.ilbers.de (8.15.2/8.15.2/Debian-8+deb9u1) with ESMTPS id 56T992tu017232 (version=TLSv1.2 cipher=ECDHE-RSA-AES256-GCM-SHA384 bits=256 verify=NOT) for ; Tue, 29 Jul 2025 11:09:02 +0200 Received: by mail-qt1-f183.google.com with SMTP id d75a77b69052e-4ab801d931csf109557981cf.2 for ; Tue, 29 Jul 2025 02:09:02 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=googlegroups.com; s=20230601; t=1753780136; x=1754384936; darn=ilbers.de; h=list-unsubscribe:list-subscribe:list-archive:list-help:list-post :list-id:mailing-list:precedence:reply-to:x-original-sender :mime-version:subject:references:in-reply-to:message-id:to:from:date :from:to:cc:subject:date:message-id:reply-to; bh=LW9tp4uEtDa9fhyp0zB8Bq6WhyoDjdzKkZu4ZUACCZE=; b=LKfYHymtxcmPFBduZ/D+G/YkgYTTXYpr8T6rL5jIt0mDa5W20hzZ/Tb/Kar3/oj8yw cs4KnukexnVQ4wkeHpZIUHTHc1C4HocNsrZQuqTLUBoOwX2ZPZk8QHFo1oddqmpY1ESo OI4Jp6eW7Y6u2hbvfajryYMpDfX3HLBCEXqeEMD9jbDCfCe2FGv+UrFyUs0x8GGBsyjE JU37vDBDjKsyKi8AauTG1WCLSlshgg9H5kHIBp7uktlopNjbjb7lk8xyarULup/Qmpmi FQV7RnF9NcOsXvFSWjQzaOgWq35WxxMu/lnEtMY81W/cws0wLo31Bo8NkA4Ul2csNDLP XxXw== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1753780136; x=1754384936; h=list-unsubscribe:list-subscribe:list-archive:list-help:list-post :x-spam-checked-in-group:list-id:mailing-list:precedence:reply-to :x-original-sender:mime-version:subject:references:in-reply-to :message-id:to:from:date:x-beenthere:x-gm-message-state:from:to:cc :subject:date:message-id:reply-to; bh=LW9tp4uEtDa9fhyp0zB8Bq6WhyoDjdzKkZu4ZUACCZE=; b=QydfJfF4IE8nI/2ypaW5n/+Iee1Mut0qE1+z8hXxMUZmUDvbrrATetDNmWK1q8Nv+h ENfHEgLKoYdB/a/+UustydLIT9pikvsuSQHuoh5S6sKtfKz/ka8QHs3B162O+LjH63XX XUrciVbNCfYTeF7AnE4nRBWRRzMgFiDC5jCm+YXSn+bwj01Q+oOTWF7OjDw8getGdEsM xOZZThWjyUlMExjpZx3qn44enoJuuFu0VBCKDG1PeF44NNAkCPSqmfuznZIxKuWsOcRw 02UmIGrYOB7eITj4RlzJ9YW7ZqqcBFyBbGMJmmk9C6qB1A+6ZF/XleD7NWApOkA+Lh2E KhZA== X-Forwarded-Encrypted: i=1; AJvYcCUHY5LRRQBk1ntHHyN+RnjE5YHFp3SuwE5cqXR6x2CQwcHPzGxtXTIZwfYgYwiAE3lKH6Uj@ilbers.de X-Gm-Message-State: AOJu0Ywvhyd6pIbLO2AGt1IC/if8iwsDl18r7+tzeDAxWuR3DVbNpibx Jkz2QWo4IwAbGQLautmV8Z1PPjEJ74LFVv4PfG/ssx957t6RMkGeU0Xk X-Google-Smtp-Source: AGHT+IFYlGE/VWLBbuTRdBIpaG7Oodt3m72WXLnkkjOCJZl42ebURiSm/Sj2RMHML3zgV5l9st8AuA== X-Received: by 2002:a05:622a:1ba3:b0:4a9:c8e3:a38 with SMTP id d75a77b69052e-4ae8f086f8dmr219350091cf.30.1753780135248; Tue, 29 Jul 2025 02:08:55 -0700 (PDT) X-BeenThere: isar-users@googlegroups.com; h=AZMbMZfMgPOe+EdE4ftF6l1HBJzsmY2RxxQ/8pwNHapYnGyu1w== Received: by 2002:a05:622a:552:b0:4ab:40fc:abd with SMTP id d75a77b69052e-4ae7bd312b7ls100820981cf.1.-pod-prod-06-us; Tue, 29 Jul 2025 02:08:54 -0700 (PDT) X-Received: by 2002:a05:620a:d85:b0:7e6:5ef5:8469 with SMTP id af79cd13be357-7e65ef5990dmr406243785a.63.1753780134332; Tue, 29 Jul 2025 02:08:54 -0700 (PDT) Date: Tue, 29 Jul 2025 02:08:54 -0700 (PDT) From: "'Christoph' via isar-users" To: isar-users Message-Id: <1e09f457-e673-4f13-a5c3-066440a8e115n@googlegroups.com> In-Reply-To: <0f4c261e-305d-467d-92a2-2fee7848571fn@googlegroups.com> References: <20250220095944.114203-1-felix.moessbauer@siemens.com> <20250220095944.114203-2-felix.moessbauer@siemens.com> <0f4c261e-305d-467d-92a2-2fee7848571fn@googlegroups.com> Subject: Re: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="----=_Part_861138_1328528368.1753780134120" X-Original-Sender: christoph.steiger@siemens.com X-Original-From: Christoph Reply-To: Christoph Precedence: list Mailing-list: list isar-users@googlegroups.com; contact isar-users+owners@googlegroups.com List-ID: X-Spam-Checked-In-Group: isar-users@googlegroups.com X-Google-Group-Id: 914930254986 List-Post: , List-Help: , List-Archive: , List-Unsubscribe: , X-Spam-Status: No, score=-4.9 required=5.0 tests=DKIMWL_WL_MED,DKIM_SIGNED, DKIM_VALID,DKIM_VALID_AU,DKIM_VALID_EF,HTML_MESSAGE,MAILING_LIST_MULTI, RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H3,RCVD_IN_MSPIKE_WL, RCVD_IN_RP_CERTIFIED,RCVD_IN_RP_RNBL,RCVD_IN_RP_SAFE,SPF_PASS autolearn=unavailable autolearn_force=no version=3.4.2 X-Spam-Checker-Version: SpamAssassin 3.4.2 (2018-09-13) on shymkent.ilbers.de X-TUID: S3iVAAvUJR5A ------=_Part_861138_1328528368.1753780134120 Content-Type: multipart/alternative; boundary="----=_Part_861139_1064841550.1753780134120" ------=_Part_861139_1064841550.1753780134120 Content-Type: text/plain; charset="UTF-8" Content-Transfer-Encoding: quoted-printable Hi, Are you planing to add Concludedlicense information as per your comment in= =20 the original? Any plan for SPDX3? =20 Support for SPDX3 is not currently planned. But, as already mentioned, we= =20 are rewriting the SBOM generator to be standalone and rely on the offical=20 libraries for SPDX [1] and CycloneDX [2].=20 As it stands SPDX3 is listed with experimental support. Looking through=20 what is available there right now, it should be enough for our use-case. The=20 additional work should not be too much then. Once the second iteration of this RFC hit= s 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=E2=80=AFPM UTC+2 Christoph Steiger wro= te: FYI Benjamin, Cedric and Mete:=20 We are currently working on a V2 for this with more or less the same=20 functionality and some internal changes. It might be interesting for you=20 too. Maybe you could try this version out in your builds and see if=20 anything important/nice-to-have is missing in the SBOMs?=20 > From: Christoph Steiger =20 >=20 > Add a new class to allow generation of software bill of materials=20 > (SBOM). Supported are the two standard SBOM formats CycloneDX and SPDX.= =20 > SBOM generation is enabled per default for all images.=20 >=20 > Both formats support the minimal usecase of binary packages information= =20 > and their dependencies. Unfortunately there is no proper way to express= =20 > the relationships of debian source packages and their corresponding=20 > binary packages in the CDX format, so it is left out there.=20 >=20 > The information included in the SBOM is parsed from the dpkg status=20 > file found in the created image.=20 >=20 > Signed-off-by: Christoph Steiger =20 > ---=20 > meta/classes/create-sbom.bbclass | 49 ++++=20 > meta/classes/image.bbclass | 2 +=20 > meta/lib/sbom.py | 446 +++++++++++++++++++++++++++++++=20 > meta/lib/sbom_cdx_types.py | 82 ++++++=20 > meta/lib/sbom_spdx_types.py | 95 +++++++=20 > 5 files changed, 674 insertions(+)=20 > create mode 100644 meta/classes/create-sbom.bbclass=20 > create mode 100644 meta/lib/sbom.py=20 > create mode 100644 meta/lib/sbom_cdx_types.py=20 > create mode 100644 meta/lib/sbom_spdx_types.py=20 >=20 > diff --git a/meta/classes/create-sbom.bbclass=20 b/meta/classes/create-sbom.bbclass=20 > new file mode 100644=20 > index 00000000..8c647699=20 > --- /dev/null=20 > +++ b/meta/classes/create-sbom.bbclass=20 > @@ -0,0 +1,49 @@=20 > +# This software is a part of ISAR.=20 > +# Copyright (C) 2025 Siemens AG=20 > +#=20 > +# SPDX-License-Identifier: MIT=20 > +=20 > +# sbom type to generate, accepted are "cyclonedx" and "spdx"=20 > +SBOM_TYPE ?=3D "cyclonedx spdx"=20 > +=20 > +# general user variables=20 > +SBOM_DISTRO_SUPPLIER ?=3D "ISAR"=20 > +SBOM_DISTRO_NAME ?=3D "ISAR-Debian-GNU-Linux"=20 > +SBOM_DISTRO_VERSION ?=3D "1.0.0"=20 > +SBOM_DISTRO_SUMMARY ?=3D "Linux distribution built with ISAR"=20 > +SBOM_DOCUMENT_UUID ?=3D ""=20 > +=20 > +# SPDX specific user variables=20 > +SBOM_SPDX_NAMESPACE_PREFIX ?=3D "https://spdx.org/spdxdocs"=20 > +=20 > +SBOM_DEPLOY_BASE =3D "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"=20 > +=20 > +SBOM_GEN_VERSION =3D "0.1.0"=20 > +=20 > +# adapted from the isar-cip-core image_uuid.bbclass=20 > +def generate_document_uuid(d):=20 > + import uuid=20 > +=20 > + base_hash =3D d.getVar("BB_TASKHASH")=20 > + if base_hash is None:=20 > + bb.warn("no BB_TASKHASH available, SBOM UUID is not reproducible")=20 > + return uuid.uuid4()=20 > + return str(uuid.UUID(base_hash[:32], version=3D4))=20 > +=20 > +python do_create_sbom() {=20 > + import sbom=20 > +=20 > + dpkg_status =3D d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/status"=20 > + packages =3D sbom.Package.parse_status_file(dpkg_status)=20 > +=20 > + if not d.getVar("SBOM_DOCUMENT_UUID"):=20 > + d.setVar("SBOM_DOCUMENT_UUID", generate_document_uuid(d))=20 > +=20 > + sbom_type =3D d.getVar("SBOM_TYPE")=20 > + if "cyclonedx" in sbom_type:=20 > + sbom.generate(d, packages, sbom.SBOMType.CycloneDX,=20 d.getVar("SBOM_DEPLOY_BASE") + ".cyclonedx.json")=20 > + if "spdx" in sbom_type:=20 > + sbom.generate(d, packages, sbom.SBOMType.SPDX,=20 d.getVar("SBOM_DEPLOY_BASE") + ".spdx.json")=20 > +}=20 > +=20 > +addtask do_create_sbom after do_rootfs before do_build=20 > diff --git a/meta/classes/image.bbclass b/meta/classes/image.bbclass=20 > index 56eca202..e9da6a61 100644=20 > --- a/meta/classes/image.bbclass=20 > +++ b/meta/classes/image.bbclass=20 > @@ -81,6 +81,8 @@ inherit image-postproc-extension=20 > inherit image-locales-extension=20 > inherit image-account-extension=20 >=20 > +inherit create-sbom=20 > +=20 > # Extra space for rootfs in MB=20 > ROOTFS_EXTRA ?=3D "64"=20 >=20 > diff --git a/meta/lib/sbom.py b/meta/lib/sbom.py=20 > new file mode 100644=20 > index 00000000..d7c79e43=20 > --- /dev/null=20 > +++ b/meta/lib/sbom.py=20 > @@ -0,0 +1,446 @@=20 > +# This software is part of ISAR.=20 > +# Copyright (C) 2025 Siemens AG=20 > +#=20 > +# SPDX-License-Identifier: MIT=20 > +=20 > +from dataclasses import dataclass=20 > +from datetime import datetime=20 > +from enum import Enum=20 > +from typing import Dict, List, Type=20 > +import json=20 > +import re=20 > +from uuid import uuid4=20 > +=20 > +import sbom_cdx_types as cdx=20 > +import sbom_spdx_types as spdx=20 > +=20 > +=20 > +class SBOMType(Enum):=20 > + CycloneDX =3D (0,)=20 > + SPDX =3D (1,)=20 > +=20 > +=20 > +@dataclass=20 > +class SourcePackage:=20 > + name: str=20 > + version: str | None=20 > +=20 > + def purl(self):=20 > + """Return the PURL of the package."""=20 > + return "pkg:deb/debian/{}@{}?arch=3Dsource".format(self.name,=20 self.version)=20 > +=20 > + def bom_ref(self, sbom_type: SBOMType) -> str:=20 > + """Return a unique BOM reference."""=20 > + if sbom_type =3D=3D SBOMType.CycloneDX:=20 > + return cdx.CDXREF_PREFIX + "{}-src".format(self.name)=20 > + elif sbom_type =3D=3D SBOMType.SPDX:=20 > + return spdx.SPDX_REF_PREFIX + "{}-src".format(self.name)=20 > +=20 > + def parse(s: str) -> Type["SourcePackage"]:=20 > + split =3D s.split(" ")=20 > + name =3D split[0]=20 > + try:=20 > + version =3D " ".join(split[1:]).strip("()")=20 > + except IndexError:=20 > + version =3D None=20 > +=20 > + return SourcePackage(name=3Dname, version=3Dversion)=20 > +=20 > +=20 > +@dataclass=20 > +class Dependency:=20 > + name: str=20 > + version: str | None=20 > +=20 > + def bom_ref(self, sbom_type: SBOMType) -> str:=20 > + """Return a unique BOM reference."""=20 > + if sbom_type =3D=3D SBOMType.CycloneDX:=20 > + return cdx.CDX_REF_PREFIX + "{}".format(self.name)=20 > + elif sbom_type =3D=3D SBOMType.SPDX:=20 > + return spdx.SPDX_REF_PREFIX + "{}".format(self.name)=20 > +=20 > + def parse_multiple(s: str) -> List[Type["Dependency"]]:=20 > + """Parse a 'Depends' line in the dpkg status file."""=20 > + dependencies =3D []=20 > + for entry in s.split(","):=20 > + entry =3D entry.strip()=20 > + for entry in entry.split("|"):=20 > + split =3D entry.split("(")=20 > + name =3D split[0].strip()=20 > + try:=20 > + version =3D split[1].strip(")")=20 > + except IndexError:=20 > + version =3D None=20 > + dependencies.append(Dependency(name=3Dname, version=3Dversion))=20 > +=20 > + return dependencies=20 > +=20 > +=20 > +@dataclass=20 > +class Package:=20 > + """Incomplete representation of a debian package."""=20 > +=20 > + name: str=20 > + section: str=20 > + maintainer: str=20 > + architecture: str=20 > + source: SourcePackage=20 > + version: str=20 > + depends: List[Dependency]=20 > + description: str=20 > + homepage: str=20 > +=20 > + def purl(self) -> str:=20 > + """Return the PURL of the package."""=20 > + purl =3D "pkg:deb/debian/{}@{}".format(self.name, self.version)=20 > + if self.architecture:=20 > + purl =3D purl + "?arch=3D{}".format(self.architecture)=20 > + return purl=20 > +=20 > + def bom_ref(self, sbom_type: SBOMType) -> str:=20 > + """Return a unique BOM reference."""=20 > + if sbom_type =3D=3D SBOMType.CycloneDX:=20 > + return cdx.CDX_REF_PREFIX + self.name=20 > + elif sbom_type =3D=3D SBOMType.SPDX:=20 > + return spdx.SPDX_REF_PREFIX + self.name=20 > +=20 > + def parse_status_file(status_file: str) -> List[Type["Package"]]:=20 > + """Parse a dpkg status file."""=20 > + packages =3D []=20 > + with open(status_file, "r") as f:=20 > + name =3D None=20 > + section =3D None=20 > + maintainer =3D None=20 > + architecture =3D None=20 > + source =3D None=20 > + version =3D None=20 > + dependencies =3D None=20 > + description =3D None=20 > + homepage =3D None=20 > + for line in f.readlines():=20 > + if line.strip():=20 > + if line[0] =3D=3D " ":=20 > + # this is a description line, we ignore it=20 > + continue=20 > + else:=20 > + split =3D line.split(":")=20 > + key =3D split[0]=20 > + value =3D ":".join(split[1:]).strip()=20 > + if key =3D=3D "Package":=20 > + name =3D value=20 > + elif key =3D=3D "Section":=20 > + section =3D value=20 > + elif key =3D=3D "Maintainer":=20 > + maintainer =3D value=20 > + elif key =3D=3D "Architecture":=20 > + architecture =3D value=20 > + elif key =3D=3D "Source":=20 > + source =3D SourcePackage.parse(value)=20 > + elif key =3D=3D "Version":=20 > + version =3D value=20 > + elif key =3D=3D "Depends":=20 > + dependencies =3D Dependency.parse_multiple(value)=20 > + elif key =3D=3D "Description":=20 > + description =3D value=20 > + elif key =3D=3D "Homepage":=20 > + homepage =3D value=20 > + else:=20 > + # fixup source version, if not specified it is the same=20 > + # as the package version=20 > + if source and not source.version:=20 > + source.version =3D version=20 > + # empty line means new package, so finish the current one=20 > + packages.append(=20 > + Package(=20 > + name=3Dname,=20 > + section=3Dsection,=20 > + maintainer=3Dmaintainer,=20 > + architecture=3Darchitecture,=20 > + source=3Dsource,=20 > + version=3Dversion,=20 > + depends=3Ddependencies,=20 > + description=3Ddescription,=20 > + homepage=3Dhomepage,=20 > + )=20 > + )=20 > + name =3D None=20 > + section =3D None=20 > + maintainer =3D None=20 > + architecture =3D None=20 > + source =3D None=20 > + version =3D None=20 > + dependencies =3D None=20 > + description =3D None=20 > + homepage =3D None=20 > +=20 > + return packages=20 > +=20 > +=20 > +def cyclonedx_bom(d, packages: List[Package]) -> Dict:=20 > + """Return a valid CycloneDX SBOM."""=20 > + data =3D []=20 > + dependencies =3D []=20 > +=20 > + pattern =3D=20 re.compile("(?P^[^<]*)(\\<(?P.*)\\>)?")=20 > + for package in packages:=20 > + match =3D pattern.match(package.maintainer)=20 > + supplier =3D cdx.CDXSupplier(name=3Dmatch["supplier_name"])=20 > + supplier_email =3D match["supplier_email"]=20 > + if supplier_email:=20 > + supplier.contact =3D [cdx.CDXSupplierContact(email=3Dsupplier_email)]= =20 > + entry =3D cdx.CDXComponent(=20 > + type=3Dcdx.CDX_COMPONENT_TYPE_LIBRARY,=20 > + bom_ref=3Dpackage.bom_ref(SBOMType.CycloneDX),=20 > + supplier=3Dsupplier,=20 > + name=3Dpackage.name,=20 > + version=3Dpackage.version,=20 > + description=3Dpackage.description,=20 > + purl=3Dpackage.purl(),=20 > + )=20 > + if package.homepage:=20 > + entry.externalReferences =3D (=20 > + cdx.CDXExternalReference(=20 > + url=3Dpackage.homepage,=20 > + type=3Dcdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,=20 > + comment=3D"homepage",=20 > + ),=20 > + )=20 > + data.append(entry)=20 > +=20 > + distro_bom_ref =3D cdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")=20 > + distro_dependencies =3D []=20 > + # after we have found all packages we can start to resolve dependencies= =20 > + package_names =3D [package.name for package in packages]=20 > + for package in packages:=20 > + distro_dependencies.append(package.bom_ref(SBOMType.CycloneDX))=20 > + if package.depends:=20 > + deps =3D []=20 > + for dep in package.depends:=20 > + dep_bom_ref =3D dep.bom_ref(SBOMType.CycloneDX)=20 > + # it is possibe to specify the same package multiple times, but=20 > + # in different versions=20 > + if dep.name in package_names and dep_bom_ref not in deps:=20 > + deps.append(dep_bom_ref)=20 > + else:=20 > + # this might happen if we have optional dependencies=20 > + continue=20 > + dependency =3D cdx.CDXDependency(=20 > + ref=3Dpackage.bom_ref(SBOMType.CycloneDX),=20 > + dependsOn=3Ddeps,=20 > + )=20 > + dependencies.append(dependency)=20 > + dependency =3D cdx.CDXDependency(=20 > + ref=3Ddistro_bom_ref,=20 > + dependsOn=3Ddistro_dependencies,=20 > + )=20 > + dependencies.append(dependency)=20 > +=20 > + doc_uuid =3D d.getVar("SBOM_DOCUMENT_UUID")=20 > + distro_component =3D cdx.CDXComponent(=20 > + type=3Dcdx.CDX_COMPONENT_TYPE_OS,=20 > + bom_ref=3Dcdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME"),=20 > + supplier=3Dcdx.CDXSupplier(name=3Dd.getVar("SBOM_DISTRO_SUPPLIER")),=20 > + name=3Dd.getVar("SBOM_DISTRO_NAME"),=20 > + version=3Dd.getVar("SBOM_DISTRO_VERSION"),=20 > + description=3Dd.getVar("SBOM_DISTRO_SUMMARY"),=20 > + )=20 > +=20 > + timestamp =3D datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH"))= )=20 > + bom =3D cdx.CDXBOM(=20 > + bomFormat=3Dcdx.CDX_BOM_FORMAT,=20 > + specVersion=3Dcdx.CDX_SPEC_VERSION,=20 > + serialNumber=3D"urn:uuid:{}".format(doc_uuid if doc_uuid else uuid4()),= =20 > + version=3D1,=20 > + metadata=3Dcdx.CDXBOMMetadata(=20 > + timestamp=3Dtimestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),=20 > + component=3Ddistro_component,=20 > + tools=3Dcdx.CDXBOMMetadataTool(=20 > + components=3D[=20 > + cdx.CDXComponent(=20 > + type=3Dcdx.CDX_COMPONENT_TYPE_APPLICATION,=20 > + name=3D"ISAR SBOM Generator",=20 > + version=3Dd.getVar("SBOM_GEN_VERSION"),=20 > + )=20 > + ],=20 > + ),=20 > + ),=20 > + components=3Ddata,=20 > + dependencies=3Ddependencies,=20 > + )=20 > + return bom=20 > +=20 > +=20 > +def spdx_bom(d, packages: List[Package]) -> Dict:=20 > + "Return a valid SPDX SBOM."=20 > +=20 > + data =3D []=20 > + # create a "fake" entry for the distribution=20 > + distro_ref =3D spdx.SPDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NAME")=20 > + distro_package =3D spdx.SPDXPackage(=20 > + SPDXID=3Ddistro_ref,=20 > + name=3Dd.getVar("SBOM_DISTRO_NAME"),=20 > + versionInfo=3Dd.getVar("SBOM_DISTRO_VERSION"),=20 > + primaryPackagePurpose=3Dspdx.SPDX_PACKAGE_PURPOSE_OS,=20 > + supplier=3D"Organization: {}".format(d.getVar("SBOM_DISTRO_SUPPLIER")),= =20 > + downloadLocation=3Dspdx.SPDX_NOASSERTION,=20 > + filesAnalyzed=3DFalse,=20 > + licenseConcluded=3Dspdx.SPDX_NOASSERTION,=20 > + licenseDeclared=3Dspdx.SPDX_NOASSERTION,=20 > + copyrightText=3Dspdx.SPDX_NOASSERTION,=20 > + summary=3Dd.getVar("SBOM_DISTRO_SUMMARY"),=20 > + )=20 > +=20 > + data.append(distro_package)=20 > +=20 > + pattern =3D=20 re.compile("(?P^[^<]*)(\\<(?P.*)\\>)?")=20 > + for package in packages:=20 > + match =3D pattern.match(package.maintainer)=20 > + supplier_name =3D match["supplier_name"]=20 > + supplier_email =3D match["supplier_email"]=20 > + if any([cue in supplier_name.lower() for cue in=20 spdx.SPDX_SUPPLIER_ORG_CUE]):=20 > + supplier =3D "Organization: {}".format(supplier_name)=20 > + else:=20 > + supplier =3D "Person: {}".format(supplier_name)=20 > + if supplier_email:=20 > + supplier +=3D "({})".format(supplier_email)=20 > +=20 > + entry =3D spdx.SPDXPackage(=20 > + SPDXID=3Dpackage.bom_ref(SBOMType.SPDX),=20 > + name=3Dpackage.name,=20 > + versionInfo=3Dpackage.version,=20 > + primaryPackagePurpose=3Dspdx.SPDX_PACKAGE_PURPOSE_LIBRARY,=20 > + supplier=3Dsupplier,=20 > + downloadLocation=3Dspdx.SPDX_NOASSERTION,=20 > + filesAnalyzed=3DFalse,=20 > + # TODO: it should be possible to conclude license/copyright=20 > + # information, we could look e.g. in /usr/share/doc/*/copyright=20 > + licenseConcluded=3Dspdx.SPDX_NOASSERTION,=20 > + licenseDeclared=3Dspdx.SPDX_NOASSERTION,=20 > + copyrightText=3Dspdx.SPDX_NOASSERTION,=20 > + summary=3Dpackage.description,=20 > + externalRefs=3D[=20 > + spdx.SPDXExternalRef(=20 > + referenceCategory=3Dspdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,=20 > + referenceType=3Dspdx.SPDX_REFERENCE_TYPE_PURL,=20 > + referenceLocator=3Dpackage.purl(),=20 > + )=20 > + ],=20 > + )=20 > + if package.homepage:=20 > + entry.homepage =3D package.homepage=20 > + data.append(entry)=20 > +=20 > + if package.source:=20 > + src_entry =3D spdx.SPDXPackage(=20 > + SPDXID=3Dpackage.source.bom_ref(SBOMType.SPDX),=20 > + name=3Dpackage.source.name,=20 > + versionInfo=3Dpackage.source.version,=20 > + primaryPackagePurpose=3Dspdx.SPDX_PACKAGE_PURPOSE_SRC,=20 > + supplier=3Dsupplier,=20 > + downloadLocation=3Dspdx.SPDX_NOASSERTION,=20 > + filesAnalyzed=3DFalse,=20 > + licenseConcluded=3Dspdx.SPDX_NOASSERTION,=20 > + licenseDeclared=3Dspdx.SPDX_NOASSERTION,=20 > + copyrightText=3Dspdx.SPDX_NOASSERTION,=20 > + summary=3D"debian source code package '{}'".format(package.source.name)= ,=20 > + externalRefs=3D[=20 > + spdx.SPDXExternalRef(=20 > + referenceCategory=3Dspdx.SPDX_REFERENCE_CATEGORY_PKG_MANAGER,=20 > + referenceType=3Dspdx.SPDX_REFERENCE_TYPE_PURL,=20 > + referenceLocator=3Dpackage.source.purl(),=20 > + )=20 > + ],=20 > + )=20 > + # source packages might be referenced multiple times=20 > + if src_entry not in data:=20 > + data.append(src_entry)=20 > +=20 > + relationships =3D []=20 > + # after we have found all packages we can start to resolve dependencies= =20 > + package_names =3D [package.name for package in packages]=20 > + for package in packages:=20 > + relationships.append(=20 > + spdx.SPDXRelationship(=20 > + spdxElementId=3Dpackage.bom_ref(SBOMType.SPDX),=20 > + relatedSpdxElement=3Ddistro_ref,=20 > + relationshipType=3Dspdx.SPDX_RELATIONSHIP_PACKAGE_OF,=20 > + )=20 > + )=20 > + if package.depends:=20 > + for dep in package.depends:=20 > + if dep.name in package_names:=20 > + relationship =3D spdx.SPDXRelationship(=20 > + spdxElementId=3Dpackage.bom_ref(SBOMType.SPDX),=20 > + relatedSpdxElement=3Ddep.bom_ref(SBOMType.SPDX),=20 > + relationshipType=3Dspdx.SPDX_RELATIONSHIP_DEPENDS_ON,=20 > + )=20 > + relationships.append(relationship)=20 > + else:=20 > + # this might happen if we have optional dependencies=20 > + pass=20 > + if package.source:=20 > + relationship =3D spdx.SPDXRelationship(=20 > + spdxElementId=3Dpackage.source.bom_ref(SBOMType.SPDX),=20 > + relatedSpdxElement=3Dpackage.bom_ref(SBOMType.SPDX),=20 > + relationshipType=3Dspdx.SPDX_RELATIONSHIP_GENERATES,=20 > + )=20 > + relationships.append(relationship)=20 > + relationships.append(=20 > + spdx.SPDXRelationship(=20 > + spdxElementId=3Dspdx.SPDX_REF_DOCUMENT,=20 > + relatedSpdxElement=3Ddistro_ref,=20 > + relationshipType=3Dspdx.SPDX_RELATIONSHIP_DESCRIBES,=20 > + )=20 > + )=20 > +=20 > + namespace_uuid =3D d.getVar("SBOM_DOCUMENT_UUID")=20 > + timestamp =3D datetime.fromtimestamp(int(d.getVar("SOURCE_DATE_EPOCH"))= )=20 > + bom =3D spdx.SPDXBOM(=20 > + SPDXID=3Dspdx.SPDX_REF_DOCUMENT,=20 > + spdxVersion=3Dspdx.SPDX_VERSION,=20 > + creationInfo=3Dspdx.SPDXCreationInfo(=20 > + comment=3D"This document has been generated as part of an ISAR build.",= =20 > + creators=3D[=20 > + "Tool: ISAR SBOM Generator - {}".format(d.getVar("SBOM_GEN_VERSION"))= =20 > + ],=20 > + created=3Dtimestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),=20 > + ),=20 > + name=3Dd.getVar("SBOM_DISTRO_NAME"),=20 > + dataLicense=3D"CC0-1.0",=20 > + documentNamespace=3D"{}/{}-{}".format(=20 > + d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),=20 > + d.getVar("SBOM_DISTRO_NAME"),=20 > + namespace_uuid if namespace_uuid else uuid4(),=20 > + ),=20 > + packages=3Ddata,=20 > + relationships=3Drelationships,=20 > + )=20 > + return bom=20 > +=20 > +=20 > +def fixup_dict(o):=20 > + """Apply fixups for the BOMs.=20 > +=20 > + This is necessary for some field names and to remove fields with a None= =20 > + value.=20 > + """=20 > + dct =3D vars(o)=20 > + new_dct =3D {}=20 > + for k, v in dct.items():=20 > + # remove fields with no content=20 > + if v is not None:=20 > + # we can not name our fields with dashes, so convert them=20 > + k =3D k.replace("_", "-")=20 > + new_dct[k] =3D v=20 > + return new_dct=20 > +=20 > +=20 > +def generate(d, packages: List[Package], sbom_type: SBOMType, out: str):= =20 > + """Generate a SBOM."""=20 > + if sbom_type =3D=3D SBOMType.CycloneDX:=20 > + bom =3D cyclonedx_bom(d, packages)=20 > + elif sbom_type =3D=3D SBOMType.SPDX:=20 > + bom =3D spdx_bom(d, packages)=20 > +=20 > + with open(out, "w") as bom_file:=20 > + json.dump(bom, bom_file, indent=3D2, default=3Dfixup_dict, sort_keys=3D= True)=20 > diff --git a/meta/lib/sbom_cdx_types.py b/meta/lib/sbom_cdx_types.py=20 > new file mode 100644=20 > index 00000000..4911cc23=20 > --- /dev/null=20 > +++ b/meta/lib/sbom_cdx_types.py=20 > @@ -0,0 +1,82 @@=20 > +# This software is part of ISAR.=20 > +# Copyright (C) 2025 Siemens AG=20 > +#=20 > +# SPDX-License-Identifier: MIT=20 > +=20 > +from dataclasses import dataclass=20 > +from typing import List, Optional=20 > +=20 > +# Minimal implementation of some CycloneDX SBOM types.=20 > +# Please mind that (almost) none of these types are complete, they only= =20 > +# reflect what was strictly necessary for immediate SBOM creation=20 > +=20 > +CDX_BOM_FORMAT =3D "CycloneDX"=20 > +CDX_SPEC_VERSION =3D "1.6"=20 > +=20 > +CDX_REF_PREFIX =3D "CDXRef-"=20 > +=20 > +CDX_PACKAGE_EXTREF_TYPE_WEBSITE =3D "website"=20 > +=20 > +CDX_COMPONENT_TYPE_LIBRARY =3D "library"=20 > +CDX_COMPONENT_TYPE_APPLICATION =3D "application"=20 > +CDX_COMPONENT_TYPE_OS =3D "operating-system"=20 > +=20 > +=20 > +@dataclass=20 > +class CDXDependency:=20 > + ref: str=20 > + dependsOn: Optional[str]=20 > +=20 > +=20 > +@dataclass=20 > +class CDXExternalReference:=20 > + url: str=20 > + type: str=20 > + comment: Optional[str] =3D None=20 > +=20 > +=20 > +@dataclass=20 > +class CDXSupplierContact:=20 > + email: Optional[str] =3D None=20 > +=20 > +=20 > +@dataclass=20 > +class CDXSupplier:=20 > + name: Optional[str] =3D None=20 > + contact: Optional[CDXSupplierContact] =3D None=20 > +=20 > +=20 > +@dataclass=20 > +class CDXComponent:=20 > + type: str=20 > + name: str=20 > + bom_ref: Optional[str] =3D None=20 > + supplier: Optional[str] =3D None=20 > + version: Optional[CDXSupplier] =3D None=20 > + description: Optional[str] =3D None=20 > + purl: Optional[str] =3D None=20 > + externalReferences: Optional[List[CDXExternalReference]] =3D None=20 > + homepage: Optional[str] =3D None=20 > +=20 > +=20 > +@dataclass=20 > +class CDXBOMMetadataTool:=20 > + components: Optional[List[CDXComponent]]=20 > +=20 > +=20 > +@dataclass=20 > +class CDXBOMMetadata:=20 > + timestamp: Optional[str] =3D None=20 > + component: Optional[str] =3D None=20 > + tools: Optional[List[CDXBOMMetadataTool]] =3D None=20 > +=20 > +=20 > +@dataclass=20 > +class CDXBOM:=20 > + bomFormat: str=20 > + specVersion: str=20 > + serialNumber: Optional[str] =3D None=20 > + version: Optional[str] =3D None=20 > + metadata: Optional[CDXBOMMetadata] =3D None=20 > + components: Optional[List[CDXComponent]] =3D None=20 > + dependencies: Optional[List[CDXDependency]] =3D None=20 > diff --git a/meta/lib/sbom_spdx_types.py b/meta/lib/sbom_spdx_types.py=20 > new file mode 100644=20 > index 00000000..efd7cc0c=20 > --- /dev/null=20 > +++ b/meta/lib/sbom_spdx_types.py=20 > @@ -0,0 +1,95 @@=20 > +# This software is part of ISAR.=20 > +# Copyright (C) 2025 Siemens AG=20 > +#=20 > +# SPDX-License-Identifier: MIT=20 > +=20 > +from dataclasses import dataclass=20 > +from typing import List, Optional=20 > +=20 > +# Minimal implementation of some SPDX SBOM types.=20 > +# Please mind that (almost) none of these types are complete, they only= =20 > +# reflect what was strictly necessary for immediate SBOM creation=20 > +=20 > +SPDX_VERSION =3D "SPDX-2.3"=20 > +=20 > +SPDX_REF_PREFIX =3D "SPDXRef-"=20 > +=20 > +SPDX_REF_DOCUMENT =3D "SPDXRef-DOCUMENT"=20 > +=20 > +SPDX_PACKAGE_PURPOSE_LIBRARY =3D "LIBRARY"=20 > +SPDX_PACKAGE_PURPOSE_OS =3D "OPERATING_SYSTEM"=20 > +SPDX_PACKAGE_PURPOSE_SRC =3D "SOURCE"=20 > +=20 > +SPDX_NOASSERTION =3D "NOASSERTION"=20 > +=20 > +SPDX_RELATIONSHIP_DEPENDS_ON =3D "DEPENDS_ON"=20 > +SPDX_RELATIONSHIP_PACKAGE_OF =3D "PACKAGE_OF"=20 > +SPDX_RELATIONSHIP_GENERATES =3D "GENERATES"=20 > +SPDX_RELATIONSHIP_DESCRIBES =3D "DESCRIBES"=20 > +=20 > +SPDX_REFERENCE_CATEGORY_PKG_MANAGER =3D "PACKAGE_MANAGER"=20 > +SPDX_REFERENCE_TYPE_PURL =3D "purl"=20 > +=20 > +# cues for an organization in the maintainer name=20 > +SPDX_SUPPLIER_ORG_CUE =3D [=20 > + "maintainers",=20 > + "group",=20 > + "developers",=20 > + "team",=20 > + "project",=20 > + "task force",=20 > + "strike force",=20 > + "packagers",=20 > +]=20 > +=20 > +=20 > +@dataclass=20 > +class SPDXRelationship:=20 > + spdxElementId: str=20 > + relatedSpdxElement: str=20 > + relationshipType: str=20 > +=20 > +=20 > +@dataclass=20 > +class SPDXExternalRef:=20 > + referenceCategory: str=20 > + referenceType: str=20 > + referenceLocator: str=20 > +=20 > +=20 > +@dataclass=20 > +class SPDXPackage:=20 > + SPDXID: str=20 > + name: str=20 > + downloadLocation: str=20 > + filesAnalyzed: Optional[bool] =3D False=20 > + versionInfo: Optional[str] =3D None=20 > + homepage: Optional[str] =3D None=20 > + primaryPackagePurpose: Optional[str] =3D None=20 > + supplier: Optional[str] =3D None=20 > + licenseConcluded: Optional[str] =3D None=20 > + licenseDeclared: Optional[str] =3D None=20 > + copyrightText: Optional[str] =3D None=20 > + summary: Optional[str] =3D None=20 > + externalRefs: Optional[List[SPDXExternalRef]] =3D None=20 > +=20 > +=20 > +@dataclass=20 > +class SPDXCreationInfo:=20 > + created: str=20 > + comment: Optional[str] =3D None=20 > + creators: List[str] =3D None=20 > +=20 > +=20 > +@dataclass=20 > +class SPDXBOM:=20 > + """Incomplete BOM as of SPDX spec v2.3."""=20 > +=20 > + SPDXID: str=20 > + spdxVersion: str=20 > + creationInfo: SPDXCreationInfo=20 > + name: str=20 > + dataLicense: str=20 > + documentNamespace: str=20 > + packages: List[SPDXPackage]=20 > + relationships: List[SPDXRelationship]=20 --=20 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 e= mail 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. ------=_Part_861139_1064841550.1753780134120 Content-Type: text/html; charset="UTF-8" Content-Transfer-Encoding: quoted-printable
Hi,

Are you planing to add Concludedlicense information as per your comment i= n the original? Any plan for SPDX3?
=C2=A0
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 us= e-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=E2=80=AFPM UTC+2 Christoph Steiger wrote= :
FYI Benjamin, Cedric and M= ete:

We are currently working on a V2 for this with more or less the same= =20
functionality and some internal changes. It might be interesting for = you=20
too. Maybe you could try this version out in your builds and see if= =20
anything important/nice-to-have is missing in the SBOMs?

> From: Christoph Steiger <christop...@siem= ens.com>
>=20
> Add a new class to allow generation of software bill of material= s
> (SBOM). Supported are the two standard SBOM formats CycloneDX an= d SPDX.
> SBOM generation is enabled per default for all images.
>=20
> Both formats support the minimal usecase of binary packages info= rmation
> and their dependencies. Unfortunately there is no proper way to = express
> the relationships of debian source packages and their correspond= ing
> binary packages in the CDX format, so it is left out there.
>=20
> The information included in the SBOM is parsed from the dpkg sta= tus
> file found in the created image.
>=20
> Signed-off-by: Christoph Steiger <christo= p...@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
>=20
> diff --git a/meta/classes/create-sbom.bbclass b/meta/classes/cre= ate-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 ?=3D "cyclonedx spdx"
> +
> +# general user variables
> +SBOM_DISTRO_SUPPLIER ?=3D "ISAR"
> +SBOM_DISTRO_NAME ?=3D "ISAR-Debian-GNU-Linux"
> +SBOM_DISTRO_VERSION ?=3D "1.0.0"
> +SBOM_DISTRO_SUMMARY ?=3D "Linux distribution built with ISAR"
> +SBOM_DOCUMENT_UUID ?=3D ""
> +
> +# SPDX specific user variables
> +SBOM_SPDX_NAMESPACE_PREFIX ?=3D "https://spdx.org/spdxdocs"
> +
> +SBOM_DEPLOY_BASE =3D "${DEPLOY_DIR_IMAGE}/${IMAGE_FULLNAME}"
> +
> +SBOM_GEN_VERSION =3D "0.1.0"
> +
> +# adapted from the isar-cip-core image_uuid.bbclass
> +def generate_document_uuid(d):
> + import uuid
> +
> + base_hash =3D d.getVar("BB_TASKHASH")
> + if base_hash is None:
> + bb.warn("no BB_TASKHASH available, SBOM UUID is not rep= roducible")
> + return uuid.uuid4()
> + return str(uuid.UUID(base_hash[:32], version=3D4))
> +
> +python do_create_sbom() {
> + import sbom
> +
> + dpkg_status =3D d.getVar("IMAGE_ROOTFS") + "/var/lib/dpkg/s= tatus"
> + packages =3D 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 =3D d.getVar("SBOM_TYPE")
> + if "cyclonedx" in sbom_type:
> + sbom.generate(d, packages, sbom.SBOMType.CycloneDX, d.g= etVar("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.bbc= lass
> 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
> =20
> +inherit create-sbom
> +
> # Extra space for rootfs in MB
> ROOTFS_EXTRA ?=3D "64"
> =20
> 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 =3D (0,)
> + SPDX =3D (1,)
> +
> +
> +@dataclass
> +class SourcePackage:
> + name: str
> + version: str | None
> +
> + def purl(self):
> + """Return the PURL of the package."""
> + return "pkg:deb/debian/{}@{}?arch=3Dsource".format(self.name, = self.version)
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type =3D=3D SBOMType.CycloneDX:
> + return cdx.CDXREF_PREFIX + "{}-src".format(self.name)
> + elif sbom_type =3D=3D SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + "{}-src".format(self.name)
> +
> + def parse(s: str) -> Type["SourcePackage"]:
> + split =3D s.split(" ")
> + name =3D split[0]
> + try:
> + version =3D " ".join(split[1:]).strip("()")
> + except IndexError:
> + version =3D None
> +
> + return SourcePackage(name=3Dname, version=3Dversion)
> +
> +
> +@dataclass
> +class Dependency:
> + name: str
> + version: str | None
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type =3D=3D SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + "{}".format(self.name)
> + elif sbom_type =3D=3D 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 =3D []
> + for entry in s.split(","):
> + entry =3D entry.strip()
> + for entry in entry.split("|"):
> + split =3D entry.split("(")
> + name =3D split[0].strip()
> + try:
> + version =3D split[1].strip(")")
> + except IndexError:
> + version =3D None
> + dependencies.append(Dependency(name=3Dname, ver= sion=3Dversion))
> +
> + 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 =3D "pkg:deb/debian/{}@{}".format(self.name, self.version= )
> + if self.architecture:
> + purl =3D purl + "?arch=3D{}".format(self.architectu= re)
> + return purl
> +
> + def bom_ref(self, sbom_type: SBOMType) -> str:
> + """Return a unique BOM reference."""
> + if sbom_type =3D=3D SBOMType.CycloneDX:
> + return cdx.CDX_REF_PREFIX + self.name
> + elif sbom_type =3D=3D SBOMType.SPDX:
> + return spdx.SPDX_REF_PREFIX + self.name
> +
> + def parse_status_file(status_file: str) -> List[Type["Pa= ckage"]]:
> + """Parse a dpkg status file."""
> + packages =3D []
> + with open(status_file, "r") as f:
> + name =3D None
> + section =3D None
> + maintainer =3D None
> + architecture =3D None
> + source =3D None
> + version =3D None
> + dependencies =3D None
> + description =3D None
> + homepage =3D None
> + for line in f.readlines():
> + if line.strip():
> + if line[0] =3D=3D " ":
> + # this is a description line, we ignore= it
> + continue
> + else:
> + split =3D line.split(":")
> + key =3D split[0]
> + value =3D ":".join(split[1:]).strip()
> + if key =3D=3D "Package":
> + name =3D value
> + elif key =3D=3D "Section":
> + section =3D value
> + elif key =3D=3D "Maintainer":
> + maintainer =3D value
> + elif key =3D=3D "Architecture":
> + architecture =3D value
> + elif key =3D=3D "Source":
> + source =3D SourcePackage.parse(valu= e)
> + elif key =3D=3D "Version":
> + version =3D value
> + elif key =3D=3D "Depends":
> + dependencies =3D Dependency.parse_m= ultiple(value)
> + elif key =3D=3D "Description":
> + description =3D value
> + elif key =3D=3D "Homepage":
> + homepage =3D value
> + else:
> + # fixup source version, if not specified it= is the same
> + # as the package version
> + if source and not source.version:
> + source.version =3D version
> + # empty line means new package, so finish t= he current one
> + packages.append(
> + Package(
> + name=3Dname,
> + section=3Dsection,
> + maintainer=3Dmaintainer,
> + architecture=3Darchitecture,
> + source=3Dsource,
> + version=3Dversion,
> + depends=3Ddependencies,
> + description=3Ddescription,
> + homepage=3Dhomepage,
> + )
> + )
> + name =3D None
> + section =3D None
> + maintainer =3D None
> + architecture =3D None
> + source =3D None
> + version =3D None
> + dependencies =3D None
> + description =3D None
> + homepage =3D None
> +
> + return packages
> +
> +
> +def cyclonedx_bom(d, packages: List[Package]) -> Dict:
> + """Return a valid CycloneDX SBOM."""
> + data =3D []
> + dependencies =3D []
> +
> + pattern =3D re.compile("(?P<supplier_name>^[^<]*)(= \\<(?P<supplier_email>.*)\\>)?")
> + for package in packages:
> + match =3D pattern.match(package.maintainer)
> + supplier =3D cdx.CDXSupplier(name=3Dmatch["supplier_nam= e"])
> + supplier_email =3D match["supplier_email"]
> + if supplier_email:
> + supplier.contact =3D [cdx.CDXSupplierContact(email= =3Dsupplier_email)]
> + entry =3D cdx.CDXComponent(
> + type=3Dcdx.CDX_COMPONENT_TYPE_LIBRARY,
> + bom_ref=3Dpackage.bom_ref(SBOMType.CycloneDX),
> + supplier=3Dsupplier,
> + name=3Dpackage.name,
> + version=3Dpackage.version,
> + description=3Dpackage.description,
> + purl=3Dpackage.purl(),
> + )
> + if package.homepage:
> + entry.externalReferences =3D (
> + cdx.CDXExternalReference(
> + url=3Dpackage.homepage,
> + type=3Dcdx.CDX_PACKAGE_EXTREF_TYPE_WEBSITE,
> + comment=3D"homepage",
> + ),
> + )
> + data.append(entry)
> +
> + distro_bom_ref =3D cdx.CDX_REF_PREFIX + d.getVar("SBOM_DIST= RO_NAME")
> + distro_dependencies =3D []
> + # after we have found all packages we can start to resolve = dependencies
> + package_names =3D [package.name for package in packages]
> + for package in packages:
> + distro_dependencies.append(package.bom_ref(SBOMType.Cyc= loneDX))
> + if package.depends:
> + deps =3D []
> + for dep in package.depends:
> + dep_bom_ref =3D dep.bom_ref(SBOMType.CycloneDX)
> + # it is possibe to specify the same package mul= tiple times, but
> + # in different versions
> + if dep.name in package_names and dep_bom_ref not in dep= s:
> + deps.append(dep_bom_ref)
> + else:
> + # this might happen if we have optional dep= endencies
> + continue
> + dependency =3D cdx.CDXDependency(
> + ref=3Dpackage.bom_ref(SBOMType.CycloneDX),
> + dependsOn=3Ddeps,
> + )
> + dependencies.append(dependency)
> + dependency =3D cdx.CDXDependency(
> + ref=3Ddistro_bom_ref,
> + dependsOn=3Ddistro_dependencies,
> + )
> + dependencies.append(dependency)
> +
> + doc_uuid =3D d.getVar("SBOM_DOCUMENT_UUID")
> + distro_component =3D cdx.CDXComponent(
> + type=3Dcdx.CDX_COMPONENT_TYPE_OS,
> + bom_ref=3Dcdx.CDX_REF_PREFIX + d.getVar("SBOM_DISTRO_NA= ME"),
> + supplier=3Dcdx.CDXSupplier(name=3Dd.getVar("SBOM_DISTRO= _SUPPLIER")),
> + name=3Dd.getVar("SBOM_DISTRO_NAME"),
> + version=3Dd.getVar("SBOM_DISTRO_VERSION"),
> + description=3Dd.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + timestamp =3D datetime.fromtimestamp(int(d.getVar("SOURCE_D= ATE_EPOCH")))
> + bom =3D cdx.CDXBOM(
> + bomFormat=3Dcdx.CDX_BOM_FORMAT,
> + specVersion=3Dcdx.CDX_SPEC_VERSION,
> + serialNumber=3D"urn:uuid:{}".format(doc_uuid if doc_uui= d else uuid4()),
> + version=3D1,
> + metadata=3Dcdx.CDXBOMMetadata(
> + timestamp=3Dtimestamp.strftime("%Y-%m-%dT%H:%M:%SZ"= ),
> + component=3Ddistro_component,
> + tools=3Dcdx.CDXBOMMetadataTool(
> + components=3D[
> + cdx.CDXComponent(
> + type=3Dcdx.CDX_COMPONENT_TYPE_APPLICATI= ON,
> + name=3D"ISAR SBOM Generator",
> + version=3Dd.getVar("SBOM_GEN_VERSION"),
> + )
> + ],
> + ),
> + ),
> + components=3Ddata,
> + dependencies=3Ddependencies,
> + )
> + return bom
> +
> +
> +def spdx_bom(d, packages: List[Package]) -> Dict:
> + "Return a valid SPDX SBOM."
> +
> + data =3D []
> + # create a "fake" entry for the distribution
> + distro_ref =3D spdx.SPDX_REF_PREFIX + d.getVar("SBOM_DISTRO= _NAME")
> + distro_package =3D spdx.SPDXPackage(
> + SPDXID=3Ddistro_ref,
> + name=3Dd.getVar("SBOM_DISTRO_NAME"),
> + versionInfo=3Dd.getVar("SBOM_DISTRO_VERSION"),
> + primaryPackagePurpose=3Dspdx.SPDX_PACKAGE_PURPOSE_OS,
> + supplier=3D"Organization: {}".format(d.getVar("SBOM_DIS= TRO_SUPPLIER")),
> + downloadLocation=3Dspdx.SPDX_NOASSERTION,
> + filesAnalyzed=3DFalse,
> + licenseConcluded=3Dspdx.SPDX_NOASSERTION,
> + licenseDeclared=3Dspdx.SPDX_NOASSERTION,
> + copyrightText=3Dspdx.SPDX_NOASSERTION,
> + summary=3Dd.getVar("SBOM_DISTRO_SUMMARY"),
> + )
> +
> + data.append(distro_package)
> +
> + pattern =3D re.compile("(?P<supplier_name>^[^<]*)(= \\<(?P<supplier_email>.*)\\>)?")
> + for package in packages:
> + match =3D pattern.match(package.maintainer)
> + supplier_name =3D match["supplier_name"]
> + supplier_email =3D match["supplier_email"]
> + if any([cue in supplier_name.lower() for cue in spdx.SP= DX_SUPPLIER_ORG_CUE]):
> + supplier =3D "Organization: {}".format(supplier_nam= e)
> + else:
> + supplier =3D "Person: {}".format(supplier_name)
> + if supplier_email:
> + supplier +=3D "({})".format(supplier_email)
> +
> + entry =3D spdx.SPDXPackage(
> + SPDXID=3Dpackage.bom_ref(SBOMType.SPDX),
> + name=3Dpackage.name,
> + versionInfo=3Dpackage.version,
> + primaryPackagePurpose=3Dspdx.SPDX_PACKAGE_PURPOSE_L= IBRARY,
> + supplier=3Dsupplier,
> + downloadLocation=3Dspdx.SPDX_NOASSERTION,
> + filesAnalyzed=3DFalse,
> + # TODO: it should be possible to conclude license/c= opyright
> + # information, we could look e.g. in /usr/share/doc= /*/copyright
> + licenseConcluded=3Dspdx.SPDX_NOASSERTION,
> + licenseDeclared=3Dspdx.SPDX_NOASSERTION,
> + copyrightText=3Dspdx.SPDX_NOASSERTION,
> + summary=3Dpackage.description,
> + externalRefs=3D[
> + spdx.SPDXExternalRef(
> + referenceCategory=3Dspdx.SPDX_REFERENCE_CAT= EGORY_PKG_MANAGER,
> + referenceType=3Dspdx.SPDX_REFERENCE_TYPE_PU= RL,
> + referenceLocator=3Dpackage.purl(),
> + )
> + ],
> + )
> + if package.homepage:
> + entry.homepage =3D package.homepage
> + data.append(entry)
> +
> + if package.source:
> + src_entry =3D spdx.SPDXPackage(
> + SPDXID=3Dpackage.source.bom_ref(SBOMType.SPDX),
> + name=3Dpackage.source.name,
> + versionInfo=3Dpackage.source.version,
> + primaryPackagePurpose=3Dspdx.SPDX_PACKAGE_PURPO= SE_SRC,
> + supplier=3Dsupplier,
> + downloadLocation=3Dspdx.SPDX_NOASSERTION,
> + filesAnalyzed=3DFalse,
> + licenseConcluded=3Dspdx.SPDX_NOASSERTION,
> + licenseDeclared=3Dspdx.SPDX_NOASSERTION,
> + copyrightText=3Dspdx.SPDX_NOASSERTION,
> + summary=3D"debian source code package '{}'".for= mat(package.source.name),
> + externalRefs=3D[
> + spdx.SPDXExternalRef(
> + referenceCategory=3Dspdx.SPDX_REFERENCE= _CATEGORY_PKG_MANAGER,
> + referenceType=3Dspdx.SPDX_REFERENCE_TYP= E_PURL,
> + referenceLocator=3Dpackage.source.purl(= ),
> + )
> + ],
> + )
> + # source packages might be referenced multiple time= s
> + if src_entry not in data:
> + data.append(src_entry)
> +
> + relationships =3D []
> + # after we have found all packages we can start to resolve = dependencies
> + package_names =3D [package.name for package in packages]
> + for package in packages:
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=3Dpackage.bom_ref(SBOMType.SPDX),
> + relatedSpdxElement=3Ddistro_ref,
> + relationshipType=3Dspdx.SPDX_RELATIONSHIP_PACKA= GE_OF,
> + )
> + )
> + if package.depends:
> + for dep in package.depends:
> + if dep.name in package_names:
> + relationship =3D spdx.SPDXRelationship(
> + spdxElementId=3Dpackage.bom_ref(SBOMTyp= e.SPDX),
> + relatedSpdxElement=3Ddep.bom_ref(SBOMTy= pe.SPDX),
> + relationshipType=3Dspdx.SPDX_RELATIONSH= IP_DEPENDS_ON,
> + )
> + relationships.append(relationship)
> + else:
> + # this might happen if we have optional dep= endencies
> + pass
> + if package.source:
> + relationship =3D spdx.SPDXRelationship(
> + spdxElementId=3Dpackage.source.bom_ref(SBOMType= .SPDX),
> + relatedSpdxElement=3Dpackage.bom_ref(SBOMType.S= PDX),
> + relationshipType=3Dspdx.SPDX_RELATIONSHIP_GENER= ATES,
> + )
> + relationships.append(relationship)
> + relationships.append(
> + spdx.SPDXRelationship(
> + spdxElementId=3Dspdx.SPDX_REF_DOCUMENT,
> + relatedSpdxElement=3Ddistro_ref,
> + relationshipType=3Dspdx.SPDX_RELATIONSHIP_DESCRIBES= ,
> + )
> + )
> +
> + namespace_uuid =3D d.getVar("SBOM_DOCUMENT_UUID")
> + timestamp =3D datetime.fromtimestamp(int(d.getVar("SOURCE_D= ATE_EPOCH")))
> + bom =3D spdx.SPDXBOM(
> + SPDXID=3Dspdx.SPDX_REF_DOCUMENT,
> + spdxVersion=3Dspdx.SPDX_VERSION,
> + creationInfo=3Dspdx.SPDXCreationInfo(
> + comment=3D"This document has been generated as part= of an ISAR build.",
> + creators=3D[
> + "Tool: ISAR SBOM Generator - {}".format(d.getVa= r("SBOM_GEN_VERSION"))
> + ],
> + created=3Dtimestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
> + ),
> + name=3Dd.getVar("SBOM_DISTRO_NAME"),
> + dataLicense=3D"CC0-1.0",
> + documentNamespace=3D"{}/{}-{}".format(
> + d.getVar("SBOM_SPDX_NAMESPACE_PREFIX"),
> + d.getVar("SBOM_DISTRO_NAME"),
> + namespace_uuid if namespace_uuid else uuid4(),
> + ),
> + packages=3Ddata,
> + relationships=3Drelationships,
> + )
> + 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 =3D vars(o)
> + new_dct =3D {}
> + 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 conver= t them
> + k =3D k.replace("_", "-")
> + new_dct[k] =3D v
> + return new_dct
> +
> +
> +def generate(d, packages: List[Package], sbom_type: SBOMType, o= ut: str):
> + """Generate a SBOM."""
> + if sbom_type =3D=3D SBOMType.CycloneDX:
> + bom =3D cyclonedx_bom(d, packages)
> + elif sbom_type =3D=3D SBOMType.SPDX:
> + bom =3D spdx_bom(d, packages)
> +
> + with open(out, "w") as bom_file:
> + json.dump(bom, bom_file, indent=3D2, default=3Dfixup_di= ct, sort_keys=3DTrue)
> diff --git a/meta/lib/sbom_cdx_types.py b/meta/lib/sbom_cdx_type= s.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, t= hey only
> +# reflect what was strictly necessary for immediate SBOM creati= on
> +
> +CDX_BOM_FORMAT =3D "CycloneDX"
> +CDX_SPEC_VERSION =3D "1.6"
> +
> +CDX_REF_PREFIX =3D "CDXRef-"
> +
> +CDX_PACKAGE_EXTREF_TYPE_WEBSITE =3D "website"
> +
> +CDX_COMPONENT_TYPE_LIBRARY =3D "library"
> +CDX_COMPONENT_TYPE_APPLICATION =3D "application"
> +CDX_COMPONENT_TYPE_OS =3D "operating-system"
> +
> +
> +@dataclass
> +class CDXDependency:
> + ref: str
> + dependsOn: Optional[str]
> +
> +
> +@dataclass
> +class CDXExternalReference:
> + url: str
> + type: str
> + comment: Optional[str] =3D None
> +
> +
> +@dataclass
> +class CDXSupplierContact:
> + email: Optional[str] =3D None
> +
> +
> +@dataclass
> +class CDXSupplier:
> + name: Optional[str] =3D None
> + contact: Optional[CDXSupplierContact] =3D None
> +
> +
> +@dataclass
> +class CDXComponent:
> + type: str
> + name: str
> + bom_ref: Optional[str] =3D None
> + supplier: Optional[str] =3D None
> + version: Optional[CDXSupplier] =3D None
> + description: Optional[str] =3D None
> + purl: Optional[str] =3D None
> + externalReferences: Optional[List[CDXExternalReference]] = =3D None
> + homepage: Optional[str] =3D None
> +
> +
> +@dataclass
> +class CDXBOMMetadataTool:
> + components: Optional[List[CDXComponent]]
> +
> +
> +@dataclass
> +class CDXBOMMetadata:
> + timestamp: Optional[str] =3D None
> + component: Optional[str] =3D None
> + tools: Optional[List[CDXBOMMetadataTool]] =3D None
> +
> +
> +@dataclass
> +class CDXBOM:
> + bomFormat: str
> + specVersion: str
> + serialNumber: Optional[str] =3D None
> + version: Optional[str] =3D None
> + metadata: Optional[CDXBOMMetadata] =3D None
> + components: Optional[List[CDXComponent]] =3D None
> + dependencies: Optional[List[CDXDependency]] =3D None
> diff --git a/meta/lib/sbom_spdx_types.py b/meta/lib/sbom_spdx_ty= pes.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, t= hey only
> +# reflect what was strictly necessary for immediate SBOM creati= on
> +
> +SPDX_VERSION =3D "SPDX-2.3"
> +
> +SPDX_REF_PREFIX =3D "SPDXRef-"
> +
> +SPDX_REF_DOCUMENT =3D "SPDXRef-DOCUMENT"
> +
> +SPDX_PACKAGE_PURPOSE_LIBRARY =3D "LIBRARY"
> +SPDX_PACKAGE_PURPOSE_OS =3D "OPERATING_SYSTEM"
> +SPDX_PACKAGE_PURPOSE_SRC =3D "SOURCE"
> +
> +SPDX_NOASSERTION =3D "NOASSERTION"
> +
> +SPDX_RELATIONSHIP_DEPENDS_ON =3D "DEPENDS_ON"
> +SPDX_RELATIONSHIP_PACKAGE_OF =3D "PACKAGE_OF"
> +SPDX_RELATIONSHIP_GENERATES =3D "GENERATES"
> +SPDX_RELATIONSHIP_DESCRIBES =3D "DESCRIBES"
> +
> +SPDX_REFERENCE_CATEGORY_PKG_MANAGER =3D "PACKAGE_MANAGER"
> +SPDX_REFERENCE_TYPE_PURL =3D "purl"
> +
> +# cues for an organization in the maintainer name
> +SPDX_SUPPLIER_ORG_CUE =3D [
> + "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] =3D False
> + versionInfo: Optional[str] =3D None
> + homepage: Optional[str] =3D None
> + primaryPackagePurpose: Optional[str] =3D None
> + supplier: Optional[str] =3D None
> + licenseConcluded: Optional[str] =3D None
> + licenseDeclared: Optional[str] =3D None
> + copyrightText: Optional[str] =3D None
> + summary: Optional[str] =3D None
> + externalRefs: Optional[List[SPDXExternalRef]] =3D None
> +
> +
> +@dataclass
> +class SPDXCreationInfo:
> + created: str
> + comment: Optional[str] =3D None
> + creators: List[str] =3D 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 &= quot;isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an e= mail to isar-use= rs+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-use= rs/1e09f457-e673-4f13-a5c3-066440a8e115n%40googlegroups.com.
------=_Part_861139_1064841550.1753780134120-- ------=_Part_861138_1328528368.1753780134120--