Skip to content

Commit

Permalink
Adding SPDX support
Browse files Browse the repository at this point in the history
Added SDPX format support for SBOM

Support for SPDX format was added to fetch-depds command and also
to merge_syft_sboms.
No changes were made in particular package manager generating components
which are then converted to cyclonedx format. SPDX sbom can be obtained
by calling Sbom.to_spdx().
New switch sbom-type was added to merge_syft_sboms, so user can choose
which output format should be generated - default is cyclonedx.
Once all tooling is ready to consume spdx sboms, cutoff changes
in this repository can be started.

SPDXRef-DocumentRoot-File- includes all spdx packages and is set
to be described by SPDXRef-DOCUMENT. This way of spdx generation
is closer to way syft generates spdx

Co-authered-by: Alexey Ovchinnikov <[email protected]>
Signed-off-by: Jindrich Luza <[email protected]>
  • Loading branch information
a-ovchinnikov committed Jan 14, 2025
1 parent dcff5b5 commit 72fbb39
Show file tree
Hide file tree
Showing 13 changed files with 5,627 additions and 19 deletions.
321 changes: 319 additions & 2 deletions cachi2/core/models/sbom.py

Large diffs are not rendered by default.

63 changes: 49 additions & 14 deletions cachi2/interface/cli.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import enum
import functools
import importlib.metadata
import json
import logging
import shutil
import sys
from itertools import chain
from pathlib import Path
from typing import Any, Callable, Optional
from typing import Any, Callable, List, Optional, Union

import pydantic
import typer
Expand All @@ -16,7 +16,7 @@
from cachi2.core.extras.envfile import EnvFormat, generate_envfile
from cachi2.core.models.input import Flag, PackageInput, Request, parse_user_input
from cachi2.core.models.output import BuildConfig
from cachi2.core.models.sbom import Sbom, merge_component_properties
from cachi2.core.models.sbom import Sbom, SPDXSbom, spdx_now
from cachi2.core.resolver import inject_files_post, resolve_packages, supported_package_managers
from cachi2.core.rooted_path import RootedPath
from cachi2.interface.logging import LogLevel, setup_logging
Expand All @@ -37,6 +37,20 @@
)


class SBOMFormat(str, enum.Enum):
"""The type of SBOM to generate."""

cyclonedx = "cyclonedx"
spdx = "spdx"


SBOM_TYPE_OPTION = typer.Option(
SBOMFormat.cyclonedx,
"--sbom-output-type",
help=("Format of generated SBOM. Default is CycloneDX"),
)


Paths = list[Path]


Expand Down Expand Up @@ -179,6 +193,7 @@ def fetch_deps(
"already have a vendor/ directory (will fail if changes would be made)."
),
),
sbom_type: SBOMFormat = SBOM_TYPE_OPTION,
) -> None:
"""Fetch dependencies for supported package managers.
Expand Down Expand Up @@ -284,7 +299,10 @@ def combine_option_and_json_flags(json_flags: list[Flag]) -> list[str]:
request_output.build_config.model_dump_json(indent=2, exclude_none=True)
)

sbom = request_output.generate_sbom()
if sbom_type == SBOMFormat.cyclonedx:
sbom: Union[Sbom, SPDXSbom] = request_output.generate_sbom()
else:
sbom = request_output.generate_sbom().to_spdx(doc_namespace="NOASSERTION")
request.output_dir.join_within_root("bom.json").path.write_text(
# the Sbom model has camelCase aliases in some fields
sbom.model_dump_json(indent=2, by_alias=True, exclude_none=True)
Expand Down Expand Up @@ -399,26 +417,43 @@ def merge_sboms(
help="Names of files with SBOMs to merge.",
),
output_sbom_file_name: Optional[Path] = OUTFILE_OPTION,
sbom_type: SBOMFormat = SBOM_TYPE_OPTION,
sbom_name: Optional[str] = typer.Option(
None, "--sbom-name", help="Name of the resulting merged SBOM."
),
) -> None:
"""Merge two or more SBOMs into one.
The command works with Cachi2-generated SBOMs only. You might want to run
The command works with Cachi2-generated SBOMs and with a supported subset of
SPDX SBOMs. You might want to run
cachi2 fetch-deps <args...>
cachi2 fetch-deps <args...>
first to produce SBOMs to merge.
"""
sboms_to_merge = []
sboms_to_merge: List[Union[SPDXSbom, Sbom]] = []
for sbom_file in sbom_files_to_merge:
sbom_dict = json.loads(sbom_file.read_text())
# Remove extra fields which are not in Sbom or SPDXSbom models
# Both SBom and SPDXSBom models are only subset of cyclonedx and SPDX specifications
# Therefore we need to make sure only fields accepted by the models are present
try:
sboms_to_merge.append(Sbom.model_validate_json(sbom_file.read_text()))
sboms_to_merge.append(Sbom(**sbom_dict))
except pydantic.ValidationError:
raise UnexpectedFormat(f"{sbom_file} does not appear to be a valid Cachi2 SBOM.")
sbom = Sbom(
components=merge_component_properties(
chain.from_iterable(s.components for s in sboms_to_merge)
)
)
try:
sboms_to_merge.append(SPDXSbom(**sbom_dict))
except pydantic.ValidationError:
raise UnexpectedFormat(f"{sbom_file} does not appear to be a valid Cachi2 SBOM.")
# start_sbom will later coerce every other SBOM to its type.
start_sbom: Union[Sbom, SPDXSbom] # this visual noise is demanded by mypy.
if sbom_type == SBOMFormat.cyclonedx:
start_sbom = sboms_to_merge[0].to_cyclonedx()
else:
start_sbom = sboms_to_merge[0].to_spdx(doc_namespace="NOASSERTION")
if sbom_name is not None:
start_sbom.name = sbom_name
start_sbom.creationInfo.created = spdx_now()
sbom = sum(sboms_to_merge[1:], start=start_sbom)
sbom_json = sbom.model_dump_json(indent=2, by_alias=True, exclude_none=True)

if output_sbom_file_name is not None:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies = [
"tomli",
"typer",
"createrepo-c",
"packageurl-python"
]
[project.optional-dependencies]
dev = [
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import tarfile
from pathlib import Path
from typing import Generator
from unittest import mock

import git
import pytest
Expand Down Expand Up @@ -57,3 +59,10 @@ def input_request(tmp_path: Path, request: pytest.FixtureRequest) -> Request:
output_dir=tmp_path / "output",
packages=package_input,
)


@pytest.fixture
def isodate() -> Generator:
with mock.patch("datetime.datetime") as mock_datetime:
mock_datetime.now.return_value.isoformat.return_value = "2021-07-01T00:00:00.000000"
yield mock_datetime
Loading

0 comments on commit 72fbb39

Please sign in to comment.