# 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.
"""Format-agnostic SBOM base class and export-format enum.
Kept deliberately free of SPDX or CycloneDX dependencies: instantiating
:py:class:`SBOM` directly is meaningless, but importing the symbols here
is safe even when the optional ``[sbom-offline]`` extra is not installed.
"""
from __future__ import annotations
import logging
import sys
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from backports.strenum import StrEnum # type: ignore[import-not-found]
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
from ..package import PackageMetadata
from .vulnerabilities import Vulnerability
[docs]
class SBOM:
"""Utilities shared by all SBOM classes.
.. seealso::
Anchore's `Syft <https://github.com/anchore/syft>`_ and Microsoft's
`sbom-tool <https://github.com/microsoft/sbom-tool>`_ are mature SPDX
and CycloneDX emitters, useful references for field-population
conventions. Both inventory packages by parsing on-disk databases and
lockfiles, whereas ``mpm`` queries the live managers directly.
"""
def __init__(
self,
export_format: ExportFormat = ExportFormat.JSON, # type: ignore[assignment]
) -> None:
"""Defaults to JSON export format."""
logging.debug(f"Set export format to {export_format}")
self.export_format = export_format
# ``manager_id -> count`` of unique packages the renderer admitted
# into the document. Populated by :py:meth:`_track_addition` so
# subclasses' format-specific dedup is reflected here.
self.packages_per_manager: dict[str, int] = {}
# ``manager_id -> count`` of admitted packages whose metadata was
# non-empty (i.e. the manager's extractor produced something).
self.enriched_per_manager: dict[str, int] = {}
# Keys used to dedup ``_track_addition`` calls across subclasses
# that may invoke it more than once per (manager, package) pair.
self._tracked_additions: set[tuple[str, str]] = set()
# ``purl string -> vulnerabilities`` attached post-hoc by the
# network layer (``mpm --network sbom``). Distinct from
# PackageMetadata, which the local extractor produces: this is
# data fetched after the fact from OSV and bound to the document
# via :py:meth:`attach_vulnerabilities`. Renderers consume it in
# their ``finalize`` override.
self.vulnerabilities_by_purl: dict[str, tuple[Vulnerability, ...]] = {}
[docs]
def all_purls(self) -> Iterator[str]:
"""Yield every package purl present in the document.
Powers the vulnerability scan: the network layer queries OSV once
with the full purl set rather than once per package. Subclasses
implement this against their own component index.
"""
raise NotImplementedError
[docs]
def attach_vulnerabilities(
self,
vulnerabilities: dict[str, tuple[Vulnerability, ...]],
) -> None:
"""Bind cross-package vulnerability data to the document.
Called by the CLI between the per-package ``add_package`` loop and
``finalize``, only in ``--network`` mode. Renderers read the
stored data in their ``finalize`` override and project it into the
format-native vulnerability surface (CycloneDX ``vulnerabilities``
array, SPDX security ``externalRefs``).
"""
self.vulnerabilities_by_purl.update(vulnerabilities)
def _track_addition(
self,
manager_id: str,
package_id: str,
metadata: PackageMetadata | None,
) -> None:
"""Record that one package entered the document.
Called by :py:meth:`add_package` subclass implementations after
their own dedup check so the renderer-level counters reflect
what actually got serialized, not the number of inbound calls.
Idempotent on ``(manager_id, package_id)`` to stay robust against
future refactors that might double-call.
"""
key = (manager_id, package_id)
if key in self._tracked_additions:
return
self._tracked_additions.add(key)
self.packages_per_manager[manager_id] = (
self.packages_per_manager.get(manager_id, 0) + 1
)
if metadata is not None and not metadata.is_empty():
self.enriched_per_manager[manager_id] = (
self.enriched_per_manager.get(manager_id, 0) + 1
)
[docs]
def stats(self) -> dict[str, object]:
"""Return a summary of what landed in the document.
Format-agnostic counters live in the base implementation; SPDX and
CycloneDX subclasses extend the returned dict with their own
merged-documents, dependency-graph, and any other format-specific
counts. Surfaced by the CLI as a post-run INFO-level summary and
usable by tests or programmatic consumers without re-parsing the
rendered document.
"""
# Count unique advisories and the packages they affect. The same
# advisory can affect several packages, so the vulnerability total
# is over distinct ids, not over the per-purl lists.
affected_purls = [p for p, v in self.vulnerabilities_by_purl.items() if v]
unique_vuln_ids = {
vuln.id for vulns in self.vulnerabilities_by_purl.values() for vuln in vulns
}
return {
"packages_total": sum(self.packages_per_manager.values()),
"packages_per_manager": dict(self.packages_per_manager),
"enriched_per_manager": dict(self.enriched_per_manager),
"vulnerabilities_total": len(unique_vuln_ids),
"vulnerable_packages": len(affected_purls),
}
[docs]
def finalize(self) -> None:
"""Resolve any deferred state before ``export()``.
Some constructs cannot be emitted at ``add_package()`` time
because they reference packages that may not have been added yet:
a Homebrew formula's runtime dependency on another formula listed
later in the scan, for example. Subclasses queue those during
``add_package`` and flush them here. The base implementation is a
no-op so subclasses can rely on it being called exactly once.
"""