Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions tools/sbom-diff-and-risk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,11 @@ sbom-diff-risk compare \
- `--format auto|cyclonedx-json|spdx-json|requirements-txt|pyproject-toml`
- `--before-format cyclonedx-json|spdx-json|requirements-txt|pyproject-toml`
- `--after-format cyclonedx-json|spdx-json|requirements-txt|pyproject-toml`
- `--pyproject-group name`
- `--out-json path`
- `--out-md path`
- `--out-sarif path`
- `--pyproject-group name`
- `--out-json path`
- `--summary-json path`
- `--out-md path`
- `--out-sarif path`
- `--policy path`
- `--fail-on rule[,rule...]`
- `--warn-on rule[,rule...]`
Expand All @@ -170,9 +171,11 @@ sbom-diff-risk compare \
- `--scorecard-timeout seconds`
- `--source-allowlist pypi.org,files.pythonhosted.org,github.com`

Offline mode remains the default. No network access occurs unless `--enrich-pypi` or `--enrich-scorecard` is set explicitly.

## Dependency Provenance Analysis (Opt-in)
Offline mode remains the default. No network access occurs unless `--enrich-pypi` or `--enrich-scorecard` is set explicitly.

`--summary-json PATH` writes only the stable `report.json["summary"]` object for compact machine consumption. It uses the same summary schema as the full JSON report.

## Dependency Provenance Analysis (Opt-in)

This section is about analyzing third-party package provenance signals. It is not about verifying the `sbom-diff-and-risk` tool's own release artifacts.

