From 7956dff4782279d5fe665774d9a9f0cca441b3cd Mon Sep 17 00:00:00 2001 From: Ralf Hubert Date: Tue, 26 May 2026 11:16:02 +0200 Subject: [PATCH] WIP: sbom --- classes/install.yaml | 2 +- classes/sbom-deploy.yaml | 118 ++++++++++++ classes/sbom.yaml | 155 +++++++++++++++ config.yaml | 1 + doc/usage.rst | 118 ++++++++++++ plugins/sbom.py | 393 +++++++++++++++++++++++++++++++++++++++ recipes/libs/libc.yaml | 2 +- 7 files changed, 787 insertions(+), 2 deletions(-) create mode 100644 classes/sbom-deploy.yaml create mode 100644 classes/sbom.yaml create mode 100644 plugins/sbom.py diff --git a/classes/install.yaml b/classes/install.yaml index ba91a865..45a43cc3 100644 --- a/classes/install.yaml +++ b/classes/install.yaml @@ -1,4 +1,4 @@ -inherit: [strip, "basement::bits::libs"] +inherit: [strip, "basement::bits::libs", sbom] # The install class provides the common methods to # * copy the relevant parts for the different packages, diff --git a/classes/sbom-deploy.yaml b/classes/sbom-deploy.yaml new file mode 100644 index 00000000..80d3624a --- /dev/null +++ b/classes/sbom-deploy.yaml @@ -0,0 +1,118 @@ +inherit: [sbom] + +buildSetup: &sbom_deploy_setup | + _sbomDeployAddSingle () { + local SOURCE_PATH=$1 + local FILE=$2 + local added=$3 + if [ -f $SOURCE_PATH/$file ]; then + if [[ $added == false ]]; then + # if the file has entries already add we need to add the `,` + # separator first + if [[ $(tail -n 1 .bob/_sbom_deploy.json) == *"}" ]]; then + echo -n "," >> .bob/_sbom_deploy.json + fi + echo "," >> .bob/_sbom_deploy.json + echo " \"files\": [" >> .bob/_sbom_deploy.json + added=true + else + echo "," >> .bob/_sbom_deploy.json + fi + echo -n " \"$file\"" >> .bob/_sbom_deploy.json + fi + echo "$added" + } + + # Add a component to the list of deployed components + # Usage: + # sbomDeploy [-f FILES_TXT] [-n FILE_NAME] sourcePath + # + # arguments: + # sourcePath - workspace of the dependency to add files from + # + # options: + # -f FILES_TXT - use files from FILES_TXT + # -n FILE_NAME - add FILE_NAME + sbomDeploy() { + local FILES_FILES=() + local FILES=() + # parse arguments + OPTIND=1 + local opt + while getopts "f:n:" opt ; do + case "$opt" in + f) + FILES_FILES+=( "$OPTARG" ) + ;; + n) + FILES+=( "$OPTARG" ) + ;; + \?) + echo "sbomDeploy: Invalid option: -$OPTARG" >&2 + exit 1 + ;; + esac + done + shift $(( OPTIND -1 )) + + local SOURCE_PATH=$1 + + if [[ ! -s $SOURCE_PATH/.bob/sbom-manifest.json ]]; then + echo "Dependency ${SOURCE_PATH} doesn't contain a sbom-manifest. \"sbom\" class missing?" 1>&2 + exit 1 + # TODO: should we fail here? + fi + + mkdir -p .bob + if [ ! -e .bob/_sbom_deploy.json ]; then + echo " {" > .bob/_sbom_deploy.json + else + echo ", {" >> .bob/_sbom_deploy.json + fi + + local added=false + local MANIFEST_ID=$(grep "bob:sbom-manifest-id" $1/.bob/sbom-manifest.json) + MANIFEST_ID=${MANIFEST_ID##*:} + MANIFEST_ID=${MANIFEST_ID/,/} + + echo -n " \"bob:sbom-manifest-id\": ${MANIFEST_ID}" >> .bob/_sbom_deploy.json + + for files_file in ${FILES_FILES[@]}; do + while read -r file; do + added=$(_sbomDeployAddSingle $SOURCE_PATH $file $added) + done < $files_file + done + + for file in ${FILES[@]}; do + added=$(_sbomDeployAddSingle $SOURCE_PATH $file $added) + done + + if [[ $added == true ]]; then + echo "" >> .bob/_sbom_deploy.json + echo " ]" >> .bob/_sbom_deploy.json + fi + echo -n " }" >> .bob/_sbom_deploy.json + } + +packageSetup: *sbom_deploy_setup + +packageVars: [SBOM_ENABLED] +packageFinalize: | + if [[ ${SBOM_ENABLED} ]]; then + mkdir -p .bob + + echo "[" > .bob/sbom_deploy.json + if [[ -f .bob/_sbom_deploy.json ]]; then + cat .bob/_sbom_deploy.json >> .bob/sbom_deploy.json + rm .bob/_sbom_deploy.json + elif [[ -f $1/.bob/_sbom_deploy.json ]]; then + cat $1/.bob/_sbom_deploy.json >> .bob/sbom_deploy.json + fi + echo "" >> .bob/sbom_deploy.json + echo "]" >> .bob/sbom_deploy.json + fi + +packageAuditFiles: + sbom_deploy: + filename: ".bob/sbom_deploy.json" + if: "${SBOM_ENABLED:-1}" diff --git a/classes/sbom.yaml b/classes/sbom.yaml new file mode 100644 index 00000000..e4f58510 --- /dev/null +++ b/classes/sbom.yaml @@ -0,0 +1,155 @@ +Config: + SBOM_FILE_TYPES: + type: str + help: | + Override the autodetected file type of the deployed files. + In Cyclonedx SBOM format varios type classes are supported - see + https://cyclonedx.org/docs/1.7/json/#metadata_tools_oneOf_i0_components_items_type + for a complete list. This class tries to autodetect 'application', 'library' + and 'file' - types. But these detection can be wrong or incomplete. + By specifying this variable as a semi-colon separated list of colon separated + file:type pairs the recipe can specify different types here, e.g. for + for linux recipes it could set: + + SBOM_FILE_TYPES="bzImage:operating-system;.ko:device-driver" + default: "" + SBOM_ENABLED: + type: bool + default: True + help: If set (default) required files form SBOM generation are produced. + +packageVars: [PKG_VERSION, PKG_LICENSE, SBOM_FILE_TYPES] +packageSetup: | + # Create file manifest + sbomCreateManifest() + { + local manifest_file=".bob/sbom-manifest.json" + local files=() + declare -A file_types + + mkdir -p .bob + + # split the SBOM_FILE_TYPES=":;<:type>" + IFS=';' read -ra items <<< "${SBOM_FILE_TYPES:-}" + for item in "${items[@]}"; do + [[ -z ${item} ]] && continue; + local file=${item%%:*} + local type=${item#*:} + file_types["$file"]=$type + done + + while IFS= read -r -d '' file; do + files+=("${file#./}") + done < <(find . -type f -not -path "./.bob/*" -print0 2>/dev/null || true) + + # Create JSON manifest with file info and hashes + { + echo "{" + echo ' "file_components": [' + first=true + for file in "${files[@]}"; do + if [[ $first == true ]]; then + first=false + echo ' {' + else + echo ", {" + fi + + echo " \"hashes\": [ " + echo " {\"alg\": \"SHA-256\", \"content\": \"$(sha256sum $file | cut -d' ' -f1)\" }," + echo " {\"alg\": \"SHA-384\", \"content\": \"$(sha384sum $file | cut -d' ' -f1)\" }," + echo " {\"alg\": \"SHA-512\", \"content\": \"$(sha512sum $file | cut -d' ' -f1)\" }" + echo " ]," + echo " \"name\": \"$file\"," + + # determine file type. See https://cyclonedx.org/docs/1.7/json/#metadata_tools_oneOf_i0_components_items_type + # for a list of valid types. For now we only support 'application', 'library' and 'file'. + # The recipe can set `SBOM_FILE_TYPES` to override auto detection. + + local file_type= + for f in ${!file_types[@]}; do + if [[ "$file" == *"$f"* ]]; then + file_type=${file_types[$f]} + fi + done + if [[ -z "$file_type" ]]; then + local type="$(file -b "$file")" + if [[ type == *executable* ]]; then + file_type="application" + elif [[ type == *shared* ]]; then + file_type="library" + else + file_type="file" + fi + fi + echo -n " \"type\": \"${file_type}\"" + + if [ -n "${PKG_VERSION:-}" ]; then + echo "," + echo -n " \"version\": \"${PKG_VERSION}\"" + fi + + if [[ -e .bsi-properties/$file ]]; then + echo "," + cat .bsi-properties/$file + else + # add properties as required by BSI TR-03183-2 using the cyclonedx bsi::component + # namespace (https://github.com/BSI-Bund/tr-03183-cyclonedx-property-taxonomy) + + local is_executable="false" + [[ -x "$file" ]] && is_executable="true" + + # Determine BSI properties + local is_archive="false" + local is_structured="false" + + # Check if file is an archive or structured (contains metadata for decomposition) + case "${file##*.}" in + tar|zip|7z|jar|war|ear|deb|rpm|a|lib|iso) + is_archive="true" + is_structured="true" + ;; + gz|bz2|xz) + # Check for compressed tar archives + if [[ "$file" == *.tar.* ]]; then + is_archive="true" + fi + is_structured="true" + ;; + esac + + # Section 3.2.1: Only executable and archive files MUST be listed. + if [[ $is_archive == true ]] || [[ $is_executable == true ]]; then + echo "," + echo " \"properties\": [" + echo " {\"name\": \"bsi:component:archive\", \"value\": \"$is_archive\" }," + if [ -n "${PKG_LICENSE:-}" ]; then + echo " {\"name\": \"bsi:component:effectiveLicence\", \"value\": \"${PKG_LICENSE}\" }," + fi + echo " {\"name\": \"bsi:component:executable\", \"value\": \"$is_executable\" }," + local rel_path="$(basename $file)" + echo " {\"name\": \"bsi:component:filename\", \"value\": \"$rel_path\" }," + echo " {\"name\": \"bsi:component:structured\", \"value\": \"$is_structured\" }" + echo -n " ]" + fi + fi + echo "" + echo -n " }" + done + echo "" + echo ' ],' + } > "$manifest_file" + echo " \"bob:sbom-manifest-id\": \"$(sha256sum $manifest_file | cut -d' ' -f1)\"" >> $manifest_file + echo "}" >> $manifest_file + } + +packageVars: [SBOM_ENABLED] +packageFinalize: | + if [[ ${SBOM_ENABLED} ]]; then + sbomCreateManifest + fi + +packageAuditFiles: + sbom_manifest: + filename: ".bob/sbom-manifest.json" + if: "${SBOM_ENABLED}" diff --git a/config.yaml b/config.yaml index 65b0f37e..930f6361 100644 --- a/config.yaml +++ b/config.yaml @@ -6,3 +6,4 @@ plugins: - vsenv - msbuild - config + - sbom diff --git a/doc/usage.rst b/doc/usage.rst index 9a799fb1..ffd10247 100644 --- a/doc/usage.rst +++ b/doc/usage.rst @@ -523,3 +523,121 @@ The following tools can be used by naming them in They are provided by default by the ``basement::rootrecipe`` class. Usually, these tools are picked up automatically by the respective classes and it is not necessary to name them explicitly. + +SBOM +---- + +The Software Bill of Materials (SBOM) documents which software parts are +included in a (software) product. Having a SBOM is one of the key requirements +of the European CRA. + +Support for SBOM generation is divided into two main parts: + +sbom class +~~~~~~~~~~ + +To gather required information at build-time, e.g. file-types, and checksums. Each +package should inherit the `sbom` class if it provides target deployments in the +end. This class generates additional information and stores them in the audit-trail +to have them available later when the final sbom is generated by the [SBom +Generator](SBOM Generator plugin). + +For (cycloneDX) SBoms each entry has a required [type](https://cyclonedx.org/docs/1.7/json/#metadata_tools_oneOf_i0_components_items_type) +property. This is guessed by the class, but can be overwritten by setting the +`SBOM_FILE_TYPES` variable, e.g. + +``` +privateEnvironment: + SBOM_FILE_TYPES="foo.so:library,bar:application" +``` + +Note that wildcard matching is used, so `.ko:device-driver` marks all kernel +modules as device driver. + + +sbom-deploy class +~~~~~~~~~~~~~~~~~ + +The `sbom-deploy` class is intended to be used when building packages selecting a +subset of the dependencies. A typical user of this class is a root filesystem, +picking only files provided by the `-tgt` packages while omitting `.debug` +folders. For packages like this the `sbomDeploy` helper can be used to generate +a list of all included parts. This list will also be added to the audit trail. + +The German Federal Office of information Security (BSI) defined additional +requirements for SBOMs in [TR-03183-2](https://www.bsi.bund.de/SharedDocs/Downloads/EN/BSI/Publications/TechGuidelines/TR03183/BSI-TR-03183-2_v2_1_0.pdf). + +The `sbom` class tries to automatically guess the required properties like +`bsi:component:archive`. As this could go wrong it is possible to provide +pre-configured property files for each file component. Such a property file must +resist in the packageWorkspace `.bsi-properties` and has to have the same name as +the file itself. E.g. if one want to provide the properties for `usr/bin/foo` a +file `.bsi-properties/usr/bin/foo` has to exist with the content like: + +``` + "properties": [ + { + "name": "bsi:component:archive", + "value": "False" + }, + { + "name": "bsi:component:effectiveLicence", + "value": "GPL-2.0-or-later" + }, + { + "name": "bsi:component:executable", + "value": "True" + }, + { + "name": "bsi:component:filename", + "value": "foo" + }, + { + "name": "bsi:component:structured", + "value": "False" + } + ] +``` + +Note: These properties will be added to the SBOM without further validation. + +SBOM Generator plugin +~~~~~~~~~~~~~~~~~~~~~ + +As a final step one need to create the sbom using the sbom-generator plugin. +This plugin makes use of the information from the recipes and optionally the +additional infomation from the audit (generated by the classes above) to build +the SBOM. + +Typically there are different classes of sboms. The generator supports the +generation of `Design SBOM` and `Build SBOM`. + +Design SBOM +~~~~~~~~~~~ + +A Design SBOM is `created based on the planned set of included components`. (see +TR-03183-2 Sec. 8.4.1). + +For Bob this is "based on the recipes only". No need to build any package or to +use on of the sbom classes. For this kind of SBOMs the generator is invoked by: + +``` +bob project --download=yes -n sbom -o bom.json +``` + +It will produce a SBOM containing _all_ referenced packages, their version, licenses, +source-code locations and dependencies. + +Build SBOM +~~~~~~~~~~ + +A Build SBOM `is created as part of the build process based on e.g. source files, +dependency information, already created components, volatile build process +data` (TR-03183-2 Sec. 8.4.3). + +For this SBOM the `sbom` (and `sbom-deploy`) classes are required to gather all +required properties. The generator need to be invoked like: + +``` +bob project --download=yes -n sbom -o bom.json --file-components "sbom_manifest" +``` diff --git a/plugins/sbom.py b/plugins/sbom.py new file mode 100644 index 00000000..0f9205e7 --- /dev/null +++ b/plugins/sbom.py @@ -0,0 +1,393 @@ +# Bob build tool +# Copyright (C) 2026 Bob Contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""SBOM (Software Bill of Materials) generation plugin.""" + +import re +from bob import BOB_VERSION +from bob.audit import Audit, Artifact +from bob.errors import BobError, BuildError, ParseError +from bob.input import Step, PluginSetting +from bob.intermediate import StepIR +from bob.scm import UrlScm + +from dataclasses import dataclass +from datetime import datetime, timezone + +import argparse +import gzip +import io +import json +import os + +@dataclass +class SBOMGeneratorConfig: + """Configuration of the Generator.""" + pretty: bool + fileComponents: list[str] + +@dataclass +class ArtifactInfo: + """Common artifact properties needed by all SBOM Generators.""" + name: str + package: str + build_date: str + cpe: str + files: dict + scms: dict + variant_id: str + description: str + license: str + vendor: str + version: str + bom_ref : str + + def __init__(self): + pass + +class SBOMGeneratorBase: + """Base class for SBOM format generators.""" + + def __init__(self, step: Step, config: SBOMGeneratorConfig): + """Initialize SBOM generator with audit trail. """ + self._config = config + self._step = step + self._graph = {} + + auditFile = os.path.join(os.path.dirname(step.getWorkspacePath()), "audit.json.gz") + try: + self._audit = Audit.fromFile(auditFile) + except Exception as e: + raise BobError(f"Failed to load audit file: {e}") + + # create a mapping of variantIds to artifactIds. From the (dependencies) of our step we + # only get variantIds but we have to look them up in the audit using artifactIds. + self._variant_to_artifactId = self._get_variant_to_artifact_id_map() + + def _get_variant_to_artifact_id_map(self): + artifacts = {} + queue = [None] + visited = set() + + while queue: + aid = queue.pop(0) + if aid in visited: + continue + visited.add(aid) + + art = self._audit.getArtifact(aid) + + artifacts[bytes.fromhex(art.getVariantId())] = aid + for ref in art.getReferences(): + if ref not in visited: + queue.append(ref) + + return artifacts + + @staticmethod + def _shorten_package_name(name: str): + if not name: + return 'unknown' + if '::' in name: + name = name.split('::')[-1] + if '/' in name: + name = name.split('/')[-1] + return name + + @staticmethod + def append_if_set(info : ArtifactInfo, name, info_name=None): + val = getattr(info, info_name if info_name else name) + if val is not None: + return { name: val} + return {} + + def generate(self): + """Generate SBOM in the specific format. + + Returns: + dict: SBOM data structure (format-specific) + """ + raise NotImplementedError("Subclasses must implement generate()") + + def _generateSbomInfos(self, step, rootBomRef=None, processed=[], deployed={}): + vid = step.getVariantId() + + if vid in processed: + return None + processed.append(vid) + + deps = [] + + if not vid in self._variant_to_artifactId: + raise BuildError(f"Can not get audit information for {step.getPackage().getName()}: " + f"Expected Variant ID {vid.hex()} not found in audit. " + "Different build or non matching arguments (--sandbox,..)?") + artifact = self._audit.getArtifact(self._variant_to_artifactId[vid]) + + audit_files = artifact.getFiles() + next_deployed = deployed.copy() + if 'sbom_deploy' in audit_files: + try: + sbom_deploy = json.loads(audit_files.get('sbom_deploy')) + except json.JSONDecodeError as e: + raise BuildError(f"Unable to load 'sbom_deploy' from audit of " + f"{step.getPackage().getName()}: {e}" + f"{audit_files.get('sbom_deploy')}") + # sbom_deploy: list of dictionaries with "bob:sbom-manifest-id":", "files": [..] + # only the via 'bob:sbom-manifest-id' referenced components are of interest for a deployed-sbom, + # any other component is assumed to be not deployed + for e in sbom_deploy: + next_deployed[bytes.fromhex(e['bob:sbom-manifest-id'])] = e['files'] if 'files' in e else [] + + data = None + if step.isPackageStep(): + data = {'info': self._get_artifact_info(artifact, step), + 'deployed' : deployed} + if rootBomRef is not None: + self._graph[rootBomRef].append(data['info'].bom_ref) + self._graph[data['info'].bom_ref] = [] + rootBomRef = data['info'].bom_ref + + for dep in step.getArguments(): + if dep.isValid(): + depVariant = dep.getVariantId() + deps.append(depVariant) + yield from self._generateSbomInfos(dep, rootBomRef, processed, next_deployed) + + yield data + + + def _get_artifact_info(self, artifact : Artifact, step : StepIR) -> ArtifactInfo: + """Extract common artifact information from audit and step information.""" + info = ArtifactInfo() + + meta_data = artifact.getMetaData() + meta_env = artifact.getMetaEnv() + build_info = artifact.getBuildInfo() + name = SBOMGeneratorBase._shorten_package_name(meta_data.get('package')) + + cpe_type = meta_env.get('PKG_CPE_TYPE','a') + cpe_vendor = meta_env.get('PKG_CPE_VENDOR','*') + cpe_product = meta_env.get('PKG_CPE_PRODUCT', + SBOMGeneratorBase._shorten_package_name(meta_data.get('recipe'))) + cpe_version = meta_env.get('PKG_VERSION','*') + cpe_update = meta_env.get('PKG_CPE_UPDATE','*') + cpe_edition = meta_env.get('PKG_CPE_EDITION','*') + cpe_lang = meta_env.get('PKG_CPE_LANG','*') + cpe_sw_edition = meta_env.get('PKG_CPE_SW_EDITION','*') + cpe_target_sw = meta_env.get('PKG_CPE_TARGET_SW','*') + cpe_target_hw = meta_env.get('PKG_CPE_TARGET_HW','*') + cpe_other = meta_env.get('PKG_CPE_OTHER','*') + + cpe = f"cpe:2.3:{cpe_type}:{cpe_vendor}:{cpe_product}:{cpe_version}:{cpe_update}:{cpe_edition}:{cpe_lang}:{cpe_sw_edition}:{cpe_target_sw}:{cpe_target_hw}:{cpe_other}" + + info.build_date = build_info.get('date', '') + info.cpe = cpe + info.files = artifact.getFiles() + info.name = name + info.package = meta_data.get('package') + info.scms = step.getPackage().getCheckoutStep().getScmList() + + info.description = meta_env.get('PKG_DESCRIPTION') + info.license = meta_env.get('PKG_LICENSE') + info.variant_id = artifact.getVariantId() + info.vendor = meta_env.get('PKG_VENDOR') + info.version = meta_env.get('PKG_VERSION') + + info.bom_ref = f"pkg:{info.name}:{info.variant_id}" + (f"@{info.version}" \ + if info.version is not None else "") + return info + +class CycloneDXGenerator(SBOMGeneratorBase): + """CycloneDX JSON format SBOM generator.""" + + def generate(self): + """Generate SBOM in CycloneDX JSON format.""" + + processed = [] + components = [] + metadata = {} + file_deps = [] + for data in self._generateSbomInfos(self._step, None, processed): + if data is None: + continue + artifact = data['info'] + deployed = data['deployed'] + if bytes.fromhex(artifact.variant_id) == self._step.getVariantId(): + metadata = self._generate_metadata(artifact) + continue # do not add the root element as component as this is in `metadata` + component, component_file_deps = self._generate_component(artifact, deployed) + components.extend(component) + if len(component_file_deps) > 0: + file_deps.append(component_file_deps) + + deps = self._generate_dependencies(file_deps) + sbom = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": metadata, + "components": components, + "dependencies": deps + } + + return sbom + + def __add_external_references(self, data, info): + data['externalReferences'] = [ { + "type": "build-system", + "url": "https://bobbuildtool.dev/" + }] + + for scm in info.scms: + data['externalReferences'].append({ + "type": "vcs", + "url": scm.getProperties(False).get('url') + }) + + def _generate_metadata(self, info: ArtifactInfo): + """Generate CycloneDX metadata.""" + now = datetime.now(timezone.utc) + timestamp = now.replace( + microsecond=0).isoformat().replace('+00:00', 'Z') + metadata = { + "timestamp": timestamp, + "tools": [ + { + "vendor": "BobBuildTool", + "name": "bob sbom", + "version": BOB_VERSION, + } + ], + "component": { + "type": "application", + "bom-ref": info.bom_ref, + "name": info.name, + } | SBOMGeneratorBase.append_if_set(info, 'version') \ + | SBOMGeneratorBase.append_if_set(info, 'description') \ + | SBOMGeneratorBase.append_if_set(info, 'cpe') \ + | CycloneDXGenerator._generate_licenses (info) + } + + return metadata + + def _generate_component(self, info: ArtifactInfo, deployed): + """Generate CycloneDX component""" + file_deps = {} + + components = [] + + audit_files = info.files + component_bom_ref = info.bom_ref + component_file_deps = [] + + for f in self._config.fileComponents: + if f in audit_files: + try: + additional_sbom = json.loads(audit_files.get(f)) + manifest_id = additional_sbom['bob:sbom-manifest-id'] + filter = None + + # if there is a deployment filter active but the actual package is not part of than it's not deployed + if len(deployed) > 0: + if bytes.fromhex(manifest_id) not in deployed: + continue + filter = deployed[bytes.fromhex(manifest_id)] + + for c in additional_sbom["file_components"]: + # skip files if they are not in the deployment + if filter is not None and not c['name'] in filter: + continue + file_ref = f"file:{info.name}:{info.variant_id}:{c.get('name')}" + c.update({"bom-ref" : file_ref}) + components.append(c) + component_file_deps.append(file_ref) + except json.JSONDecodeError as e: + raise BuildError(f"Unable to load {f} from audit of {info.name}: {e}\n{audit_files.get(f)}") + + if len(component_file_deps) > 0: + file_deps[component_bom_ref] = component_file_deps + + component = { + "type": "application", + "bom-ref": component_bom_ref, + "name": info.name, + "cpe": info.cpe, + "externalReferences": [] + } | SBOMGeneratorBase.append_if_set(info, 'version') \ + | CycloneDXGenerator._generate_licenses(info) + + self.__add_external_references(component, info) + + components.append(component) + + return components, file_deps + + def _generate_dependencies(self, file_deps): + """ Build the the dependency graph for pkgs and file dependencies. """ + dependencies = {} + + for root,deps in self._graph.items(): + dependencies[root] = deps + + for file_dep in file_deps: + for root,deps in file_dep.items(): + if dependencies[root] is not None: + dependencies[root].extend(deps) + else: + dependencies[root] = deps + + return [{'ref' : r, 'dependsOn' : d } for r,d in dependencies.items() if len(d) > 0 ] + + @staticmethod + def _generate_licenses(artifact_info: ArtifactInfo): + """Generate CycloneDX license information.""" + if artifact_info.license: + if len(artifact_info.license.split()) > 1 or \ + artifact_info.license.startswith("LicenseRef"): # FIXME: Move all dependency license refs to the root dist + # so we can import them as '{'license': {'text': {'content': "..."}}} + # more than one word -> assume SPDX License Expression + lic = {"expression": artifact_info.license} + else: + + lic = {"license": { "id": artifact_info.license }} + return {"licenses": [ lic ]} + return {} + +def sbomGenerator(package, argv, extra, bob): + parser = argparse.ArgumentParser(prog="bob project sbom", description='Generate a SBOM') + parser.add_argument('--pretty', action='store_true', default=False, help="Generaty pretty printed json output") + parser.add_argument('--file-components', action='append', default=[], + help="Use file-component information from FILE_COMPONENTS of audit") + parser.add_argument("-o", "--output", default="bom.json", type=str, + help="Name of output (json) file") + + args = parser.parse_args(argv) + + config = SBOMGeneratorConfig(args.pretty, args.file_components) + + generator = CycloneDXGenerator(package.getPackageStep(), config) + sbom = generator.generate() + + dump_args = {} + if config.pretty: + dump_args['indent']=2 + dump_args['sort_keys']=True + + try: + with open(args.output, 'w') as fp: + json.dump(sbom, fp, **dump_args) + except IOError as e: + raise BobError(f"Failed to write output file: {e}") + + print(f"SBOM written to {args.output}") + return 0; + +manifest = { + 'apiVersion' : '1.2', + 'projectGenerators' : { + 'sbom' : sbomGenerator + } +} diff --git a/recipes/libs/libc.yaml b/recipes/libs/libc.yaml index 060700e7..1ca3a5c9 100644 --- a/recipes/libs/libc.yaml +++ b/recipes/libs/libc.yaml @@ -1,4 +1,4 @@ -inherit: [strip] +inherit: [strip, sbom] buildTools: [target-toolchain] buildVars: [TOOLCHAIN_SYSROOT]