# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
from __future__ import annotations
import io
import logging
import re
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any
from boltons.ecoutils import get_profile
from cyclonedx.model import (
ExternalReference,
ExternalReferenceType,
XsUri,
)
from cyclonedx.model.bom import Bom
from cyclonedx.model.component import Component, ComponentType
from cyclonedx.model.contact import OrganizationalEntity
from cyclonedx.output import make_outputter
from cyclonedx.output.json import JsonV1Dot5
from cyclonedx.schema import OutputFormat, SchemaVersion
from cyclonedx.validation import BaseSchemabasedValidator, make_schemabased_validator
from cyclonedx.validation.json import JsonStrictValidator
from extra_platforms import current_os
from packageurl import PackageURL
from spdx_tools.spdx.model import (
Actor,
ActorType,
CreationInfo,
Document,
ExternalPackageRef,
ExternalPackageRefCategory,
PackagePurpose,
Relationship,
RelationshipType,
)
from spdx_tools.spdx.model import Package as SPDXPackage
from spdx_tools.spdx.validation.document_validator import validate_full_spdx_document
from spdx_tools.spdx.writer.json import json_writer
from spdx_tools.spdx.writer.rdf import rdf_writer
from spdx_tools.spdx.writer.tagvalue import tagvalue_writer
from spdx_tools.spdx.writer.write_utils import convert
from spdx_tools.spdx.writer.xml import xml_writer
from spdx_tools.spdx.writer.yaml import yaml_writer
from meta_package_manager.base import Package, PackageManager
from . import __version__
[docs]
class SBOM:
"""Utilities shared by all SBOM classes."""
def __init__(self, export_format: ExportFormat = ExportFormat.JSON) -> None:
"""Defaults to JSON export format."""
logging.debug(f"Set export format to {export_format}")
self.export_format = export_format
[docs]
class SPDX(SBOM):
"""Generates an SPDX document from a list of packages.
`SPDX 2.3 specifications <https://spdx.github.io/spdx-spec/v2.3/>`_.
"""
DOC_ID = "SPDXRef-DOCUMENT"
"""Document root ID."""
document: Document
[docs]
@classmethod
def normalize_spdx_id(cls, str: str) -> str:
"""SPDX IDs must only contain letters, numbers, ``.`` and ``-``."""
return "-".join((s for s in re.split(r"[^a-zA-Z0-9\.]", str) if s))
[docs]
def init_doc(self) -> None:
"""
`SPDX document metadata specifications
<https://spdx.github.io/spdx-spec/v2.3/document-creation-information/>`_.
"""
profile = get_profile()
system_id = self.normalize_spdx_id(
"-".join((
current_os().name,
profile["linux_dist_name"],
profile["linux_dist_version"],
profile["uname"]["system"],
profile["uname"]["release"],
profile["uname"]["machine"],
))
)
self.document = Document(
CreationInfo(
spdx_version="SPDX-2.3",
spdx_id=self.DOC_ID,
# Because mpm is a system-wide tool, we chose to name the document
# after the host operating system platform it was run on.
name=system_id,
# Point directly to the mpm release on GitHub so we can get some
# additional meaning from an URI that is not supposed to have any
# meaning. We add a trailing "/<random_unique_id>" to the URI as the
# namespace is supposed to be unique for each document. See:
# https://spdx.github.io/spdx-spec/v2.3/document-creation-information/#65-spdx-document-namespace-field
document_namespace=(
"https://github.com/kdeldycke/meta-package-manager/releases/tag/"
f"v{__version__}/{profile['guid']}"
),
creators=[Actor(ActorType.TOOL, f"meta-package-manager-{__version__}")],
created=datetime.now(),
data_license="CC0-1.0",
)
)
[docs]
def add_package(self, manager: PackageManager, package: Package) -> None:
"""
`SPDX package metadata specifications
<https://spdx.github.io/spdx-spec/v2.3/package-information/>`_.
"""
# pURL string, by its virtue of containing all important metadata of a package,
# makes perfect unique IDs.
package_docid = self.normalize_spdx_id(f"SPDXRef-{package.purl}")
self.document.packages.append(
SPDXPackage(
name=package.id,
spdx_id=package_docid,
version=str(package.installed_version),
supplier=Actor(ActorType.ORGANIZATION, manager.name),
# TODO: Use real URL.
download_location="https://www.example.com",
# Current SPDX export is only compiling metadata about packages and their
# dependencies. So we do not analyze files, as specified in:
# https://spdx.github.io/spdx-spec/v2.3/package-information/#782-intent
files_analyzed=False,
summary=package.name,
description=package.description,
external_references=[
ExternalPackageRef(
ExternalPackageRefCategory.PACKAGE_MANAGER,
"purl",
package.purl.to_string(),
)
],
primary_package_purpose=PackagePurpose.INSTALL,
)
)
# A DESCRIBES relationship asserts that the document indeed describes the
# package.
self.document.relationships.append(
Relationship(self.DOC_ID, RelationshipType.DESCRIBES, package_docid)
)
[docs]
def export(self) -> str:
"""Similar to ``spdx_tools.spdx.writer.write_anything.write_file`` but write
directly to provided stream instead of file path.
"""
stream = io.StringIO()
writer: Any
if self.export_format == ExportFormat.JSON:
writer = json_writer
elif self.export_format == ExportFormat.XML:
writer = xml_writer
elif self.export_format == ExportFormat.YAML:
writer = yaml_writer
elif self.export_format == ExportFormat.TAG_VALUE:
writer = tagvalue_writer
elif self.export_format == ExportFormat.RDF_XML:
writer = rdf_writer
# RDF writer expects a binary-mode IO stream.
stream = io.BytesIO() # type: ignore[assignment]
else:
raise ValueError(f"{self.export_format} not supported.")
logging.debug("Validate document...")
errors = validate_full_spdx_document(self.document)
if errors:
document_dict = convert(self.document, None) # type: ignore[arg-type]
logging.debug(document_dict)
raise ValueError(f"Document is not valid. Errors: {errors}")
logging.debug(f"Export with {writer.__name__}")
writer.write_document_to_stream(self.document, stream, validate=False)
return stream.getvalue()
[docs]
class CycloneDX(SBOM):
"""Generates a CycloneDX document from a list of packages.
`CycloneDX 1.5 specifications <https://cyclonedx.org/docs/1.5>`_.
"""
document: Bom
[docs]
def init_doc(self) -> None:
"""
`CycloneDX document metadata specifications
<https://cyclonedx.org/docs/1.5/json/#metadata/>`_.
"""
gh_url = "https://github.com/kdeldycke/meta-package-manager"
doc_url = "https://kdeldycke.github.io/meta-package-manager"
self.document = Bom()
# XXX lifecycles not supported yet:
# https://github.com/CycloneDX/cyclonedx-python-lib/issues/578
# self.document.metadata.lifecycles = [{"phase": "operations"}]
self.document.metadata.component = Component(
name="meta-package-manager",
type=ComponentType.APPLICATION,
bom_ref=f"meta-package-manager@{__version__}",
supplier=OrganizationalEntity(
name="Meta Package Manager",
urls=[XsUri(gh_url)],
),
version=__version__,
purl=PackageURL(
type="pypi", name="meta-package-manager", version=__version__
),
external_references=[
ExternalReference(
type=ExternalReferenceType.ADVISORIES,
url=XsUri(f"{gh_url}/security"),
),
ExternalReference(
type=ExternalReferenceType.BUILD_META,
url=XsUri(f"{gh_url}/blob/v{__version__}/uv.lock"),
),
ExternalReference(
type=ExternalReferenceType.BUILD_SYSTEM,
url=XsUri(f"{gh_url}/actions"),
),
ExternalReference(
type=ExternalReferenceType.CONFIGURATION,
url=XsUri(f"{doc_url}/configuration.html"),
),
ExternalReference(
type=ExternalReferenceType.DISTRIBUTION,
url=XsUri("https://pypi.org/project/meta-package-manager"),
),
ExternalReference(
type=ExternalReferenceType.DISTRIBUTION_INTAKE,
url=XsUri(f"{gh_url}/releases/tag/v{__version__}"),
),
ExternalReference(
type=ExternalReferenceType.DOCUMENTATION,
url=XsUri(doc_url),
),
ExternalReference(
type=ExternalReferenceType.ISSUE_TRACKER,
url=XsUri(f"{gh_url}/issues"),
),
ExternalReference(
type=ExternalReferenceType.LICENSE,
url=XsUri(f"{gh_url}/blob/main/license"),
),
ExternalReference(
type=ExternalReferenceType.MATURITY_REPORT,
url=XsUri(f"{gh_url}/pulse"),
),
ExternalReference(
type=ExternalReferenceType.RELEASE_NOTES,
url=XsUri(f"{doc_url}/changelog.html"),
),
ExternalReference(
type=ExternalReferenceType.SOURCE_DISTRIBUTION,
url=XsUri(
f"{gh_url}/releases/download/v{__version__}"
f"/meta_package_manager-{__version__}.tar.gz"
),
),
ExternalReference(
type=ExternalReferenceType.VCS,
url=XsUri(gh_url),
),
ExternalReference(
type=ExternalReferenceType.WEBSITE,
url=XsUri(gh_url),
),
ExternalReference(
type=ExternalReferenceType.OTHER,
url=XsUri("https://github.com/sponsors/kdeldycke"),
comment="Funding",
),
],
)
[docs]
def add_package(self, manager: PackageManager, package: Package) -> None:
"""
`CycloneDX package metadata specifications
<https://cyclonedx.org/docs/1.5/json/#components>`_.
"""
data = Component(
name=package.id,
type=ComponentType.APPLICATION,
# pURL string, by its virtue of containing all important metadata of a
# package, makes perfect unique IDs.
bom_ref=package.purl.to_string(),
group=package.manager_id,
version=str(package.installed_version),
description=package.description,
purl=package.purl,
)
self.document.components.add(data)
self.document.register_dependency(
self.document.metadata.component, # type:ignore[arg-type]
[data],
)
[docs]
def export(self) -> str:
validator: BaseSchemabasedValidator
if self.export_format == ExportFormat.JSON:
content = JsonV1Dot5(self.document).output_as_string(indent=2)
validator = JsonStrictValidator(SchemaVersion.V1_5)
elif self.export_format == ExportFormat.XML:
writer = make_outputter(self.document, OutputFormat.XML, SchemaVersion.V1_6)
content = writer.output_as_string(indent=2)
validator = make_schemabased_validator(
writer.output_format, writer.schema_version
)
else:
raise ValueError(f"{self.export_format} not supported.")
logging.debug("Validate document...")
errors = validator.validate_str(content)
if errors:
logging.debug(content)
raise ValueError(f"Document is not valid. Errors: {errors}")
return content