Source code for tests.test_cli_sbom

# Copyright Kevin Deldycke <[email protected]> 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.
"""`mpm sbom` CLI tests.

Drives the subcommand end to end: format resolution, per-standard exports to
console and file, the `--query` filter and `--minimal` mode. The SPDX and
CycloneDX renderers it delegates to are unit-tested in {mod}`tests.test_sbom`.
"""

from __future__ import annotations

import json
import re
import subprocess
import sys
from functools import partial
from pathlib import Path
from textwrap import dedent
from xml.etree import ElementTree

import pytest
from yaml import Loader, load

# Every test here invokes `mpm sbom`, which needs both writer stacks. The skip
# used to arrive by accident, inherited from importing `.test_sbom` below, and
# would have vanished the day that import did. State it here instead.
pytest.importorskip("cyclonedx")
pytest.importorskip("spdx_tools")

from meta_package_manager.sbom.cyclonedx import cyclonedx_support
from meta_package_manager.sbom.spdx import spdx_support

if not (spdx_support and cyclonedx_support):
    pytest.skip("SBOM writer stack not fully importable", allow_module_level=True)

from packageurl import PackageURL

from meta_package_manager.capabilities import Operations
from meta_package_manager.package import PackageMetadata
from meta_package_manager.sbom import _network
from meta_package_manager.sbom.base import ExportFormat

from .fake_manager import FakeManager
from .test_cli import check_manager_selection
from .test_sbom import assert_valid_cyclonedx

PROXY_VARS = (
    "ALL_PROXY",
    "HTTPS_PROXY",
    "HTTP_PROXY",
    "all_proxy",
    "http_proxy",
    "https_proxy",
)
"""Proxy variables `httpx` reads, cleared so a host proxy never reaches a test."""

OSV_RECORD = {
    "id": "GHSA-aaaa-bbbb-cccc",
    "summary": "Cross-site scripting in Example",
    "modified": "2021-06-01T00:00:00Z",
}
"""The fields of the advisory {mod}`tests.test_sbom_vulnerabilities` uses that a
CycloneDX vulnerability entry needs."""


