From patchwork Thu Feb 20 09:59:44 2025 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Felix Moessbauer X-Patchwork-Id: 4061 Return-Path: Received: from shymkent.ilbers.de ([unix socket]) by shymkent (Cyrus 2.5.10-Debian-2.5.10-3+deb9u2) with LMTPA; Thu, 20 Feb 2025 11:01:47 +0100 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 51KA1jub007836 (version=TLSv1.2 cipher=ECDHE-RSA-AES256-GCM-SHA384 bits=256 verify=NOT) for ; Thu, 20 Feb 2025 11:01:46 +0100 Received: by mail-qt1-f183.google.com with SMTP id d75a77b69052e-471ec7f1969sf33164991cf.1 for ; Thu, 20 Feb 2025 02:01:46 -0800 (PST) ARC-Seal: i=2; a=rsa-sha256; t=1740045700; cv=pass; d=google.com; s=arc-20240605; b=J7QAFgHFgShTMc3Dn6YVvOfW9wyR4tsIlo95TVxz4vjVKyDoWK8DXCPsihbIEnmpls 1th+PQ0vPk8yPq1WLEkVItXG0ptpnb98ZpXQjQO7Yu/yahcMNJcj8z2fCgKJ1bu2rJW+ YvDmJO0jucZt/zaqB8hrK8dWvTR1FsnGfuBNMLLyekeLOQU6GARRepi7ivUmlyCVNbql 2I9PchNrNpWaxi04jH/txhZ1xyQhxAkT2N/DqSCh5W23LhGprp4yLzDMaVEz3Zt6M3D3 P7az6sAe5Gkh4tELf1ZrNFjdQuanFxvZfXdN2I8ybNnl1CXOXm2fppWjdzdU3EiRQxH1 E9Ug== ARC-Message-Signature: i=2; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605; h=list-unsubscribe:list-subscribe:list-archive:list-help:list-post :list-id:mailing-list:precedence:reply-to:feedback-id:mime-version :references:in-reply-to:message-id:date:subject:cc:to:from :dkim-signature; bh=A4SjUvSwYIWJF7VSO0rxGdE2d/HGoOFdtULX6dZXP+E=; fh=vt+l1DCykhi82cB+d/csbTGkR9FfsIj7JspFnJxfL3I=; b=IgUrotUSzKEB9oQdQmhmejeOOII8sj9WZxwh3g2BXXRNxrDFvLx09OTNJEckq1csYy uYGymlH6IYbOgxVl50KkHloN2C7R6R38D4cbqVrTuHgTBz99gCM93rFYLGbDhT8sJiPF TFdW+f4opH+f9kmxf6MfRC8vQlqGYUeUtiArazZ0Y7dWuCDMRKWHuSz5UBfbi0DymzL6 E54FoCSVehI1UyeBXaJxzNfXx4sQb3qHm0fKgWfPqv09080WBgdDOhfvzMBp0WU54b97 lTFXxeRKkbH7EylvU7LDT0rVNXOwBMNA8ycgx/6sjqOEJq7DbtwZOEj6MelVZUAanaGK +WYQ==; darn=isar-build.org ARC-Authentication-Results: i=2; gmr-mx.google.com; dkim=pass header.i=@siemens.com header.s=fm1 header.b=D3hrSENO; spf=pass (google.com: domain of fm-1321639-202502201001350d037229d875753b65-rpwjdo@rts-flowmailer.siemens.com designates 185.136.65.227 as permitted sender) smtp.mailfrom=fm-1321639-202502201001350d037229d875753b65-RpWjDO@rts-flowmailer.siemens.com; dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=siemens.com DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=googlegroups.com; s=20230601; t=1740045700; x=1740650500; darn=isar-build.org; h=list-unsubscribe:list-subscribe:list-archive:list-help:list-post :list-id:mailing-list:precedence:reply-to :x-original-authentication-results:x-original-sender:feedback-id :mime-version:references:in-reply-to:message-id:date:subject:cc:to :from:from:to:cc:subject:date:message-id:reply-to; bh=A4SjUvSwYIWJF7VSO0rxGdE2d/HGoOFdtULX6dZXP+E=; b=uCkWoGlxxUNkgEjwb4vIX00dHDJ7T/Jg30u2LN/ry11yvV3VIG2P1kbWf2ReAjOUZc dkKPJ2S6evl7AjfLqePBQYL0PSivDGN8tOUMarereJ5Sca6MMamrGoKjRb7YGRYxDQra X7kzWinDKDevFkBGanwG6lklTJW+G99Y2pXhmtVle2olvMbVoF+Uvr/oYg3s1GYd1ZeF kB9zMxUGBElbeHQQpgkGW1XYm2WApdOAnpK8IYvIjrB+6gzuFo+EaM+QwgG/iU7L2wBg sz8oKW0lR0ZJ8ORNoRGJuF9e6z76vkLf2m992YNQa1ImFnRcmJ+p4CrUgIwDYxW0g9RF h8+Q== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1740045700; x=1740650500; 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-authentication-results:x-original-sender:feedback-id :mime-version:references:in-reply-to:message-id:date:subject:cc:to :from:x-beenthere:x-gm-message-state:from:to:cc:subject:date :message-id:reply-to; bh=A4SjUvSwYIWJF7VSO0rxGdE2d/HGoOFdtULX6dZXP+E=; b=C8F/4Al6p5xI+XKCSRxh8inmdGFkC6EshZxMNN+9lDmP09CfEPdNIGsFwFlTkVkzsZ aH3RDe+KASjsnwc4U8m7leq4gwD2kFEgciC4mHuYUs/WWF4k07e/dFTYjw5zY0/x3Alo YukBJpyaHEZ7UwkpjCkMnznc0jV+Xquqy3ScCYJR4/kOVUHTzRNWifAMOXaonTEDKpe3 3eyxFGp21bNzCOD8SMDeV/QH3DNNnM87TIlIxAP+GCPVPT1uvDCH5mdjd/GrwX2uHF7R Rjn40fbyHXJcM5uXg0X0sp/RzEqy07bY3RfAM0zrSXkZfKvGwtQoBYYcWt4VrzIfNh4r YO/w== X-Forwarded-Encrypted: i=2; AJvYcCWk3yFloYTh8UVtELW3KDMtwyecbtKU3Nz+AogZW+JMS8GGGRSAQ7NGP07SzUPy9HojwSpaeik=@isar-build.org X-Gm-Message-State: AOJu0Yzz/GzdoG1v8cro2V2Hg3V9638EKyfM5xNnw4RR0TCX4XOtg5o3 R7guDxyKAdU5cKDsati0z21+vIOuD6+TxC0s6Pmzv6BhCXNVvZA7 X-Google-Smtp-Source: AGHT+IEWFDMzzZZSs5cKXvPNX85nk7Pm4lvb+1+UdE1LuTmKmQqWutD4BDCrJcZlfTiI9yUFOGJrpg== X-Received: by 2002:a05:622a:1820:b0:471:89c1:618a with SMTP id d75a77b69052e-472150b1fc7mr29377801cf.15.1740045699672; Thu, 20 Feb 2025 02:01:39 -0800 (PST) X-BeenThere: isar-users@googlegroups.com; h=Adn5yVEDcfylsRTqExh+cZ7Ngv6+ykLsj57S/MMR4/uozm2V1Q== Received: by 2002:a05:622a:103:b0:471:b736:6cc1 with SMTP id d75a77b69052e-47214fa89cbls12517051cf.1.-pod-prod-00-us; Thu, 20 Feb 2025 02:01:38 -0800 (PST) X-Received: by 2002:a05:620a:a48e:b0:7b6:67e1:b4ff with SMTP id af79cd13be357-7c0c2294546mr244369085a.28.1740045698741; Thu, 20 Feb 2025 02:01:38 -0800 (PST) ARC-Seal: i=1; a=rsa-sha256; t=1740045698; cv=none; d=google.com; s=arc-20240605; b=hjq2mohm/tF+hnmNSWh/+2bYrFvB/KmTU5F5Co7AY3nSzzKAvg4hR2UCRdqMe005L7 plBuHmHviob2LrvH489NsKOZ0QfHA/MPRyNUVYOTBahu9P8NTxGg6h42qvgemYJ05Ngn Iq/dizcedMdFaNNrAoLJM7ia2SD9E5LRc5W0hl0Jz+FdMRaxj++CH1j3BAajsFpI+kfN m6cI7F39uuWWOMuZrIFNi84+0zGFT9RyvdQCAkmA2dG97GBx7aaHNM+v2HRh35FpOnSf SgvE/C7DxmrlmM+vxju1KXlNDqatb1sYwG2H/3+T2zWBEqWSkyXVHbRx54xctrJCsBls G8Xg== ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605; h=feedback-id:content-transfer-encoding:mime-version:references :in-reply-to:message-id:date:subject:cc:to:from:dkim-signature; bh=wD/nct+CztRmX2nWPIn65kP0/5wy1GgPD3cmuGNoC8E=; fh=PpyLtIeScdGtM1qIVvjnfD1pEoqYOXRK4rwVDEPyj3g=; b=XFFgKEJDXjz9S4rIPGp+PT2QspxYLqaLXkmKAW4oHUkhgSFA9syl2H/3My8BaUv0KW 7mfhbsVYmkFJc1O+Fef5udK+HqUAdN8bgXViaXdaKlSKrawCIO9tN6rlF4f/10vvN0wg 9NT5/SLvgxDCwAseVFbgTgECJ514qQh6/AjcA1uQ52vOWLvwjQf+aTD0+h9ocoVcfQvz I3OcswBm/R2pHe0rkJdxW+xXY6rxtJOxaKwMhfVQfLVfK7fZoCo5/I351KqdDQihxnLh A4AUdZT2CXXPLKJLq2nFpo/IKlaGxta0WRq6xdip/360NTzUoNKNyz1BL9yrOkhJo+GS 4r/A==; dara=google.com ARC-Authentication-Results: i=1; gmr-mx.google.com; dkim=pass header.i=@siemens.com header.s=fm1 header.b=D3hrSENO; spf=pass (google.com: domain of fm-1321639-202502201001350d037229d875753b65-rpwjdo@rts-flowmailer.siemens.com designates 185.136.65.227 as permitted sender) smtp.mailfrom=fm-1321639-202502201001350d037229d875753b65-RpWjDO@rts-flowmailer.siemens.com; dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=siemens.com Received: from mta-65-227.siemens.flowmailer.net (mta-65-227.siemens.flowmailer.net. [185.136.65.227]) by gmr-mx.google.com with ESMTPS id af79cd13be357-7c0ae7323d3si23629585a.7.2025.02.20.02.01.38 for (version=TLS1_2 cipher=ECDHE-ECDSA-AES128-GCM-SHA256 bits=128/128); Thu, 20 Feb 2025 02:01:38 -0800 (PST) Received-SPF: pass (google.com: domain of fm-1321639-202502201001350d037229d875753b65-rpwjdo@rts-flowmailer.siemens.com designates 185.136.65.227 as permitted sender) client-ip=185.136.65.227; Received: by mta-65-227.siemens.flowmailer.net with ESMTPSA id 202502201001350d037229d875753b65 for ; Thu, 20 Feb 2025 11:01:35 +0100 X-Patchwork-Original-From: "'Felix Moessbauer' via isar-users" From: Felix Moessbauer To: isar-users@googlegroups.com Cc: jan.kiszka@siemens.com, gernot.hillier@siemens.com, Christoph Steiger Subject: [RFC PATCH 1/1] meta: add CycloneDX/SPDX SBOM generation Date: Thu, 20 Feb 2025 10:59:44 +0100 Message-ID: <20250220095944.114203-2-felix.moessbauer@siemens.com> In-Reply-To: <20250220095944.114203-1-felix.moessbauer@siemens.com> References: <20250220095944.114203-1-felix.moessbauer@siemens.com> MIME-Version: 1.0 X-Flowmailer-Platform: Siemens Feedback-ID: 519:519-1321639:519-21489:flowmailer X-Original-Sender: felix.moessbauer@siemens.com X-Original-Authentication-Results: gmr-mx.google.com; dkim=pass header.i=@siemens.com header.s=fm1 header.b=D3hrSENO; spf=pass (google.com: domain of fm-1321639-202502201001350d037229d875753b65-rpwjdo@rts-flowmailer.siemens.com designates 185.136.65.227 as permitted sender) smtp.mailfrom=fm-1321639-202502201001350d037229d875753b65-RpWjDO@rts-flowmailer.siemens.com; dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=siemens.com X-Original-From: Felix Moessbauer Reply-To: Felix Moessbauer 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,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-getmail-retrieved-from-mailbox: =?utf-8?q?INBOX?= From: Christoph Steiger 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 --- 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^[^<]*)(\\<(?P.*)\\>)?") + 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^[^<]*)(\\<(?P.*)\\>)?") + 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]