Skip to content

Commit

Permalink
[STONEBLD-2714] Added SDPX format support for SBOM
Browse files Browse the repository at this point in the history
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_syfy_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.
  • Loading branch information
midnightercz committed Aug 22, 2024
1 parent 05e8654 commit 5e78923
Show file tree
Hide file tree
Showing 7 changed files with 713 additions and 27 deletions.
105 changes: 104 additions & 1 deletion cachi2/core/models/sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pydantic

from cachi2.core.models.validators import unique_sorted
from cachi2.core.models.validators import unique_sorted, unique_sorted_multikey

PropertyName = Literal[
"cachi2:found_by",
Expand Down Expand Up @@ -93,3 +93,106 @@ class Sbom(pydantic.BaseModel):
def _unique_components(cls, components: list[Component]) -> list[Component]:
"""Sort and de-duplicate components."""
return unique_sorted(components, by=lambda component: component.key())

def to_spdx(self) -> "SPDXSbom":
"""Convert a CycloneDX SBOM to an SPDX SBOM."""
return SPDXSbom(
packages=[
SPDXPackage(
name=component.name,
version=component.version,
package_download_location=component.purl,
)
for component in self.components
],
creationInfo=SPDXCreationInfo(
creators=[f"{tool.vendor} {tool.name}" for tool in self.metadata.tools]
),
)


class SPDXPackageExternalRef(pydantic.BaseModel):
"""SPDX Package External Reference.
Compliant to the SPDX specification:
https://spdx.github.io/spdx-spec/v2.3/package-information/#721-external-reference-field
"""

referenceCategory: str
referenceLocator: str
referenceType: str


class SPDXPackage(pydantic.BaseModel):
"""SPDX Package.
Compliant to the SPDX specification:
https://spdx.github.io/spdx-spec/v2.3/package-information/
"""

name: str
version: Optional[str] = None
externalRefs: list[SPDXPackageExternalRef] = []

def key(self) -> tuple[str, ...]:
"""Uniquely identifies a package.
Used mainly for sorting and deduplication.
"""
purls = []
for ref in self.externalRefs:
if ref.referenceType == "purl":
purls.append(ref.referenceLocator)
return tuple(purls)

@classmethod
def from_package_dict(cls, package: dict[str, Any]) -> "SPDXPackage":
"""Create a SPDXPackage from a Cachi2 package dictionary."""
if package.get("externalRefs", None):
external_refs = [
SPDXPackageExternalRef(
referenceLocator=er["referenceLocator"],
referenceType=er["referenceType"],
referenceCategory=er["referenceCategory"],
)
for er in package["externalRefs"]
]
else:
external_refs = []

return SPDXPackage(
name=package.get("name", None),
version=package.get("version", None),
externalRefs=external_refs,
)


class SPDXCreationInfo(pydantic.BaseModel):
"""SPDX Creation Information.
Compliant to the SPDX specification:
https://spdx.github.io/spdx-spec/v2.3/document-creation-information/
"""

creators: list[str] = []


class SPDXSbom(pydantic.BaseModel):
"""Software bill of materials in the SPDX format.
See full specification at:
https://spdx.github.io/spdx-spec/v2.3
"""

spdxVersion: str = "SPDX-2.3"
spdxIdentifier: str = "SPDXRef-DOCUMENT"
dataLicense: str = "CC0-1.0"
name: str = ""

creationInfo: SPDXCreationInfo
packages: list[SPDXPackage] = []

@pydantic.field_validator("packages")
def _unique_packages(cls, packages: list[SPDXPackage]) -> list[SPDXPackage]:
"""Sort and de-duplicate components."""
return unique_sorted_multikey(packages, by=lambda package: package.key())
38 changes: 37 additions & 1 deletion cachi2/core/models/validators.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
from pathlib import Path
from typing import Any, Callable, Iterable, TypeVar
from typing import Any, Callable, Iterable, List, TypeVar

T = TypeVar("T")

Expand All @@ -24,6 +24,31 @@ def unique(items: Iterable[T], by: Callable[[T], Any], dedupe: bool = True) -> l
return list(by_key.values())


def unique_multikey(items: Iterable[T], by: Callable[[T], List[Any]]) -> list[T]:
"""Make sure input items are unique by the specified key.
The 'by' function must return a hashable key (the uniqueness key).
If item A and item B have the same key, then
if dedupe is true (the default) and A == B, B is discarded
if dedupe is false or A != B, raise an error
"""
by_key: dict[tuple[str, ...], Any] = {}
for item in items:
multi_key = by(item)
found = False
for mkey in by_key:
for key in mkey:
if key in multi_key:
found = True
break
if found:
break
else:
by_key[tuple(multi_key)] = item
return list(by_key.values())


def unique_sorted(items: Iterable[T], by: Callable[[T], Any], dedupe: bool = True) -> list[T]:
"""Make sure input items are unique and sort them.
Expand All @@ -34,6 +59,17 @@ def unique_sorted(items: Iterable[T], by: Callable[[T], Any], dedupe: bool = Tru
return unique_items


def unique_sorted_multikey(items: Iterable[T], by: Callable[[T], Any]) -> list[T]:
"""Make sure input items are unique and sort them.
This version of unique_sorted works with items where keys is composed of list of multiple values
where every value is considered as key itself. One item can then have more single keys.
"""
unique_items = unique_multikey(items, by)
unique_items.sort(key=by)
return unique_items


def check_sane_relpath(path: Path) -> Path:
"""Check that the path is relative and looks sane."""
if path.is_absolute():
Expand Down
21 changes: 19 additions & 2 deletions cachi2/interface/cli.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import enum
import functools
import importlib.metadata
import json
import logging
import shutil
import sys
from pathlib import Path
from typing import Any, Callable, Optional
from typing import Any, Callable, Optional, Union

import pydantic
import typer
Expand All @@ -15,6 +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, SPDXSbom
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 Down Expand Up @@ -62,6 +64,13 @@ def version_callback(value: bool) -> None:
raise typer.Exit()


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

cyclonedx = "cyclonedx"
spdx = "spdx"


@app.callback()
@handle_errors
def cachi2( # noqa: D103; docstring becomes part of --help message
Expand Down Expand Up @@ -158,6 +167,11 @@ def fetch_deps(
"already have a vendor/ directory (will fail if changes would be made)."
),
),
sbom_output_type: SBOMFormat = typer.Option(
SBOMFormat.cyclonedx,
"--sbom-type",
help=("Format of generated SBOM. Default is CycloneDX"),
),
) -> None:
"""Fetch dependencies for supported package managers.
Expand Down Expand Up @@ -263,7 +277,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_output_type == SBOMFormat.cyclonedx:
sbom: Union[Sbom, SPDXSbom] = request_output.generate_sbom()
else:
sbom = request_output.generate_sbom().to_spdx()
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
Loading

0 comments on commit 5e78923

Please sign in to comment.