diff --git a/.yamllint b/.yamllint index bd129bcd..e91e6974 100644 --- a/.yamllint +++ b/.yamllint @@ -21,3 +21,4 @@ ignore: - ansible/vaults/ - .venv - node_modules/ + - .tekton/ # Directory managed by Konflux diff --git a/docs/img/index-image.spdx.svg b/docs/img/index-image.spdx.svg new file mode 100644 index 00000000..3f127cd2 --- /dev/null +++ b/docs/img/index-image.spdx.svg @@ -0,0 +1,143 @@ + + + + + + + + + +SPDXRef-DOCUMENT + +SPDXRef-DOCUMENT + + + + +SPDXRef-DOCUMENT_DESCRIBES + +DESCRIBES + + + +SPDXRef-DOCUMENT->SPDXRef-DOCUMENT_DESCRIBES + + + + + +SPDXRef-image-index + +SPDXRef-image-index + + + +SPDXRef-DOCUMENT_DESCRIBES->SPDXRef-image-index + + + + + +SPDXRef-image-ubi-eed2511e5b2f6c7891d79466ef64ed13e9e29e8fe536709666084922e3ff2ff6 + +SPDXRef-image-ubi-eed2511e5b2f6c7891d79466ef64ed13e9e29e8fe536709666084922e3ff2ff6 + + + + +SPDXRef-image-ubi-eed2511e5b2f6c7891d79466ef64ed13e9e29e8fe536709666084922e3ff2ff6_VARIANT_OF + +VARIANT_OF + + + +SPDXRef-image-ubi-eed2511e5b2f6c7891d79466ef64ed13e9e29e8fe536709666084922e3ff2ff6->SPDXRef-image-ubi-eed2511e5b2f6c7891d79466ef64ed13e9e29e8fe536709666084922e3ff2ff6_VARIANT_OF + + + + + +SPDXRef-image-ubi-eed2511e5b2f6c7891d79466ef64ed13e9e29e8fe536709666084922e3ff2ff6_VARIANT_OF->SPDXRef-image-index + + + + + +SPDXRef-image-ubi-2ded713f6a4f846c080a42f73f1e1af6f5b4a7653825480f0e0f1d86dec89375 + +SPDXRef-image-ubi-2ded713f6a4f846c080a42f73f1e1af6f5b4a7653825480f0e0f1d86dec89375 + + + + +SPDXRef-image-ubi-2ded713f6a4f846c080a42f73f1e1af6f5b4a7653825480f0e0f1d86dec89375_VARIANT_OF + +VARIANT_OF + + + +SPDXRef-image-ubi-2ded713f6a4f846c080a42f73f1e1af6f5b4a7653825480f0e0f1d86dec89375->SPDXRef-image-ubi-2ded713f6a4f846c080a42f73f1e1af6f5b4a7653825480f0e0f1d86dec89375_VARIANT_OF + + + + + +SPDXRef-image-ubi-2ded713f6a4f846c080a42f73f1e1af6f5b4a7653825480f0e0f1d86dec89375_VARIANT_OF->SPDXRef-image-index + + + + + +SPDXRef-image-ubi-e568fd454597f2450f75978641c020c50874cf4760e986a1cd9f4a1b3fbc2c4c + +SPDXRef-image-ubi-e568fd454597f2450f75978641c020c50874cf4760e986a1cd9f4a1b3fbc2c4c + + + + +SPDXRef-image-ubi-e568fd454597f2450f75978641c020c50874cf4760e986a1cd9f4a1b3fbc2c4c_VARIANT_OF + +VARIANT_OF + + + +SPDXRef-image-ubi-e568fd454597f2450f75978641c020c50874cf4760e986a1cd9f4a1b3fbc2c4c->SPDXRef-image-ubi-e568fd454597f2450f75978641c020c50874cf4760e986a1cd9f4a1b3fbc2c4c_VARIANT_OF + + + + + +SPDXRef-image-ubi-e568fd454597f2450f75978641c020c50874cf4760e986a1cd9f4a1b3fbc2c4c_VARIANT_OF->SPDXRef-image-index + + + + + +SPDXRef-image-ubi-b7c40d971296f5b6197d0a0833e91f4172720d37db4089ebb1159a9bbe68b52e + +SPDXRef-image-ubi-b7c40d971296f5b6197d0a0833e91f4172720d37db4089ebb1159a9bbe68b52e + + + + +SPDXRef-image-ubi-b7c40d971296f5b6197d0a0833e91f4172720d37db4089ebb1159a9bbe68b52e_VARIANT_OF + +VARIANT_OF + + + +SPDXRef-image-ubi-b7c40d971296f5b6197d0a0833e91f4172720d37db4089ebb1159a9bbe68b52e->SPDXRef-image-ubi-b7c40d971296f5b6197d0a0833e91f4172720d37db4089ebb1159a9bbe68b52e_VARIANT_OF + + + + + +SPDXRef-image-ubi-b7c40d971296f5b6197d0a0833e91f4172720d37db4089ebb1159a9bbe68b52e_VARIANT_OF->SPDXRef-image-index + + + + + diff --git a/docs/sboms/oci_image_sbom.md b/docs/sboms/oci_image_sbom.md new file mode 100644 index 00000000..b6e6a2a8 --- /dev/null +++ b/docs/sboms/oci_image_sbom.md @@ -0,0 +1,56 @@ +# SBOM for Image Index + +The Mobster tool is capable of generating SBOMs for OCI image indexes based +on the guidelines from the +[Red Hat Product Security](https://github.com/RedHatProductSecurity/security-data-guidelines). + +## Usage + +```bash +# First get index manifest using buildah +buildah manifest inspect registry.redhat.io/ubi10-beta/ubi@sha256:f817eb70b083c93b4d6b47e1daae292d662e3427f5e73c5e8f513695e5afc7cc > ./index-image-manifest.json + +# Then generate SBOM using Mobster +mobster generate \ + --output index.sbom.spdx.json \ + oci-index \ + --index-image-pullspec "registry.redhat.io/ubi10-beta/ubi:latest" \ + --index-image-digest "sha256:f817eb70b083c93b4d6b47e1daae292d662e3427f5e73c5e8f513695e5afc7cc" \ + --index-manifest-path ./index-image-manifest.json +``` + + +**List of arguments:** + +- `--index-image-pullspec` + - Must be in the format `repository/image:tag` + - Example value `registry.redhat.io/ubi10-beta/ubi:latest` +- `--index-image-digest` + - Must be in the format `algorithm:hexvalue` + - Example value `sha256:f817eb70b083c93b4d6b47e1daae292d662e3427f5e73c5e8f513695e5afc7cc` +- `--index-manifest-path` + - Path to a file containing a json output of `buildah manifest inspect` command + - File contents MUST be a valid JSON + - See example in [index_manifest.json](../../tests/data/index_manifest.json) +- `--output` + - Path where the SBOM should be written + + +## Example + +The example SBOM generated by the above command is available in +[tests/data/index_manifest_sbom.spdx.json](../../tests/data/index_manifest_sbom.spdx.json). + +# Structure of the generated SBOM + +The generated SBOM has following structure: +``` + - SPDXRef-DOCUMENT + - SPDXRef-image-index + - Image-amd64 (VARIANT_OF) + - Image-arm64 (VARIANT_OF) + - Image-ppc64le (VARIANT_OF) + - Image-s390x (VARIANT_OF) +``` + +![index-sbom](../img/index-image.spdx.svg) diff --git a/src/mobster/cmd/generate.py b/src/mobster/cmd/generate.py index 8cb8f8bb..cfb423eb 100644 --- a/src/mobster/cmd/generate.py +++ b/src/mobster/cmd/generate.py @@ -3,9 +3,24 @@ import json import logging from abc import ABC +from datetime import datetime, timezone from typing import Any +from uuid import uuid4 + +from spdx_tools.spdx.model.actor import Actor, ActorType +from spdx_tools.spdx.model.checksum import Checksum, ChecksumAlgorithm +from spdx_tools.spdx.model.document import CreationInfo, Document +from spdx_tools.spdx.model.package import ( + ExternalPackageRef, + ExternalPackageRefCategory, + Package, +) +from spdx_tools.spdx.model.relationship import Relationship, RelationshipType +from spdx_tools.spdx.model.spdx_no_assertion import SpdxNoAssertion +from spdx_tools.spdx.writer.write_anything import write_file from mobster.cmd.base import Command +from mobster.image import Image LOGGER = logging.getLogger(__name__) @@ -16,7 +31,7 @@ class GenerateCommand(Command, ABC): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self._content: dict[str, Any] | None = None + self._content: Any = None @property def content(self) -> Any: @@ -55,15 +70,206 @@ class GenerateOciIndexCommand(GenerateCommand): Command to generate an SBOM document for an OCI index image. """ + INDEX_IMAGE_MANIFEST_MEDIA_TYPES = [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + ] + + IMAGE_MANIFEST_MEDIA_TYPES = [ + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + ] + + DOC_ELEMENT_ID = "SPDXRef-DOCUMENT" + INDEX_ELEMENT_ID = "SPDXRef-image-index" + + def get_package(self, image: Image, spdx_id: str) -> Package: + """ + Transform the parsed image object into SPDX package object. + + + Args: + image (Image): A parsed image object. + spdx_id (str): An SPDX ID for the image. + + Returns: + Package: A package object representing the OCI image. + """ + + package = Package( + spdx_id=spdx_id, + name=image.name if not image.arch else f"{image.name}_{image.arch}", + version=image.tag, + download_location=SpdxNoAssertion(), + supplier=Actor(ActorType.ORGANIZATION, "Red Hat"), + license_declared=SpdxNoAssertion(), + files_analyzed=False, + external_references=[ + ExternalPackageRef( + category=ExternalPackageRefCategory.PACKAGE_MANAGER, + reference_type="purl", + locator=image.purl(), + ) + ], + checksums=[ + Checksum( + algorithm=ChecksumAlgorithm.SHA256, + value=image.digest_hex_val, + ) + ], + ) + + return package + + def get_index_image_relationship(self, spdx_id: str) -> Relationship: + """ + Get a relationship for the OCI index image in relation to the SPDX document. + This relationship indicates that the document describes the index image. + + Args: + spdx_id (str): An SPDX ID for the index image. + + Returns: + Relationship: A SPDX relationship object for the index image. + """ + return Relationship( + spdx_element_id=self.DOC_ELEMENT_ID, + relationship_type=RelationshipType.DESCRIBES, + related_spdx_element_id=spdx_id, + ) + + def get_child_image_relationship(self, spdx_id: str) -> Relationship: + """ + Get a relationship for the child image in relation to the OCI index image. + This relationship indicates that the child image is + a variant of the index image. + + Args: + spdx_id (str): An SPDX ID for the child image. + + Returns: + Relationship: A SPDX relationship object for the child image. + """ + return Relationship( + spdx_element_id=spdx_id, + relationship_type=RelationshipType.VARIANT_OF, + related_spdx_element_id=self.INDEX_ELEMENT_ID, + ) + + def get_child_packages( + self, index_image: Image + ) -> tuple[list[Package], list[Relationship]]: + """ + Get child packages from the OCI index image. + """ + packages = [] + relationships = [] + + with open(self.cli_args.index_manifest_path, encoding="utf8") as manifest_file: + index_manifest = json.load(manifest_file) + + if index_manifest["mediaType"] not in self.INDEX_IMAGE_MANIFEST_MEDIA_TYPES: + raise ValueError( + "Invalid input file detected, requires `buildah manifest inspect` json." + ) + + LOGGER.debug("Inspecting OCI index image: %s", index_manifest) + + for manifest in index_manifest["manifests"]: + if manifest["mediaType"] not in self.IMAGE_MANIFEST_MEDIA_TYPES: + LOGGER.warning( + "Skipping manifest with unsupported media type: %s", + manifest["mediaType"], + ) + continue + + arch = manifest.get("platform", {}).get("architecture") + + LOGGER.info("Found child image with architecture: %s", arch) + + arch_image = Image( + arch=arch, + name=index_image.name, + digest=self.cli_args.index_image_digest, + tag=index_image.tag, + repository=index_image.repository, + ) + spdx_id = arch_image.propose_spdx_id() + package = self.get_package( + arch_image, + spdx_id, + ) + relationship = self.get_child_image_relationship(spdx_id) + + packages.append(package) + relationships.append(relationship) + + return packages, relationships + + def get_creation_info(self, index_image: Image) -> CreationInfo: + """ + Create the creation information for the SPDX document. + + Args: + index_image (Image): An OCI index image object. + + Returns: + CreationInfo: A creation information object for the SPDX document. + """ + sbom_name = f"{index_image.repository}@{index_image.digest}" + return CreationInfo( + spdx_version="SPDX-2.3", + spdx_id=self.DOC_ELEMENT_ID, + name=sbom_name, + data_license="CC0-1.0", + document_namespace="https://konflux-ci.dev/spdxdocs/" + f"{index_image.name}-{index_image.tag}-{uuid4()}", + creators=[ + Actor(ActorType.ORGANIZATION, "Red Hat"), + Actor(ActorType.TOOL, "Konflux CI"), + Actor(ActorType.TOOL, "Mobster"), + ], + created=datetime.now(timezone.utc), + ) + async def execute(self) -> Any: """ - Generate an SBOM document for OCI index. + Generate an SBOM document for OCI index in SPDX format. """ - # Placeholder for the actual implementation - LOGGER.debug("Generating SBOM document for OCI index") - self._content = {} + LOGGER.info("Generating SBOM document for OCI index") + + index_image = Image.from_image_index_url_and_digest( + self.cli_args.index_image_pullspec, self.cli_args.index_image_digest + ) + + main_package = self.get_package(index_image, self.INDEX_ELEMENT_ID) + main_relationship = self.get_index_image_relationship(self.INDEX_ELEMENT_ID) + component_packages, component_relationships = self.get_child_packages( + index_image + ) + + # Assemble a complete SPDX document + document = Document( + creation_info=self.get_creation_info(index_image), + packages=[main_package] + component_packages, + relationships=[main_relationship] + component_relationships, + ) + + self._content = document return self.content + async def save(self) -> None: + """ + Convert SPDX document to JSON and save it to a file. + """ + if self.cli_args.output and self._content: + LOGGER.info("Saving SBOM document to '%s'", self.cli_args.output) + write_file( + self._content, + str(self.cli_args.output), + validate=True, + ) + class GenerateProductCommand(GenerateCommand): """ diff --git a/src/mobster/image.py b/src/mobster/image.py new file mode 100644 index 00000000..69dd1358 --- /dev/null +++ b/src/mobster/image.py @@ -0,0 +1,101 @@ +"""An image module for representing OCI images.""" + +import hashlib +from dataclasses import dataclass + +from packageurl import PackageURL + + +@dataclass +class Image: + """ + Dataclass representing an oci image. + """ + + repository: str + name: str + digest: str + tag: str + arch: str | None + + @staticmethod + def from_image_index_url_and_digest( + image_tag_pullspec: str, + image_digest: str, + arch: str | None = None, + ) -> "Image": + """ + Create an Image object from the image URL and digest. + + Args: + image_tag_pullspec (str): Image pullspec in the format + /: + image_digest (str): Image digest in the format sha256: + arch (str | None, optional): Image architecure if present. Defaults to None. + + Returns: + Image: A representation of the OCI image. + """ + repository, tag = image_tag_pullspec.rsplit(":", 1) + _, name = repository.rsplit("/", 1) + + return Image( + repository=repository, + name=name, + digest=image_digest, + tag=tag, + arch=arch, + ) + + @property + def digest_algo(self) -> str: + """ + Get the algorithm used for the digest. + + Returns: + str: An uppercase string representing the algorithm used for the digest. + """ + algo, _ = self.digest.split(":") + return algo.upper() + + @property + def digest_hex_val(self) -> str: + """ + A digest value in hex format. + + Returns: + str: A hex string representing the digest value. + """ + _, val = self.digest.split(":") + return val + + def purl(self) -> str: + """ + A package URL representation of the image in string format. + + Returns: + str: Package URL string. + """ + qualifiers = {"repository_url": self.repository} + if self.arch is not None: + qualifiers["arch"] = self.arch + + purl = PackageURL( + type="oci", + name=self.name, + version=self.digest, + qualifiers=qualifiers, + ).to_string() + + return purl + + def propose_spdx_id(self) -> str: + """ + Generate a proposed SPDX ID for the image. + The ID is generated using the image name and a SHA-256 hash of the package URL. + + Returns: + str: A proposed SPDX ID for the image. + """ + purl_hex_digest = hashlib.sha256(self.purl().encode()).hexdigest() + return f"SPDXRef-image-{self.name}-{purl_hex_digest}" diff --git a/tests/cmd/test_generate.py b/tests/cmd/test_generate.py index 4d22fdcb..1fc5c679 100644 --- a/tests/cmd/test_generate.py +++ b/tests/cmd/test_generate.py @@ -1,6 +1,9 @@ from unittest.mock import MagicMock, mock_open, patch import pytest +from spdx_tools.spdx.model.document import CreationInfo +from spdx_tools.spdx.model.package import Package +from spdx_tools.spdx.model.relationship import Relationship, RelationshipType from mobster.cmd.generate import ( GenerateModelcarCommand, @@ -9,6 +12,7 @@ GenerateOciIndexCommand, GenerateProductCommand, ) +from mobster.image import Image @pytest.mark.asyncio @@ -31,10 +35,196 @@ async def test_GenerateOciImageCommand_save(mock_dump: MagicMock) -> None: @pytest.mark.asyncio -async def test_GenerateOciIndexCommand_execute() -> None: +@patch("mobster.cmd.generate.Document") +@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_creation_info") +@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_child_packages") +@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_index_image_relationship") +@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_package") +@patch("mobster.cmd.generate.Image.from_image_index_url_and_digest") +async def test_GenerateOciIndexCommand_execute( + mock_image: MagicMock, + mock_get_package: MagicMock, + mock_index_relationship: MagicMock, + mock_child_packages: MagicMock, + mock_get_creation_info: MagicMock, + mock_doc: MagicMock, +) -> None: command = GenerateOciIndexCommand(MagicMock()) - assert await command.execute() == {} + mock_child_packages.return_value = ([], []) + + result = await command.execute() + assert result == mock_doc.return_value + + mock_get_package.assert_called_once() + mock_index_relationship.assert_called_once() + mock_child_packages.assert_called_once() + mock_doc.assert_called_once_with( + creation_info=mock_get_creation_info.return_value, + packages=[mock_get_package.return_value] + mock_child_packages.return_value[0], + relationships=[ + mock_index_relationship.return_value, + ] + + mock_child_packages.return_value[1], + ) + + +@pytest.mark.asyncio +@patch("mobster.cmd.generate.write_file") +async def test_GenerateOciIndexCommand_save( + mock_write_file: MagicMock, +) -> None: + args = MagicMock() + args.output = "/tmp/test.json" + command = GenerateOciIndexCommand(args) + + command._content = MagicMock() + + await command.save() + + mock_write_file.assert_called_once_with( + command._content, + args.output, + validate=True, + ) + + +def test_GenerateOciIndexCommand_get_package() -> None: + args = MagicMock() + command = GenerateOciIndexCommand(args) + mock_image = Image.from_image_index_url_and_digest( + "registry/repo:tag", "sha256:1234567890abcdef" + ) + result = command.get_package(mock_image, "fake_spdx_id") + + assert isinstance(result, Package) + assert result.spdx_id == "fake_spdx_id" + assert result.name == mock_image.name + assert result.checksums[0].value == mock_image.digest_hex_val + + +def test_GenerateOciIndexCommand_get_index_image_relationship() -> None: + args = MagicMock() + command = GenerateOciIndexCommand(args) + + result = command.get_index_image_relationship("fake_spdx_id") + + assert isinstance(result, Relationship) + assert result.spdx_element_id == command.DOC_ELEMENT_ID + assert result.relationship_type == RelationshipType.DESCRIBES + assert result.related_spdx_element_id == "fake_spdx_id" + + +def test_GenerateOciIndexCommand_get_child_image_relationship() -> None: + args = MagicMock() + command = GenerateOciIndexCommand(args) + + result = command.get_child_image_relationship("fake_spdx_id") + + assert isinstance(result, Relationship) + assert result.spdx_element_id == "fake_spdx_id" + assert result.relationship_type == RelationshipType.VARIANT_OF + assert result.related_spdx_element_id == command.INDEX_ELEMENT_ID + + +@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_child_image_relationship") +@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_package") +@patch("mobster.cmd.generate.json.load") +def test_GenerateOciIndexCommand_get_child_packages( + mock_json_load: MagicMock, + mock_get_package: MagicMock, + mock_get_child_image_relationship: MagicMock, +) -> None: + args = MagicMock() + args.index_image_pullspec = "registry/repo:tag" + args.index_image_digest = "sha256:1234567890abcdef" + command = GenerateOciIndexCommand(args) + + mock_image = Image.from_image_index_url_and_digest( + "registry/repo:tag", "sha256:1234567890abcdef" + ) + mock_manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e247120" + "6f97fd9d7aaaecbc04ac", + "platform": {"architecture": "amd64", "os": "linux"}, + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:c85623b2a5822b6e101efb05424919da653e7c15e2e3" + "e150871c48957087d65a", + "platform": {"architecture": "arm64", "os": "linux"}, + }, + { + "mediaType": "fake_media_type", + "digest": "sha256:c856", + }, + ], + } + mock_json_load.return_value = mock_manifest + + result = command.get_child_packages(mock_image) + + assert mock_get_package.call_count == 2 + assert mock_get_child_image_relationship.call_count == 2 + + packages, relationships = result + assert len(packages) == 2 + assert len(relationships) == 2 + + +@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_child_image_relationship") +@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_package") +@patch("mobster.cmd.generate.json.load") +def test_GenerateOciIndexCommand_get_child_packages_unknown( + mock_json_load: MagicMock, + mock_get_package: MagicMock, + mock_get_child_image_relationship: MagicMock, +) -> None: + args = MagicMock() + args.index_image_pullspec = "registry/repo:tag" + args.index_image_digest = "sha256:1234567890abcdef" + command = GenerateOciIndexCommand(args) + + mock_image = Image.from_image_index_url_and_digest( + "registry/repo:tag", "sha256:1234567890abcdef" + ) + mock_manifest = { + "schemaVersion": 2, + "mediaType": "uknown_media_type", + "manifests": [], + } + mock_json_load.return_value = mock_manifest + + with pytest.raises(ValueError): + command.get_child_packages(mock_image) + + +@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_child_image_relationship") +@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_package") +@patch("mobster.cmd.generate.json.load") +def test_GenerateOciIndexCommand_get_creation_info( + mock_json_load: MagicMock, + mock_get_package: MagicMock, + mock_get_child_image_relationship: MagicMock, +) -> None: + args = MagicMock() + args.index_image_pullspec = "registry/repo:tag" + args.index_image_digest = "sha256:1234567890abcdef" + command = GenerateOciIndexCommand(args) + + mock_image = Image.from_image_index_url_and_digest( + "registry/repo:tag", "sha256:1234567890abcdef" + ) + + result = command.get_creation_info(mock_image) + + assert isinstance(result, CreationInfo) + assert result.spdx_id == command.DOC_ELEMENT_ID @pytest.mark.asyncio diff --git a/tests/cmd/test_generate_oci_index.py b/tests/cmd/test_generate_oci_index.py new file mode 100644 index 00000000..ba4b48c0 --- /dev/null +++ b/tests/cmd/test_generate_oci_index.py @@ -0,0 +1,46 @@ +import json +import pathlib +import tempfile +from unittest.mock import MagicMock + +import pytest + +from mobster.cmd.generate import GenerateOciIndexCommand + + +@pytest.mark.asyncio +async def test_generate_oci_index_sbom() -> None: + """ + This test verifies the generation of an OCI index SBOM end-to-end. + """ + + args = MagicMock() + current_dir = pathlib.Path(__file__).parent.resolve() + args.index_manifest_path = current_dir.parent / "data/index_manifest.json" + args.index_image_pullspec = "registry.redhat.io/ubi10-beta/ubi:latest" + args.index_image_digest = ( + "sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac" + ) + + expected_output_path = current_dir.parent / "data/index_manifest_sbom.spdx.json" + with open(expected_output_path, encoding="utf8") as expected_file: + expected_output = json.load(expected_file) + + command = GenerateOciIndexCommand(args) + + with tempfile.TemporaryDirectory() as temp_dir: + args.output = pathlib.Path(temp_dir) / "index_manifest_sbom.spdx.json" + await command.execute() + await command.save() + + assert command._content is not 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 diff --git a/tests/data/index_manifest.json b/tests/data/index_manifest.json new file mode 100644 index 00000000..23eb7d02 --- /dev/null +++ b/tests/data/index_manifest.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac", + "size": 659, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:c85623b2a5822b6e101efb05424919da653e7c15e2e3e150871c48957087d65a", + "size": 659, + "platform": { + "architecture": "arm64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:874debf2354befc6ce90ccee2cbdab4abe2bf437aa6f4ab7649f7aeb4d57373a", + "size": 659, + "platform": { + "architecture": "s390x", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:c8799d5d851ddd76729ebb7227818f8c0030c4443c9e8542a75ff393fc90ce12", + "size": 659, + "platform": { + "architecture": "ppc64le", + "os": "linux" + } + } + ] +} diff --git a/tests/data/index_manifest_sbom.spdx.json b/tests/data/index_manifest_sbom.spdx.json new file mode 100644 index 00000000..40712b7b --- /dev/null +++ b/tests/data/index_manifest_sbom.spdx.json @@ -0,0 +1,154 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "creationInfo": { + "created": "2025-05-06T13:14:21Z", + "creators": [ + "Organization: Red Hat", + "Tool: Konflux CI", + "Tool: Mobster" + ] + }, + "dataLicense": "CC0-1.0", + "name": "registry.redhat.io/ubi10-beta/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac", + "spdxVersion": "SPDX-2.3", + "documentNamespace": "https://konflux-ci.dev/spdxdocs/ubi-latest-aa76aa8c-13ce-4c13-bdf1-f16dd9a9ff54", + "packages": [ + { + "SPDXID": "SPDXRef-image-index", + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": "4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac" + } + ], + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE_MANAGER", + "referenceLocator": "pkg:oci/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac?repository_url=registry.redhat.io/ubi10-beta/ubi", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseDeclared": "NOASSERTION", + "name": "ubi", + "supplier": "Organization: Red Hat", + "versionInfo": "latest" + }, + { + "SPDXID": "SPDXRef-image-ubi-eed2511e5b2f6c7891d79466ef64ed13e9e29e8fe536709666084922e3ff2ff6", + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": "4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac" + } + ], + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE_MANAGER", + "referenceLocator": "pkg:oci/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac?arch=amd64&repository_url=registry.redhat.io/ubi10-beta/ubi", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseDeclared": "NOASSERTION", + "name": "ubi_amd64", + "supplier": "Organization: Red Hat", + "versionInfo": "latest" + }, + { + "SPDXID": "SPDXRef-image-ubi-2ded713f6a4f846c080a42f73f1e1af6f5b4a7653825480f0e0f1d86dec89375", + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": "4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac" + } + ], + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE_MANAGER", + "referenceLocator": "pkg:oci/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac?arch=arm64&repository_url=registry.redhat.io/ubi10-beta/ubi", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseDeclared": "NOASSERTION", + "name": "ubi_arm64", + "supplier": "Organization: Red Hat", + "versionInfo": "latest" + }, + { + "SPDXID": "SPDXRef-image-ubi-e568fd454597f2450f75978641c020c50874cf4760e986a1cd9f4a1b3fbc2c4c", + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": "4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac" + } + ], + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE_MANAGER", + "referenceLocator": "pkg:oci/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac?arch=s390x&repository_url=registry.redhat.io/ubi10-beta/ubi", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseDeclared": "NOASSERTION", + "name": "ubi_s390x", + "supplier": "Organization: Red Hat", + "versionInfo": "latest" + }, + { + "SPDXID": "SPDXRef-image-ubi-b7c40d971296f5b6197d0a0833e91f4172720d37db4089ebb1159a9bbe68b52e", + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": "4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac" + } + ], + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE_MANAGER", + "referenceLocator": "pkg:oci/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac?arch=ppc64le&repository_url=registry.redhat.io/ubi10-beta/ubi", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseDeclared": "NOASSERTION", + "name": "ubi_ppc64le", + "supplier": "Organization: Red Hat", + "versionInfo": "latest" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-image-index", + "relationshipType": "DESCRIBES" + }, + { + "spdxElementId": "SPDXRef-image-ubi-eed2511e5b2f6c7891d79466ef64ed13e9e29e8fe536709666084922e3ff2ff6", + "relatedSpdxElement": "SPDXRef-image-index", + "relationshipType": "VARIANT_OF" + }, + { + "spdxElementId": "SPDXRef-image-ubi-2ded713f6a4f846c080a42f73f1e1af6f5b4a7653825480f0e0f1d86dec89375", + "relatedSpdxElement": "SPDXRef-image-index", + "relationshipType": "VARIANT_OF" + }, + { + "spdxElementId": "SPDXRef-image-ubi-e568fd454597f2450f75978641c020c50874cf4760e986a1cd9f4a1b3fbc2c4c", + "relatedSpdxElement": "SPDXRef-image-index", + "relationshipType": "VARIANT_OF" + }, + { + "spdxElementId": "SPDXRef-image-ubi-b7c40d971296f5b6197d0a0833e91f4172720d37db4089ebb1159a9bbe68b52e", + "relatedSpdxElement": "SPDXRef-image-index", + "relationshipType": "VARIANT_OF" + } + ] +} diff --git a/tests/test_image.py b/tests/test_image.py new file mode 100644 index 00000000..00932384 --- /dev/null +++ b/tests/test_image.py @@ -0,0 +1,38 @@ +from mobster.image import Image + + +def test_image() -> None: + """ + Test the from_image_index_url_and_digest method of the Image class. + """ + image_tag_pullspec = "registry.example.com/repo/image:tag" + image_digest = ( + "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + ) + arch = "amd64" + + image = Image.from_image_index_url_and_digest( + image_tag_pullspec, image_digest, arch + ) + + assert image.repository == "registry.example.com/repo/image" + assert image.name == "image" + assert image.digest == image_digest + assert image.tag == "tag" + assert image.arch == arch + + assert image.digest_algo == "SHA256" + assert image.digest_hex_val == ( + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + ) + assert ( + image.purl() + == "pkg:oci/image@sha256:1234567890abcdef1234567890abcdef1234567890abcdef123456" + "7890abcdef?arch=amd64&repository_url=registry.example.com/repo/image" + ) + + assert ( + image.propose_spdx_id() + == "SPDXRef-image-image-73e355ba72fbb39f9249a171eb05bed259d998d5f747b5001ad42fb" + "1bda26e6a" + )