Expand Down
2 changes: 1 addition & 1 deletion tools/sbom-diff-and-risk/docs/report-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ When provenance policy fields are relevant, reports may also include `provenance

## Summary contract

`summary` is the stable, compact entry point for automation that needs counts without walking the full report.
`summary` is the stable, compact entry point for automation that needs counts without walking the full report. The `--summary-json PATH` CLI option writes only this stable `report.json["summary"]` object.

Base `summary` fields:

Expand Down
10 changes: 7 additions & 3 deletions tools/sbom-diff-and-risk/src/sbom_diff_risk/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .policy_evaluator import evaluate_policy
from .policy_parser import build_policy
from .presentation import effective_policy_evaluation, summarize_violations_by_rule
from .report_json import render_report_json
from .report_json import render_report_json, render_summary_json
from .report_md import render_report_markdown
from .report_sarif import render_report_sarif_output
from .risk import evaluate_risks, summarize_risks
Expand Down Expand Up @@ -59,6 +59,7 @@ def build_parser() -> argparse.ArgumentParser:
help="Select a PEP 735 [dependency-groups] group when a compared input is pyproject.toml.",
)
compare.add_argument("--out-json", type=Path, default=None, help="Write a JSON report to this path.")
compare.add_argument("--summary-json", type=Path, default=None, help="Write the stable JSON summary object to this path.")
compare.add_argument("--out-md", type=Path, default=None, help="Write a Markdown report to this path.")
compare.add_argument(
"--out-sarif",
Expand Down Expand Up @@ -137,8 +138,9 @@ def run_compare(args: argparse.Namespace) -> int:
pypi_timeout = getattr(args, "pypi_timeout", DEFAULT_PYPI_TIMEOUT_SECONDS)
scorecard_timeout = getattr(args, "scorecard_timeout", DEFAULT_SCORECARD_TIMEOUT_SECONDS)

if args.out_json is None and args.out_md is None and args.out_sarif is None:
raise ValueError("at least one of --out-json, --out-md, or --out-sarif must be provided")
summary_json = getattr(args, "summary_json", None)
if args.out_json is None and summary_json is None and args.out_md is None and args.out_sarif is None:
raise ValueError("at least one of --out-json, --summary-json, --out-md, or --out-sarif must be provided")
if pypi_timeout <= 0:
raise ValueError("--pypi-timeout must be a positive number of seconds.")
if scorecard_timeout <= 0:
Expand Down Expand Up @@ -228,6 +230,8 @@ def run_compare(args: argparse.Namespace) -> int:

if args.out_json is not None:
_write_text(args.out_json, render_report_json(report))
if summary_json is not None:
_write_text(summary_json, render_summary_json(report))
if args.out_md is not None:
_write_text(args.out_md, render_report_markdown(report))
if args.out_sarif is not None:
Expand Down
4 changes: 4 additions & 0 deletions tools/sbom-diff-and-risk/src/sbom_diff_risk/report_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ def render_report_json(report: CompareReport) -> str:
return json.dumps(payload, indent=2) + "\n"


def render_summary_json(report: CompareReport) -> str:
return json.dumps(_summary_to_dict(report), indent=2) + "\n"


def _summary_to_dict(report: CompareReport) -> dict[str, object]:
summary: dict[str, object] = {
"added": report.summary.added,
Expand Down
1 change: 1 addition & 0 deletions tools/sbom-diff-and-risk/tests/test_cli_exit_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def test_cli_compare_help_mentions_policy_flags_and_exit_codes() -> None:

assert result.returncode == 0
assert "--out-sarif" in result.stdout
assert "--summary-json" in result.stdout
assert "--pyproject-group" in result.stdout
assert "--policy" in result.stdout
assert "--fail-on" in result.stdout
Expand Down
246 changes: 246 additions & 0 deletions tools/sbom-diff-and-risk/tests/test_cli_summary_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
from __future__ import annotations

import json
from pathlib import Path

from sbom_diff_risk import cli
from sbom_diff_risk.models import ReportEnrichmentMetadata


def test_cli_summary_json_writes_summary_only_file(tmp_path: Path) -> None:
project_root = Path(__file__).resolve().parents[1]
summary_path = tmp_path / "summary.json"

exit_code = cli.main(
[
"compare",
"--before",
str(project_root / "examples" / "cdx_before.json"),
"--after",
str(project_root / "examples" / "cdx_after.json"),
"--summary-json",
str(summary_path),
]
)

payload = json.loads(summary_path.read_text(encoding="utf-8"))

assert exit_code == 0
assert payload == {
"added": 1,
"removed": 0,
"changed": 1,
"risk_counts": {
"new_package": 1,
"major_upgrade": 0,
"version_change_unclassified": 1,
"unknown_license": 0,
"stale_package": 0,
"suspicious_source": 0,
"not_evaluated": 2,
},
}
assert "unchanged" not in payload
assert summary_path.read_text(encoding="utf-8").endswith("\n")


def test_cli_summary_json_matches_full_report_summary(tmp_path: Path) -> None:
project_root = Path(__file__).resolve().parents[1]
report_path = tmp_path / "report.json"
summary_path = tmp_path / "summary.json"

exit_code = cli.main(
[
"compare",
"--before",
str(project_root / "examples" / "cdx_before.json"),
"--after",
str(project_root / "examples" / "cdx_after.json"),
"--out-json",
str(report_path),
"--summary-json",
str(summary_path),
]
)

report_payload = json.loads(report_path.read_text(encoding="utf-8"))
summary_payload = json.loads(summary_path.read_text(encoding="utf-8"))

assert exit_code == 0
assert summary_payload == report_payload["summary"]


def test_cli_summary_json_includes_policy_summary_when_policy_is_used(tmp_path: Path) -> None:
project_root = Path(__file__).resolve().parents[1]
summary_path = tmp_path / "summary.json"

exit_code = cli.main(
[
"compare",
"--before",
str(project_root / "examples" / "cdx_before.json"),
"--after",
str(project_root / "examples" / "cdx_after.json"),
"--policy",
str(project_root / "examples" / "policy-minimal.yml"),
"--summary-json",
str(summary_path),
]
)

payload = json.loads(summary_path.read_text(encoding="utf-8"))

assert exit_code == 0
assert payload["policy"] == {
"status": "warn",
"blocking": 0,
"warning": 1,
"suppressed": 0,
}
assert "enrichment" not in payload


def test_cli_summary_json_includes_enrichment_summary_when_enrichment_is_used(
monkeypatch,
tmp_path: Path,
) -> None:
project_root = Path(__file__).resolve().parents[1]
summary_path = tmp_path / "summary.json"

class RecordingPyPIEnricher:
def __init__(self, *args, timeout_seconds: float, **kwargs) -> None: # noqa: ANN002, ANN003
self.timeout_seconds = timeout_seconds

def enrich_components(self, components): # noqa: ANN001
return components

def build_report_metadata(self) -> ReportEnrichmentMetadata:
return ReportEnrichmentMetadata(
mode="opt_in_pypi",
pypi_enabled=True,
pypi_timeout_seconds=self.timeout_seconds,
pypi_network_access_performed=False,
network_access_performed=False,
candidate_components=2,
supported_components=2,
status_counts={
"provenance_available": 1,
"attestation_unavailable": 1,
},
)

monkeypatch.setattr(cli, "PyPIProvenanceEnricher", RecordingPyPIEnricher)

exit_code = cli.main(
[
"compare",
"--before",
str(project_root / "examples" / "requirements_before.txt"),
"--after",
str(project_root / "examples" / "requirements_after.txt"),
"--enrich-pypi",
"--summary-json",
str(summary_path),
]
)

payload = json.loads(summary_path.read_text(encoding="utf-8"))

assert exit_code == 0
assert payload["enrichment"] == {
"status": "used",
"mode": "opt_in_pypi",
"pypi": {
"candidate_components": 2,
"supported_components": 2,
"status_counts": {
"attestation_unavailable": 1,
"provenance_available": 1,
},
},
}
assert "policy" not in payload


def test_cli_summary_json_includes_scorecard_enrichment_summary_when_scorecard_is_used(
monkeypatch,
tmp_path: Path,
) -> None:
project_root = Path(__file__).resolve().parents[1]
summary_path = tmp_path / "summary.json"

class RecordingScorecardEnricher:
def __init__(self, *args, timeout_seconds: float, **kwargs) -> None: # noqa: ANN002, ANN003
self.timeout_seconds = timeout_seconds

def enrich_components(self, components): # noqa: ANN001
return components

def build_report_metadata(self) -> ReportEnrichmentMetadata:
return ReportEnrichmentMetadata(
mode="opt_in_scorecard",
scorecard_enabled=True,
scorecard_timeout_seconds=self.timeout_seconds,
scorecard_network_access_performed=False,
network_access_performed=False,
scorecard_candidate_components=2,
scorecard_supported_components=1,
scorecard_status_counts={
"scorecard_available": 1,
"repository_unmapped": 1,
},
)

monkeypatch.setattr(cli, "ScorecardEnricher", RecordingScorecardEnricher)

exit_code = cli.main(
[
"compare",
"--before",
str(project_root / "examples" / "requirements_before.txt"),
"--after",
str(project_root / "examples" / "requirements_after.txt"),
"--enrich-scorecard",
"--summary-json",
str(summary_path),
]
)

payload = json.loads(summary_path.read_text(encoding="utf-8"))

assert exit_code == 0
assert payload["enrichment"] == {
"status": "used",
"mode": "opt_in_scorecard",
"scorecard": {
"candidate_components": 2,
"supported_components": 1,
"status_counts": {
"repository_unmapped": 1,
"scorecard_available": 1,
},
},
}
assert "policy" not in payload


def test_cli_summary_json_omitted_does_not_write_summary_file(tmp_path: Path) -> None:
project_root = Path(__file__).resolve().parents[1]
report_path = tmp_path / "report.json"
summary_path = tmp_path / "summary.json"

exit_code = cli.main(
[
"compare",
"--before",
str(project_root / "examples" / "cdx_before.json"),
"--after",
str(project_root / "examples" / "cdx_after.json"),
"--out-json",
str(report_path),
]
)

assert exit_code == 0
assert report_path.is_file()
assert not summary_path.exists()