Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/mobster/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
36 changes: 36 additions & 0 deletions src/mobster/cmd/augment/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/mobster/cmd/generate/modelcar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/mobster/sbom/cyclonedx.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"""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 (
Component,
ComponentType,
)

from mobster import get_mobster_version
from mobster.image import Image


Expand All @@ -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,
}
3 changes: 2 additions & 1 deletion src/mobster/sbom/spdx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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),
)
Expand Down
9 changes: 2 additions & 7 deletions tests/cmd/generate/test_generate_oci_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
32 changes: 3 additions & 29 deletions tests/cmd/generate/test_modelcar.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
29 changes: 25 additions & 4 deletions tests/cmd/test_augment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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(
Expand All @@ -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
68 changes: 68 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 1 addition & 2 deletions tests/data/index_manifest_sbom.spdx.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading