# 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]
@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]
@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_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]
@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_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