diff --git a/tools/sbom-diff-and-risk/README.md b/tools/sbom-diff-and-risk/README.md index be04d6d..e64d3bf 100644 --- a/tools/sbom-diff-and-risk/README.md +++ b/tools/sbom-diff-and-risk/README.md @@ -210,6 +210,8 @@ report. It includes `policy_evaluation`, policy finding lists, `rule_catalog`, and `summary.policy` when policy evaluation is applied. For CI job-summary examples, see [docs/policy-decision-ci-cookbook.md](docs/policy-decision-ci-cookbook.md). +The checked-in [examples/sample-policy.json](examples/sample-policy.json) +artifact shows the standalone policy sidecar shape. ## Dependency Provenance Analysis (Opt-in) @@ -309,6 +311,7 @@ The [examples/](examples/) directory includes: - a sample pass Markdown report at [sample-report.md](examples/sample-report.md) - sample policy-warn reports at [sample-policy-warn-report.json](examples/sample-policy-warn-report.json) and [sample-policy-warn-report.md](examples/sample-policy-warn-report.md) - sample policy-fail reports at [sample-policy-fail-report.json](examples/sample-policy-fail-report.json) and [sample-policy-fail-report.md](examples/sample-policy-fail-report.md) +- a sample policy-only sidecar at [sample-policy.json](examples/sample-policy.json) - a sample SARIF export at [sample-sarif.sarif](examples/sample-sarif.sarif) - provenance-aware sample reports at [sample-provenance-report.json](examples/sample-provenance-report.json), [sample-provenance-report.md](examples/sample-provenance-report.md), and [sample-provenance-report.sarif](examples/sample-provenance-report.sarif) - Scorecard-aware sample reports at [sample-scorecard-report.json](examples/sample-scorecard-report.json), [sample-scorecard-report.md](examples/sample-scorecard-report.md), and [sample-scorecard-report.sarif](examples/sample-scorecard-report.sarif) diff --git a/tools/sbom-diff-and-risk/docs/policy-decision-ci-cookbook.md b/tools/sbom-diff-and-risk/docs/policy-decision-ci-cookbook.md index 846ac74..19649e2 100644 --- a/tools/sbom-diff-and-risk/docs/policy-decision-ci-cookbook.md +++ b/tools/sbom-diff-and-risk/docs/policy-decision-ci-cookbook.md @@ -22,6 +22,9 @@ The strict example policy can make the command return a policy failure exit code. In CI, keep the generated `outputs/policy.json` artifact so the policy decision metadata remains available for review. +For a checked-in reference artifact generated from this path, see +[sample-policy.json](../examples/sample-policy.json). + ## Python consumer This example reads the policy-only JSON sidecar, prints compact policy status, diff --git a/tools/sbom-diff-and-risk/docs/report-schema.md b/tools/sbom-diff-and-risk/docs/report-schema.md index 326cd54..404f6e1 100644 --- a/tools/sbom-diff-and-risk/docs/report-schema.md +++ b/tools/sbom-diff-and-risk/docs/report-schema.md @@ -88,6 +88,9 @@ same policy-related sections from the full JSON report: - `provenance_policy` and `provenance_policy_impact` when provenance policy fields are relevant +The checked-in [sample-policy.json](../examples/sample-policy.json) artifact +locks the standalone policy sidecar shape for a strict policy example. + ## Summary contract `summary` is the stable, compact entry point for automation that needs counts diff --git a/tools/sbom-diff-and-risk/examples/sample-policy.json b/tools/sbom-diff-and-risk/examples/sample-policy.json new file mode 100644 index 0000000..66c6d67 --- /dev/null +++ b/tools/sbom-diff-and-risk/examples/sample-policy.json @@ -0,0 +1,257 @@ +{ + "policy_evaluation": { + "applied": true, + "policy_path": "examples/policy-strict.yml", + "effective_policy": { + "version": 1, + "block_on": [ + "unknown_license", + "suspicious_source", + "stale_package", + "max_added_packages", + "allow_sources" + ], + "warn_on": [ + "new_package", + "major_upgrade" + ], + "max_added_packages": 0, + "allow_sources": [ + "pypi.org", + "files.pythonhosted.org", + "github.com" + ], + "ignore_rules": [] + }, + "blocking_violations": [ + { + "rule_id": "max_added_packages", + "level": "block", + "message": "Added package count 1 exceeds max_added_packages=0.", + "decision_reason": "added_package_count_exceeded_threshold", + "policy_rule": "max_added_packages", + "severity_source": "block_on", + "matched_threshold": 0, + "observed_value": 1, + "component_key": null, + "component_name": null, + "finding_bucket": null, + "suppression_reason": null + }, + { + "rule_id": "stale_package", + "level": "block", + "message": "stale_package was not evaluated because enrichment mode is disabled.", + "decision_reason": "risk_finding_matched_policy_rule", + "policy_rule": "stale_package", + "severity_source": "block_on", + "matched_threshold": null, + "observed_value": "not_evaluated", + "component_key": "purl:pkg:pypi/requests", + "component_name": "requests", + "finding_bucket": "not_evaluated", + "suppression_reason": null + }, + { + "rule_id": "stale_package", + "level": "block", + "message": "stale_package was not evaluated because enrichment mode is disabled.", + "decision_reason": "risk_finding_matched_policy_rule", + "policy_rule": "stale_package", + "severity_source": "block_on", + "matched_threshold": null, + "observed_value": "not_evaluated", + "component_key": "purl:pkg:pypi/urllib3", + "component_name": "urllib3", + "finding_bucket": "not_evaluated", + "suppression_reason": null + } + ], + "warning_violations": [ + { + "rule_id": "new_package", + "level": "warn", + "message": "Component was not present in the before input.", + "decision_reason": "risk_finding_matched_policy_rule", + "policy_rule": "new_package", + "severity_source": "warn_on", + "matched_threshold": null, + "observed_value": "new_package", + "component_key": "purl:pkg:pypi/urllib3", + "component_name": "urllib3", + "finding_bucket": "new_package", + "suppression_reason": null + } + ], + "suppressed_violations": [], + "totals": { + "blocking": 3, + "warning": 1, + "suppressed": 0, + "ignored_checks": 0 + }, + "exit_code": 1 + }, + "blocking_findings": [ + { + "rule_id": "max_added_packages", + "level": "block", + "message": "Added package count 1 exceeds max_added_packages=0.", + "decision_reason": "added_package_count_exceeded_threshold", + "policy_rule": "max_added_packages", + "severity_source": "block_on", + "matched_threshold": 0, + "observed_value": 1, + "component_key": null, + "component_name": null, + "finding_bucket": null, + "suppression_reason": null + }, + { + "rule_id": "stale_package", + "level": "block", + "message": "stale_package was not evaluated because enrichment mode is disabled.", + "decision_reason": "risk_finding_matched_policy_rule", + "policy_rule": "stale_package", + "severity_source": "block_on", + "matched_threshold": null, + "observed_value": "not_evaluated", + "component_key": "purl:pkg:pypi/requests", + "component_name": "requests", + "finding_bucket": "not_evaluated", + "suppression_reason": null + }, + { + "rule_id": "stale_package", + "level": "block", + "message": "stale_package was not evaluated because enrichment mode is disabled.", + "decision_reason": "risk_finding_matched_policy_rule", + "policy_rule": "stale_package", + "severity_source": "block_on", + "matched_threshold": null, + "observed_value": "not_evaluated", + "component_key": "purl:pkg:pypi/urllib3", + "component_name": "urllib3", + "finding_bucket": "not_evaluated", + "suppression_reason": null + } + ], + "warning_findings": [ + { + "rule_id": "new_package", + "level": "warn", + "message": "Component was not present in the before input.", + "decision_reason": "risk_finding_matched_policy_rule", + "policy_rule": "new_package", + "severity_source": "warn_on", + "matched_threshold": null, + "observed_value": "new_package", + "component_key": "purl:pkg:pypi/urllib3", + "component_name": "urllib3", + "finding_bucket": "new_package", + "suppression_reason": null + } + ], + "suppressed_findings": [], + "rule_catalog": { + "new_package": { + "rule_id": "new_package", + "kind": "risk_finding", + "description": "Component is present only in the after input.", + "finding_buckets": [ + "new_package" + ] + }, + "major_upgrade": { + "rule_id": "major_upgrade", + "kind": "risk_finding", + "description": "Version change is a parseable SemVer major upgrade.", + "finding_buckets": [ + "major_upgrade" + ] + }, + "version_change_unclassified": { + "rule_id": "version_change_unclassified", + "kind": "risk_finding", + "description": "Version changed but could not be classified as a reliable major SemVer upgrade.", + "finding_buckets": [ + "version_change_unclassified" + ] + }, + "unknown_license": { + "rule_id": "unknown_license", + "kind": "risk_finding", + "description": "License metadata is missing, empty, UNKNOWN, or NOASSERTION.", + "finding_buckets": [ + "unknown_license" + ] + }, + "suspicious_source": { + "rule_id": "suspicious_source", + "kind": "risk_finding", + "description": "Source provenance is missing or points to a suspicious scheme, path, or host.", + "finding_buckets": [ + "suspicious_source" + ] + }, + "stale_package": { + "rule_id": "stale_package", + "kind": "risk_finding", + "description": "Staleness check result. Offline mode maps this rule to not_evaluated instead of guessing.", + "finding_buckets": [ + "stale_package", + "not_evaluated" + ] + }, + "max_added_packages": { + "rule_id": "max_added_packages", + "kind": "policy_check", + "description": "Added package count exceeded the configured deterministic threshold.", + "finding_buckets": [] + }, + "allow_sources": { + "rule_id": "allow_sources", + "kind": "policy_check", + "description": "Component source host was not present in the configured allow_sources list.", + "finding_buckets": [] + }, + "missing_attestation": { + "rule_id": "missing_attestation", + "kind": "provenance_signal", + "description": "PyPI release metadata was fetched, but no attestations were published for the package release.", + "finding_buckets": [] + }, + "unverified_provenance": { + "rule_id": "unverified_provenance", + "kind": "provenance_signal", + "description": "PyPI attestations were present, but provenance could not be verified against publisher metadata.", + "finding_buckets": [] + }, + "provenance_unavailable": { + "rule_id": "provenance_unavailable", + "kind": "provenance_signal", + "description": "PyPI provenance evidence was unavailable because enrichment was disabled, unsupported, or errored.", + "finding_buckets": [] + }, + "provenance_required": { + "rule_id": "provenance_required", + "kind": "policy_check", + "description": "A configured provenance requirement was not satisfied for the component.", + "finding_buckets": [] + }, + "scorecard_below_threshold": { + "rule_id": "scorecard_below_threshold", + "kind": "policy_check", + "description": "A mapped repository's OpenSSF Scorecard score was below the configured minimum threshold.", + "finding_buckets": [] + } + }, + "summary": { + "policy": { + "status": "fail", + "blocking": 3, + "warning": 1, + "suppressed": 0 + } + } +} diff --git a/tools/sbom-diff-and-risk/tests/test_reports.py b/tools/sbom-diff-and-risk/tests/test_reports.py index 3e066af..eeeec47 100644 --- a/tools/sbom-diff-and-risk/tests/test_reports.py +++ b/tools/sbom-diff-and-risk/tests/test_reports.py @@ -18,7 +18,7 @@ from sbom_diff_risk.policy_models import PolicyConfig from sbom_diff_risk.policy_parser import build_policy from sbom_diff_risk.normalize import normalize_input -from sbom_diff_risk.report_json import render_report_json, render_summary_json +from sbom_diff_risk.report_json import render_policy_json, render_report_json, render_summary_json from sbom_diff_risk.report_md import render_report_markdown from sbom_diff_risk.risk import evaluate_risks, summarize_risks @@ -78,6 +78,18 @@ def test_report_json_matches_cyclonedx_policy_fail_golden() -> None: assert rendered == expected +def test_policy_json_matches_cyclonedx_policy_fail_golden() -> None: + report = _build_report("cdx_before.json", "cdx_after.json", policy_name="policy-strict.yml") + + rendered = render_policy_json(report) + expected = _read_example("sample-policy.json") + + assert rendered == expected + assert json.loads(rendered) == _policy_sidecar_from_full_report( + json.loads(_read_example("sample-policy-fail-report.json")) + ) + + def test_report_markdown_matches_cyclonedx_policy_fail_golden() -> None: report = _build_report("cdx_before.json", "cdx_after.json", policy_name="policy-strict.yml") @@ -397,3 +409,24 @@ def _build_report( def _read_example(name: str) -> str: examples = Path(__file__).resolve().parents[1] / "examples" return (examples / name).read_text(encoding="utf-8") + + +def _policy_sidecar_from_full_report(report_payload: dict[str, object]) -> dict[str, object]: + policy_payload = { + "policy_evaluation": report_payload["policy_evaluation"], + "blocking_findings": report_payload["blocking_findings"], + "warning_findings": report_payload["warning_findings"], + "suppressed_findings": report_payload["suppressed_findings"], + "rule_catalog": report_payload["rule_catalog"], + } + + summary = report_payload["summary"] + assert isinstance(summary, dict) + if "policy" in summary: + policy_payload["summary"] = {"policy": summary["policy"]} + + if "provenance_policy" in report_payload: + policy_payload["provenance_policy"] = report_payload["provenance_policy"] + policy_payload["provenance_policy_impact"] = report_payload["provenance_policy_impact"] + + return policy_payload