# 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
{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 {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 {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 {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.
"""