Skip to content

Commit 05dc646

Browse files
authored
[codex] Add optional summary JSON output
Add optional --summary-json output for the stable report summary object, reusing the existing JSON summary rendering path. Includes README/schema docs and CLI coverage for policy, PyPI enrichment, Scorecard enrichment, omitted output, and full-report summary equivalence.
1 parent 2250b8e commit 05dc646

6 files changed

Lines changed: 269 additions & 11 deletions

File tree

tools/sbom-diff-and-risk/README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,11 @@ sbom-diff-risk compare \
156156
- `--format auto|cyclonedx-json|spdx-json|requirements-txt|pyproject-toml`
157157
- `--before-format cyclonedx-json|spdx-json|requirements-txt|pyproject-toml`
158158
- `--after-format cyclonedx-json|spdx-json|requirements-txt|pyproject-toml`
159-
- `--pyproject-group name`
160-
- `--out-json path`
161-
- `--out-md path`
162-
- `--out-sarif path`
159+
- `--pyproject-group name`
160+
- `--out-json path`
161+
- `--summary-json path`
162+
- `--out-md path`
163+
- `--out-sarif path`
163164
- `--policy path`
164165
- `--fail-on rule[,rule...]`
165166
- `--warn-on rule[,rule...]`
@@ -170,9 +171,11 @@ sbom-diff-risk compare \
170171
- `--scorecard-timeout seconds`
171172
- `--source-allowlist pypi.org,files.pythonhosted.org,github.com`
172173

173-
Offline mode remains the default. No network access occurs unless `--enrich-pypi` or `--enrich-scorecard` is set explicitly.
174-
175-
## Dependency Provenance Analysis (Opt-in)
174+
Offline mode remains the default. No network access occurs unless `--enrich-pypi` or `--enrich-scorecard` is set explicitly.
175+
176+
`--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.
177+
178+
## Dependency Provenance Analysis (Opt-in)
176179

177180
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.
178181

tools/sbom-diff-and-risk/docs/report-schema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ When provenance policy fields are relevant, reports may also include `provenance
3030

3131
## Summary contract
3232

33-
`summary` is the stable, compact entry point for automation that needs counts without walking the full report.
33+
`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.
3434

3535
Base `summary` fields:
3636

tools/sbom-diff-and-risk/src/sbom_diff_risk/cli.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from .policy_evaluator import evaluate_policy
1414
from .policy_parser import build_policy
1515
from .presentation import effective_policy_evaluation, summarize_violations_by_rule
16-
from .report_json import render_report_json
16+
from .report_json import render_report_json, render_summary_json
1717
from .report_md import render_report_markdown
1818
from .report_sarif import render_report_sarif_output
1919
from .risk import evaluate_risks, summarize_risks
@@ -59,6 +59,7 @@ def build_parser() -> argparse.ArgumentParser:
5959
help="Select a PEP 735 [dependency-groups] group when a compared input is pyproject.toml.",
6060
)
6161
compare.add_argument("--out-json", type=Path, default=None, help="Write a JSON report to this path.")
62+
compare.add_argument("--summary-json", type=Path, default=None, help="Write the stable JSON summary object to this path.")
6263
compare.add_argument("--out-md", type=Path, default=None, help="Write a Markdown report to this path.")
6364
compare.add_argument(
6465
"--out-sarif",
@@ -137,8 +138,9 @@ def run_compare(args: argparse.Namespace) -> int:
137138
pypi_timeout = getattr(args, "pypi_timeout", DEFAULT_PYPI_TIMEOUT_SECONDS)
138139
scorecard_timeout = getattr(args, "scorecard_timeout", DEFAULT_SCORECARD_TIMEOUT_SECONDS)
139140

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

229231
if args.out_json is not None:
230232
_write_text(args.out_json, render_report_json(report))
233+
if summary_json is not None:
234+
_write_text(summary_json, render_summary_json(report))
231235
if args.out_md is not None:
232236
_write_text(args.out_md, render_report_markdown(report))
233237
if args.out_sarif is not None:

tools/sbom-diff-and-risk/src/sbom_diff_risk/report_json.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ def render_report_json(report: CompareReport) -> str:
4747
return json.dumps(payload, indent=2) + "\n"
4848

4949

50+
def render_summary_json(report: CompareReport) -> str:
51+
return json.dumps(_summary_to_dict(report), indent=2) + "\n"
52+
53+
5054
def _summary_to_dict(report: CompareReport) -> dict[str, object]:
5155
summary: dict[str, object] = {
5256
"added": report.summary.added,

tools/sbom-diff-and-risk/tests/test_cli_exit_codes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def test_cli_compare_help_mentions_policy_flags_and_exit_codes() -> None:
118118

119119
assert result.returncode == 0
120120
assert "--out-sarif" in result.stdout
121+
assert "--summary-json" in result.stdout
121122
assert "--pyproject-group" in result.stdout
122123
assert "--policy" in result.stdout
123124
assert "--fail-on" in result.stdout
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
from sbom_diff_risk import cli
7+
from sbom_diff_risk.models import ReportEnrichmentMetadata
8+
9+
10+
def test_cli_summary_json_writes_summary_only_file(tmp_path: Path) -> None:
11+
project_root = Path(__file__).resolve().parents[1]
12+
summary_path = tmp_path / "summary.json"
13+
14+
exit_code = cli.main(
15+
[
16+
"compare",
17+
"--before",
18+
str(project_root / "examples" / "cdx_before.json"),
19+
"--after",
20+
str(project_root / "examples" / "cdx_after.json"),
21+
"--summary-json",
22+
str(summary_path),
23+
]
24+
)
25+
26+
payload = json.loads(summary_path.read_text(encoding="utf-8"))
27+
28+
assert exit_code == 0
29+
assert payload == {
30+
"added": 1,
31+
"removed": 0,
32+
"changed": 1,
33+
"risk_counts": {
34+
"new_package": 1,
35+
"major_upgrade": 0,
36+
"version_change_unclassified": 1,
37+
"unknown_license": 0,
38+
"stale_package": 0,
39+
"suspicious_source": 0,
40+
"not_evaluated": 2,
41+
},
42+
}
43+
assert "unchanged" not in payload
44+
assert summary_path.read_text(encoding="utf-8").endswith("\n")
45+
46+
47+
def test_cli_summary_json_matches_full_report_summary(tmp_path: Path) -> None:
48+
project_root = Path(__file__).resolve().parents[1]
49+
report_path = tmp_path / "report.json"
50+
summary_path = tmp_path / "summary.json"
51+
52+
exit_code = cli.main(
53+
[
54+
"compare",
55+
"--before",
56+
str(project_root / "examples" / "cdx_before.json"),
57+
"--after",
58+
str(project_root / "examples" / "cdx_after.json"),
59+
"--out-json",
60+
str(report_path),
61+
"--summary-json",
62+
str(summary_path),
63+
]
64+
)
65+
66+
report_payload = json.loads(report_path.read_text(encoding="utf-8"))
67+
summary_payload = json.loads(summary_path.read_text(encoding="utf-8"))
68+
69+
assert exit_code == 0
70+
assert summary_payload == report_payload["summary"]
71+
72+
73+
def test_cli_summary_json_includes_policy_summary_when_policy_is_used(tmp_path: Path) -> None:
74+
project_root = Path(__file__).resolve().parents[1]
75+
summary_path = tmp_path / "summary.json"
76+
77+
exit_code = cli.main(
78+
[
79+
"compare",
80+
"--before",
81+
str(project_root / "examples" / "cdx_before.json"),
82+
"--after",
83+
str(project_root / "examples" / "cdx_after.json"),
84+
"--policy",
85+
str(project_root / "examples" / "policy-minimal.yml"),
86+
"--summary-json",
87+
str(summary_path),
88+
]
89+
)
90+
91+
payload = json.loads(summary_path.read_text(encoding="utf-8"))
92+
93+
assert exit_code == 0
94+
assert payload["policy"] == {
95+
"status": "warn",
96+
"blocking": 0,
97+
"warning": 1,
98+
"suppressed": 0,
99+
}
100+
assert "enrichment" not in payload
101+
102+
103+
def test_cli_summary_json_includes_enrichment_summary_when_enrichment_is_used(
104+
monkeypatch,
105+
tmp_path: Path,
106+
) -> None:
107+
project_root = Path(__file__).resolve().parents[1]
108+
summary_path = tmp_path / "summary.json"
109+
110+
class RecordingPyPIEnricher:
111+
def __init__(self, *args, timeout_seconds: float, **kwargs) -> None: # noqa: ANN002, ANN003
112+
self.timeout_seconds = timeout_seconds
113+
114+
def enrich_components(self, components): # noqa: ANN001
115+
return components
116+
117+
def build_report_metadata(self) -> ReportEnrichmentMetadata:
118+
return ReportEnrichmentMetadata(
119+
mode="opt_in_pypi",
120+
pypi_enabled=True,
121+
pypi_timeout_seconds=self.timeout_seconds,
122+
pypi_network_access_performed=False,
123+
network_access_performed=False,
124+
candidate_components=2,
125+
supported_components=2,
126+
status_counts={
127+
"provenance_available": 1,
128+
"attestation_unavailable": 1,
129+
},
130+
)
131+
132+
monkeypatch.setattr(cli, "PyPIProvenanceEnricher", RecordingPyPIEnricher)
133+
134+
exit_code = cli.main(
135+
[
136+
"compare",
137+
"--before",
138+
str(project_root / "examples" / "requirements_before.txt"),
139+
"--after",
140+
str(project_root / "examples" / "requirements_after.txt"),
141+
"--enrich-pypi",
142+
"--summary-json",
143+
str(summary_path),
144+
]
145+
)
146+
147+
payload = json.loads(summary_path.read_text(encoding="utf-8"))
148+
149+
assert exit_code == 0
150+
assert payload["enrichment"] == {
151+
"status": "used",
152+
"mode": "opt_in_pypi",
153+
"pypi": {
154+
"candidate_components": 2,
155+
"supported_components": 2,
156+
"status_counts": {
157+
"attestation_unavailable": 1,
158+
"provenance_available": 1,
159+
},
160+
},
161+
}
162+
assert "policy" not in payload
163+
164+
165+
def test_cli_summary_json_includes_scorecard_enrichment_summary_when_scorecard_is_used(
166+
monkeypatch,
167+
tmp_path: Path,
168+
) -> None:
169+
project_root = Path(__file__).resolve().parents[1]
170+
summary_path = tmp_path / "summary.json"
171+
172+
class RecordingScorecardEnricher:
173+
def __init__(self, *args, timeout_seconds: float, **kwargs) -> None: # noqa: ANN002, ANN003
174+
self.timeout_seconds = timeout_seconds
175+
176+
def enrich_components(self, components): # noqa: ANN001
177+
return components
178+
179+
def build_report_metadata(self) -> ReportEnrichmentMetadata:
180+
return ReportEnrichmentMetadata(
181+
mode="opt_in_scorecard",
182+
scorecard_enabled=True,
183+
scorecard_timeout_seconds=self.timeout_seconds,
184+
scorecard_network_access_performed=False,
185+
network_access_performed=False,
186+
scorecard_candidate_components=2,
187+
scorecard_supported_components=1,
188+
scorecard_status_counts={
189+
"scorecard_available": 1,
190+
"repository_unmapped": 1,
191+
},
192+
)
193+
194+
monkeypatch.setattr(cli, "ScorecardEnricher", RecordingScorecardEnricher)
195+
196+
exit_code = cli.main(
197+
[
198+
"compare",
199+
"--before",
200+
str(project_root / "examples" / "requirements_before.txt"),
201+
"--after",
202+
str(project_root / "examples" / "requirements_after.txt"),
203+
"--enrich-scorecard",
204+
"--summary-json",
205+
str(summary_path),
206+
]
207+
)
208+
209+
payload = json.loads(summary_path.read_text(encoding="utf-8"))
210+
211+
assert exit_code == 0
212+
assert payload["enrichment"] == {
213+
"status": "used",
214+
"mode": "opt_in_scorecard",
215+
"scorecard": {
216+
"candidate_components": 2,
217+
"supported_components": 1,
218+
"status_counts": {
219+
"repository_unmapped": 1,
220+
"scorecard_available": 1,
221+
},
222+
},
223+
}
224+
assert "policy" not in payload
225+
226+
227+
def test_cli_summary_json_omitted_does_not_write_summary_file(tmp_path: Path) -> None:
228+
project_root = Path(__file__).resolve().parents[1]
229+
report_path = tmp_path / "report.json"
230+
summary_path = tmp_path / "summary.json"
231+
232+
exit_code = cli.main(
233+
[
234+
"compare",
235+
"--before",
236+
str(project_root / "examples" / "cdx_before.json"),
237+
"--after",
238+
str(project_root / "examples" / "cdx_after.json"),
239+
"--out-json",
240+
str(report_path),
241+
]
242+
)
243+
244+
assert exit_code == 0
245+
assert report_path.is_file()
246+
assert not summary_path.exists()

0 commit comments

Comments
 (0)