diff --git a/src/mobster/__init__.py b/src/mobster/__init__.py index e69de29b..7db3833c 100644 --- a/src/mobster/__init__.py +++ b/src/mobster/__init__.py @@ -0,0 +1,12 @@ +""" +Mobster root module. +""" + +import importlib.metadata + + +def get_mobster_version() -> str: + """ + Get the current mobster version as a string using import.metadata.version + """ + return importlib.metadata.version("mobster") diff --git a/src/mobster/cmd/augment/handlers.py b/src/mobster/cmd/augment/handlers.py index 8e0dbbbe..e2f4f246 100644 --- a/src/mobster/cmd/augment/handlers.py +++ b/src/mobster/cmd/augment/handlers.py @@ -7,10 +7,12 @@ from packageurl import PackageURL +from mobster import get_mobster_version from mobster.error import SBOMError from mobster.image import Image, IndexImage from mobster.oci.artifact import SBOMFormat from mobster.release import Component +from mobster.sbom import cyclonedx logger = logging.getLogger(__name__) @@ -172,6 +174,16 @@ def _find_image_package(cls, sbom: Any, image: Image) -> SPDXPackage | None: return None + @classmethod + def _augment_creation_info(cls, creation_info: Any) -> None: + """ + Add Mobster version information to creationInfo. + """ + version = get_mobster_version() + creator = f"Tool: Mobster-{version}" + if creator not in creation_info["creators"]: + creation_info["creators"].append(creator) + @classmethod def _update_index_image_sbom( cls, component: Component, index: IndexImage, sbom: Any @@ -238,6 +250,7 @@ def update_sbom(self, component: Component, image: Image, sbom: Any) -> None: """ Update a build-time SBOM with release-time data. """ + self._augment_creation_info(sbom["creationInfo"]) if isinstance(image, IndexImage): SPDXVersion2._update_index_image_sbom(component, image, sbom) elif isinstance(image, Image): @@ -288,6 +301,7 @@ def _bump_version(self, sbom: Any) -> None: if sbom["specVersion"] not in ["1.4", "1.5", "1.6"]: raise SBOMError("Attempted to downgrade an SBOM.") + logger.debug("Bumping CycloneDX version to 1.6") sbom["$schema"] = "http://cyclonedx.org/schema/bom-1.6.schema.json" sbom["specVersion"] = "1.6" @@ -341,6 +355,26 @@ def _update_container_component( kflx_component, image, arch, cdx_component ) + def _augment_metadata_tools_components(self, metadata: Any) -> None: + """ + Add Mobster version information to metadata.tools.components + """ + if "tools" not in metadata: + metadata["tools"] = {"components": []} + + components = metadata["tools"]["components"] + if not self._has_current_mobster_version(components): + components.append(cyclonedx.get_tools_component_dict()) + + def _has_current_mobster_version(self, components: list[Any]) -> bool: + """ + Check whether a list of components contains a component with name + "Mobster" and the current Mobster version. + """ + return ("Mobster", get_mobster_version()) in [ + (c["name"], c.get("version")) for c in components + ] + def _update_metadata_component( self, kflx_component: Component, image: Image, sbom: Any ) -> None: @@ -355,6 +389,8 @@ def _update_metadata_component( metadata = {"component": component} sbom["metadata"] = metadata + self._augment_metadata_tools_components(sbom["metadata"]) + def construct_purl( image: Image, diff --git a/src/mobster/cmd/generate/modelcar.py b/src/mobster/cmd/generate/modelcar.py index 5ccc8949..fa179acc 100644 --- a/src/mobster/cmd/generate/modelcar.py +++ b/src/mobster/cmd/generate/modelcar.py @@ -89,6 +89,7 @@ async def to_cyclonedx(self, modelcar: Image, base: Image, model: Image) -> Any: # Create CycloneDX BOM and assign it the root component document = Bom() + document.metadata.tools.components.add(cyclonedx.get_tools_component()) document.metadata.component = root_component # Add the base and model components to the BOM diff --git a/src/mobster/sbom/cyclonedx.py b/src/mobster/sbom/cyclonedx.py index d11884a9..ca1ce197 100644 --- a/src/mobster/sbom/cyclonedx.py +++ b/src/mobster/sbom/cyclonedx.py @@ -1,5 +1,7 @@ """A module for CycloneDX SBOM format""" +from typing import Any + from cyclonedx.model import HashType from cyclonedx.model.bom_ref import BomRef from cyclonedx.model.component import ( @@ -7,6 +9,7 @@ ComponentType, ) +from mobster import get_mobster_version from mobster.image import Image @@ -32,3 +35,32 @@ def get_component(image: Image) -> Component: ) return package + + +def get_tools_component() -> Component: + """ + Create a metadata.tools CycloneDX component. Inserts current version of + mobster. + + Returns: + Component: A metadata.component object. + """ + return Component( + name="Mobster", type=ComponentType.APPLICATION, version=get_mobster_version() + ) + + +def get_tools_component_dict() -> dict[str, Any]: + """ + Create a metadata.tools CycloneDX component. Inserts current version of + mobster. + + Returns: + Component: A metadata.component object. + """ + component = get_tools_component() + return { + "name": component.name, + "type": component.type.value, + "version": component.version, + } diff --git a/src/mobster/sbom/spdx.py b/src/mobster/sbom/spdx.py index 2a9e6d66..7ece72b9 100644 --- a/src/mobster/sbom/spdx.py +++ b/src/mobster/sbom/spdx.py @@ -13,6 +13,7 @@ ) from spdx_tools.spdx.model.spdx_no_assertion import SpdxNoAssertion +from mobster import get_mobster_version from mobster.image import Image @@ -35,7 +36,7 @@ def get_creation_info(sbom_name: str) -> CreationInfo: creators=[ Actor(ActorType.ORGANIZATION, "Red Hat"), Actor(ActorType.TOOL, "Konflux CI"), - Actor(ActorType.TOOL, "Mobster"), + Actor(ActorType.TOOL, f"Mobster-{get_mobster_version()}"), ], created=datetime.now(timezone.utc), ) diff --git a/tests/cmd/generate/test_generate_oci_index.py b/tests/cmd/generate/test_generate_oci_index.py index 502e5b3e..acde43f5 100644 --- a/tests/cmd/generate/test_generate_oci_index.py +++ b/tests/cmd/generate/test_generate_oci_index.py @@ -8,6 +8,7 @@ from mobster.cmd.generate.oci_index import GenerateOciIndexCommand from mobster.image import Image +from tests.conftest import assert_spdx_sbom @pytest.mark.asyncio @@ -41,13 +42,7 @@ async def test_generate_oci_index_sbom() -> None: with open(args.output, encoding="utf8") as result_file: result = json.load(result_file) - # Copy dynamic values from expected output - result["creationInfo"]["created"] = expected_output["creationInfo"][ - "created" - ] - result["documentNamespace"] = expected_output["documentNamespace"] - - assert result == expected_output + assert_spdx_sbom(result, expected_output) def test_GenerateOciIndexCommand_get_index_image_relationship() -> None: diff --git a/tests/cmd/generate/test_modelcar.py b/tests/cmd/generate/test_modelcar.py index 2a44e164..ed17e977 100644 --- a/tests/cmd/generate/test_modelcar.py +++ b/tests/cmd/generate/test_modelcar.py @@ -1,12 +1,12 @@ import json import pathlib import tempfile -from typing import Any from unittest.mock import MagicMock import pytest from mobster.cmd.generate.modelcar import GenerateModelcarCommand +from tests.conftest import assert_cdx_sbom, assert_spdx_sbom @pytest.mark.asyncio @@ -64,32 +64,6 @@ async def test_generate_modelcar_sbom( result = json.load(result_file) if sbom_type == "spdx": - # Copy dynamic values from expected output - result["creationInfo"]["created"] = expected_output["creationInfo"][ - "created" - ] - result["documentNamespace"] = expected_output["documentNamespace"] + assert_spdx_sbom(result, expected_output) if sbom_type == "cyclonedx": - result["serialNumber"] = expected_output["serialNumber"] - result["metadata"]["timestamp"] = expected_output["metadata"][ - "timestamp" - ] - root_bom_ref = result["metadata"]["component"]["bom-ref"] - patch_bom_ref( - result, - root_bom_ref, - expected_output["metadata"]["component"]["bom-ref"], - ) - - assert result == expected_output - - -def patch_bom_ref(document: Any, old: str, new: str) -> Any: - document["metadata"]["component"]["bom-ref"] = new - for component in document["components"]: - if component["bom-ref"] == old: - component["bom-ref"] = new - for dependency in document["dependencies"]: - if dependency["ref"] == old: - dependency["ref"] = new - return document + assert_cdx_sbom(result, expected_output) diff --git a/tests/cmd/test_augment.py b/tests/cmd/test_augment.py index d01eb6a9..25eb5c1b 100644 --- a/tests/cmd/test_augment.py +++ b/tests/cmd/test_augment.py @@ -15,12 +15,14 @@ update_sbom, verify_sbom, ) -from mobster.cmd.augment.handlers import get_purl_digest +from mobster.cmd.augment.handlers import CycloneDXVersion1, get_purl_digest from mobster.error import SBOMError, SBOMVerificationError from mobster.image import Image, IndexImage from mobster.oci.artifact import SBOM, Provenance02 from mobster.oci.cosign import Cosign from mobster.release import Component, Snapshot +from mobster.sbom import cyclonedx +from tests.conftest import assert_spdx_sbom TESTDATA_PATH = Path(__file__).parent.parent.joinpath("data/component") @@ -158,7 +160,7 @@ async def test_augment_execute_singlearch( expected = prepare_sbom(reference).doc assert len(cmd.sboms) == 1 - assert cmd.sboms[0].doc == expected + assert_spdx_sbom(cmd.sboms[0].doc, expected) @pytest.mark.asyncio async def test_augment_execute_multiarch( @@ -206,8 +208,9 @@ async def test_augment_execute_multiarch( ] assert len(expected_sboms) == len(cmd.sboms) - for expected, actual in zip(cmd.sboms, expected_sboms, strict=False): - assert expected.doc == actual.doc + + for actual, expected in zip(cmd.sboms, expected_sboms, strict=False): + assert_spdx_sbom(actual.doc, expected.doc) @pytest.mark.asyncio async def test_augment_execute_cdx_singlearch( @@ -404,6 +407,14 @@ def verify_component_updated( if verify_tags: VerifyCycloneDX.verify_tags(kflx_component, cdx_component) + @staticmethod + def verify_mobster_version_info(sbom: Any) -> None: + """ + Verify that the mobster version info is added to the SBOM metadata. + """ + components = sbom["metadata"]["tools"]["components"] + assert cyclonedx.get_tools_component_dict() in components + @staticmethod def verify_components_updated(snapshot: Snapshot, sbom: Any) -> None: """ @@ -413,6 +424,7 @@ def verify_components_updated(snapshot: Snapshot, sbom: Any) -> None: VerifyCycloneDX.verify_component_updated( snapshot, sbom["metadata"]["component"], verify_tags=False ) + VerifyCycloneDX.verify_mobster_version_info(sbom) for component in sbom.get("components", []): VerifyCycloneDX.verify_component_updated( @@ -435,3 +447,12 @@ def test_get_purl_digest(purl_str: str, expected: str | BaseException) -> None: else: with pytest.raises(expected): # type: ignore get_purl_digest(purl_str) + + +def test_cdx_augment_metadata_tools_components_empty_metadata() -> None: + metadata: dict[str, Any] = {} + CycloneDXVersion1()._augment_metadata_tools_components(metadata) + + assert "tools" in metadata + assert "components" in metadata["tools"] + assert len(metadata["tools"]["components"]) == 1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..fb9e1e50 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,68 @@ +from typing import Any + +from mobster import get_mobster_version + + +def assert_spdx_sbom(actual: Any, expected: Any) -> None: + """ + Compare and assert result SPDX SBOM with the expected SBOM, handling + dynamic fields. + + Args: + actual (Any): actual generated SBOM dictionary + expected (Any): expected SBOM dictionary + """ + actual["creationInfo"]["created"] = expected["creationInfo"]["created"] + actual["documentNamespace"] = expected["documentNamespace"] + + assert ( + f"Tool: Mobster-{get_mobster_version()}" in actual["creationInfo"]["creators"] + ) + # Remove the Tool: Mobster entry from creators, as it's not in the expected result + actual["creationInfo"]["creators"] = [ + creator + for creator in actual["creationInfo"]["creators"] + if "Mobster" not in creator + ] + + assert actual == expected + + +def assert_cdx_sbom(actual: Any, expected: Any) -> None: + """ + Compare and assert a result CDX SBOM with the expected SBOM, handling + dynamic fields. + + Args: + actual (Any): actual generated SBOM dictionary + expected (Any): expected SBOM dictionary + """ + actual["serialNumber"] = expected["serialNumber"] + actual["metadata"]["timestamp"] = expected["metadata"]["timestamp"] + + assert { + "type": "application", + "name": "Mobster", + "version": get_mobster_version(), + } in actual["metadata"]["tools"]["components"] + del actual["metadata"]["tools"] + + root_bom_ref = actual["metadata"]["component"]["bom-ref"] + patch_bom_ref( + actual, + root_bom_ref, + expected["metadata"]["component"]["bom-ref"], + ) + + assert actual == expected + + +def patch_bom_ref(document: Any, old: str, new: str) -> Any: + document["metadata"]["component"]["bom-ref"] = new + for component in document["components"]: + if component["bom-ref"] == old: + component["bom-ref"] = new + for dependency in document["dependencies"]: + if dependency["ref"] == old: + dependency["ref"] = new + return document diff --git a/tests/data/index_manifest_sbom.spdx.json b/tests/data/index_manifest_sbom.spdx.json index 40712b7b..4d468651 100644 --- a/tests/data/index_manifest_sbom.spdx.json +++ b/tests/data/index_manifest_sbom.spdx.json @@ -4,8 +4,7 @@ "created": "2025-05-06T13:14:21Z", "creators": [ "Organization: Red Hat", - "Tool: Konflux CI", - "Tool: Mobster" + "Tool: Konflux CI" ] }, "dataLicense": "CC0-1.0", diff --git a/tests/data/modelcar_sbom.spdx.json b/tests/data/modelcar_sbom.spdx.json index 3f05a204..3b873511 100644 --- a/tests/data/modelcar_sbom.spdx.json +++ b/tests/data/modelcar_sbom.spdx.json @@ -4,8 +4,7 @@ "created": "2025-05-22T11:16:52Z", "creators": [ "Organization: Red Hat", - "Tool: Konflux CI", - "Tool: Mobster" + "Tool: Konflux CI" ] }, "dataLicense": "CC0-1.0", diff --git a/tox.ini b/tox.ini index 01eb4355..fcae465a 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ deps = poetry poetry-plugin-export commands_pre = - poetry install --no-root + poetry install setenv = PYTHONPATH = {toxinidir}/src