[docs] class AliasedFakeManager(FakeManager): """A fake recording a PyPI coordinate for each of its packages. Homebrew does the same for the upstream registry a formula builds from: the coordinate is an alias the vulnerability scan queries in place of the manager's own purl, which no advisory database knows. """
[docs] def package_metadata_batch(self, packages): for package in packages: yield ( package, PackageMetadata( extra_purls=( PackageURL( type="pypi", name=package.id, version=str(package.installed_version), ).to_string(), ), ), )
[docs] class UnreadableMetadataFakeManager(FakeManager): """A fake whose metadata extractor fails."""
[docs] def package_metadata_batch(self, packages): raise RuntimeError("Metadata index is unreadable.")
[docs] @pytest.fixture def osv(monkeypatch, tmp_path): """Mock the OSV API, with no host proxy and the client cache in `tmp_path`.""" respx = pytest.importorskip("respx") for var in PROXY_VARS: monkeypatch.delenv(var, raising=False) monkeypatch.setattr(_network, "get_cache_dir", lambda app_name: tmp_path / app_name) with respx.mock(base_url="https://api.osv.dev") as mock: yield mock
[docs] @pytest.fixture def subcmd(): # `DEBUG` level is required so that `check_manager_selection` can detect # skip/does-not-implement signals for implicitly selected managers. Those # messages are demoted to DEBUG when no explicit `--<id>` flag is passed # (to avoid flooding the output). INFO messages are a subset of DEBUG, so # everything logged at INFO still appears. return "--verbosity", "DEBUG", "sbom"
[docs] def evaluate_signals(mid, stdout, stderr): yield from ( # The glued `:<mid>:` label form matches whatever level the # message lands at: demoted to DEBUG for implicit selection, # WARNING/INFO for explicit ones (`mpm --<mid> sbom`). f":{mid}.installed: Export installed packages." in stderr, f":{mid}: Does not implement {Operations.installed}" in stderr, f":{mid}: Skipped:" in stderr, f":{mid}.installed: Could not list installed packages." in stderr, )
check_selection = partial(check_manager_selection, signals=evaluate_signals) """Selection assertions reading this subcommand's own signals."""
[docs] def test_default_spdx_json_output_to_console(invoke, subcmd): # `--verbosity DEBUG` makes the per-manager skip/does-not-implement # messages reach stderr: at default verbosity they stay quiet because # this invocation makes no explicit `--<id>` selection. result = invoke("--verbosity", "DEBUG", subcmd) assert result.exit_code == 0 assert "Print SPDX export to <stdout>" in result.stderr check_selection(result) json_content = json.loads(result.stdout) assert json_content assert json_content["spdxVersion"] == "SPDX-2.3"
[docs] def test_unrecognized_extension_without_format(invoke, subcmd): """A target path with no recognized extension fails early. Before any package collection happens, with an actionable message pointing at `--format`. """ result = invoke(subcmd, "help") assert result.exit_code == 2 assert "Cannot guess export format from 'help'" in result.stderr assert "Use --format to pick one of: json, xml, yaml, tag, rdf." in result.stderr # The collection loop must not have run. assert "Export installed packages." not in result.stderr
[docs] def test_unrecognized_extension_with_explicit_format( invoke, subcmd, fake_pool, tmp_path ): """An explicit `--format` overrides extension auto-detection. Even when the filename carries no recognizable suffix. The subject is the filename, so the inventory behind it is the fake one: collecting the host's real packages to prove a suffix was overridden costs the better part of a minute and answers nothing extra. """ target = tmp_path / "help" result = invoke(subcmd, "--format", "json", str(target)) assert result.exit_code == 0 json_content = json.loads(target.read_text(encoding="utf-8")) assert json_content["spdxVersion"] == "SPDX-2.3"
[docs] @pytest.mark.parametrize("export_format", (None, *ExportFormat)) @pytest.mark.parametrize("standard_name", ("SPDX", "CycloneDX")) def test_output_to_file(invoke, subcmd, fake_pool, export_format, standard_name): """Every standard and format pair renders and lands where it was asked to. Runs on the fake pool: twelve cases serializing one inventory differ in the writer, not in the packages, and re-collecting the host's real ones per case made this the most expensive block left in the suite (up to eighty-six seconds a case). Selection is asserted by the live-inventory test above, once, as it is shared by every subcommand. """ # Let the CLI auto-detect the format. `--verbosity DEBUG` keeps the # per-manager export lines in stderr, which the assertions below read. file_name = f"export.{export_format.value}" if export_format else "-" result = invoke( "--verbosity", "DEBUG", subcmd, f"--{standard_name.lower()}", file_name ) if standard_name == "CycloneDX" and export_format not in ( ExportFormat.JSON, ExportFormat.XML, None, ): assert result.exit_code == 2 assert f"CycloneDX does not support {export_format}" in result.stderr assert not result.stdout else: if not export_format: assert f"Print {standard_name} export to <stdout>" in result.stderr assert result.stdout content = result.stdout else: assert re.search( rf"Export installed packages in {standard_name} to \S*{file_name}", result.stderr, ) assert not result.stdout content = Path(file_name).read_text(encoding="utf-8") assert result.exit_code == 0 if standard_name == "CycloneDX" and export_format in ( ExportFormat.JSON, ExportFormat.XML, ): assert_valid_cyclonedx(content, export_format) if export_format == ExportFormat.JSON: json_content = json.loads(content) assert json_content if standard_name == "SPDX": assert json_content["spdxVersion"] == "SPDX-2.3" else: assert json_content["bomFormat"] == "CycloneDX" elif export_format == ExportFormat.XML: xml_content = ElementTree.fromstring(content) if standard_name == "SPDX": spdx_elem = xml_content.find("spdxVersion") assert spdx_elem is not None assert spdx_elem.text == "SPDX-2.3" else: assert xml_content.tag == "{http://cyclonedx.org/schema/bom/1.7}bom" elif export_format == ExportFormat.YAML: yaml_content = load(content, Loader=Loader) assert yaml_content assert yaml_content["spdxVersion"] == "SPDX-2.3" elif export_format == ExportFormat.TAG_VALUE: assert content.splitlines()[:2] == [ "## Document Information", "SPDXVersion: SPDX-2.3", ] elif export_format == ExportFormat.RDF_XML: rdf_elem = ElementTree.fromstring(content).find( "spdx:SpdxDocument/spdx:specVersion", {"spdx": "http://spdx.org/rdf/terms#"}, ) assert rdf_elem is not None assert rdf_elem.text == "SPDX-2.3"
[docs] @pytest.mark.parametrize( ("args", "listed"), ( # A query keeps only the installed packages matching it. pytest.param(("--query", "alpha"), {"fake-pkg-alpha"}, id="query"), # Without a query, the whole installed inventory is kept. pytest.param((), {"fake-pkg-alpha", "fake-pkg-beta"}, id="no-query"), ), ) def test_sbom_query_filter(invoke, fake_pool, args, listed): result = invoke("sbom", *args, "--minimal") assert result.exit_code == 0 for package_id in ("fake-pkg-alpha", "fake-pkg-beta"): assert (package_id in result.stdout) is (package_id in listed)
[docs] def test_minimal_mode_skips_metadata_extractor(invoke): """`--minimal` avoids the rich-metadata code path. So the export matches the historical bare shape, even for managers that implement an extractor (Homebrew, pip). """ result = invoke("sbom", "--minimal") assert result.exit_code == 0 if not result.stdout.lstrip().startswith("{"): return doc = json.loads(result.stdout) for package in doc.get("packages", []): assert package["downloadLocation"] == "NOASSERTION" assert "licenseDeclared" not in package assert "checksums" not in package
[docs] def test_cli_import_defers_sbom_libraries(): """Importing the CLI must not pull the SBOM writer libraries. `cli.py` imports every subcommand module up front to register it, so a module-level import of the writers in `cli_sbom` would spend their (heavy) third-party import cost on every `mpm` invocation, for a dependency only `mpm sbom` ever uses. The imports therefore live inside the subcommand body, the one place in the codebase deliberately breaking the imports-at-module-level rule, and this test is what keeps them from drifting back up. packageurl-python is checked too: it comes with the `[sbom-offline]` extra, which the default install does not carry. Runs in a fresh interpreter: the test session has long since imported everything, so `sys.modules` here proves nothing. """ probe = dedent(""" import sys import meta_package_manager.cli # noqa: F401 heavy = sorted( name for name in sys.modules if name.split(".", 1)[0] in {"spdx_tools", "cyclonedx", "packageurl"} ) print(",".join(heavy)) """) result = subprocess.run( (sys.executable, "-c", probe), capture_output=True, text=True, encoding="UTF-8", check=True, ) assert result.stdout.strip() == "", ( f"importing meta_package_manager.cli pulled in: {result.stdout.strip()}" )
[docs] def test_sbom_command_loads_the_deferred_libraries(invoke, fake_pool): """The other half of the contract: deferring them must not break the export.""" result = invoke("sbom", "--minimal") assert result.exit_code == 0 assert result.stdout.strip()
[docs] def test_overwrite_is_ignored_on_stdout(invoke, fake_pool): result = invoke("--verbosity", "INFO", "sbom", "--minimal", "--overwrite") assert result.exit_code == 0 assert "Ignore the --overwrite/--force/--replace option." in result.stderr assert "fake-pkg-alpha" in result.stdout
[docs] def test_format_must_match_the_extension(invoke, fake_pool): result = invoke("sbom", "--format", "xml", "export.json") assert result.exit_code == 2 assert "Selected xml does not match file extension." in result.stderr assert not Path("export.json").exists()
[docs] @pytest.mark.parametrize( ("standard", "flag", "support"), ( ("SPDX", "--spdx", "meta_package_manager.sbom.spdx.spdx_support"), ( "CycloneDX", "--cyclonedx", "meta_package_manager.sbom.cyclonedx.cyclonedx_support", ), ), ) def test_missing_writer_stack_is_a_usage_error( invoke, fake_pool, monkeypatch, standard, flag, support ): """A standard whose writer library failed to import stops the export.""" monkeypatch.setattr(support, False) result = invoke("sbom", flag) assert result.exit_code == 2 assert f"{standard} SBOM generation is unavailable." in result.stderr assert not result.stdout
[docs] def test_metadata_failure_falls_back_to_minimal_data(invoke, patch_pool_with): """A failing metadata extractor costs the enrichment, not the export.""" patch_pool_with(UnreadableMetadataFakeManager()) result = invoke("--verbosity", "INFO", "sbom") assert result.exit_code == 0 assert "Falling back to minimal SBOM data: Metadata index is unreadable." in ( result.stderr ) assert "fake-pkg-alpha" in result.stdout
[docs] def test_network_attaches_the_advisories_of_an_alias(invoke, patch_pool_with, osv): """`--network` queries OSV with the PyPI alias of each package. It attaches the advisory reported for an alias to the package behind it. """ patch_pool_with(AliasedFakeManager()) batch = osv.post("/v1/querybatch").respond( json={"results": [{"vulns": [{"id": "GHSA-aaaa-bbbb-cccc"}]}, {}]}, ) osv.get("/v1/vulns/GHSA-aaaa-bbbb-cccc").respond(json=OSV_RECORD) result = invoke("--network", "sbom", "--cyclonedx") assert result.exit_code == 0 sent = json.loads(batch.calls[0].request.content) assert [query["package"] for query in sent["queries"]] == [ {"ecosystem": "PyPI", "name": "fake-pkg-alpha"}, {"ecosystem": "PyPI", "name": "fake-pkg-beta"}, ] (vulnerability,) = json.loads(result.stdout)["vulnerabilities"] assert vulnerability["id"] == "GHSA-aaaa-bbbb-cccc" assert [affected["ref"] for affected in vulnerability["affects"]] == [ "pkg:aliasedfakemanager/[email protected]", ] assert "1 vulnerabilities found across 1 packages." in result.stderr
[docs] def test_network_failure_keeps_the_export(invoke, patch_pool_with, osv): """An OSV failure drops the vulnerability data, and the export goes on.""" patch_pool_with(AliasedFakeManager()) osv.post("/v1/querybatch").respond(status_code=400) result = invoke("--network", "sbom", "--cyclonedx") assert result.exit_code == 0 assert "Vulnerability scan failed (" in result.stderr assert "the SBOM will not include vulnerability data" in result.stderr document = json.loads(result.stdout) assert not document.get("vulnerabilities") assert "fake-pkg-alpha" in result.stdout
[docs] def test_network_scan_needs_the_online_extra(invoke, fake_pool, monkeypatch): """Without `httpx`, `--network` warns and exports the SBOM without the scan.""" monkeypatch.setattr(_network, "network_support", False) result = invoke("--network", "sbom", "--minimal") assert result.exit_code == 0 assert ( "Vulnerability scan skipped: the --network option needs the [sbom-online] " "extra." ) in result.stderr assert "fake-pkg-alpha" in result.stdout