From 2a4efe8f64345b9378a5bc369cb0b07a97300b2d Mon Sep 17 00:00:00 2001 From: stacknil Date: Sat, 20 Jun 2026 13:04:42 +0800 Subject: [PATCH] docs(sbom): add risk model boundary --- README.md | 8 +- docs/reviewer-brief.md | 4 + docs/risk-model-boundary.md | 174 +++++++++++------- scripts/validate-reviewer-routes.py | 25 +++ .../sbom-diff-and-risk/docs/reviewer-path.md | 3 + .../tests/test_risk_model_boundary_docs.py | 43 +++++ 6 files changed, 186 insertions(+), 71 deletions(-) create mode 100644 tools/sbom-diff-and-risk/tests/test_risk_model_boundary_docs.py diff --git a/README.md b/README.md index b51da03..aeca388 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,11 @@ the right evidence path. For the shortest boundary check before adding or reviewing new material, use the [repository scope map](docs/repo-scope-map.md). +For the SBOM tool's risk-model boundary, use +[`docs/risk-model-boundary.md`](docs/risk-model-boundary.md). It states which +fields affect risk buckets, which fields are context only, and which claims the +model never infers. + ## Supporting Diagnostics Projects These projects are internal supporting material for reviewer depth. They are @@ -165,6 +170,8 @@ they do not prove the same thing. [`scripts/validate-reviewer-routes.py`](scripts/validate-reviewer-routes.py) - Repository scope map: [`docs/repo-scope-map.md`](docs/repo-scope-map.md) +- Risk model boundary: + [`docs/risk-model-boundary.md`](docs/risk-model-boundary.md) The TestPyPI Trusted Publishing dry-run has been validated. Production PyPI publishing is intentionally deferred. @@ -205,4 +212,3 @@ the review question: - Production PyPI publishing: intentionally deferred [release-notes-v090]: tools/sbom-diff-and-risk/RELEASE_NOTES_v0.9.0.md - diff --git a/docs/reviewer-brief.md b/docs/reviewer-brief.md index 78dcdda..f364195 100644 --- a/docs/reviewer-brief.md +++ b/docs/reviewer-brief.md @@ -26,6 +26,7 @@ workflows, but they are not part of the `sbom-diff-and-risk` release surface. | --- | --- | --- | | What is the repository shape? | This brief, the root [README](../README.md), and the [repository scope map](repo-scope-map.md). | You can distinguish the flagship SBOM tool from the supporting diagnostics projects. | | What should I review for the SBOM tool? | The SBOM [reviewer path](../tools/sbom-diff-and-risk/docs/reviewer-path.md). | You have chosen the right 30-second, 5-minute, 15-minute, release, or deep-review route. | +| What does the SBOM risk model actually use? | The [risk model boundary](risk-model-boundary.md). | You can separate risk inputs from context-only fields and non-claims. | | Can the SBOM examples be reproduced? | The SBOM [example artifact regeneration guide](../tools/sbom-diff-and-risk/docs/example-artifact-regeneration.md). | `python scripts/regenerate-example-artifacts.py --check` passes. | | Can the released SBOM artifacts be verified? | The SBOM [verification guide](../tools/sbom-diff-and-risk/docs/verification.md). | You know whether to use checksums, release verification, or workflow artifact attestations. | | Are the reviewer routes still valid? | The repository [reviewer route contract](../scripts/validate-reviewer-routes.py). | `python scripts/validate-reviewer-routes.py` passes. | @@ -52,6 +53,9 @@ workflows, but they are not part of the `sbom-diff-and-risk` release surface. intentionally deferred production PyPI decision docs. - Scope map: `docs/repo-scope-map.md` keeps the flagship/supporting split and repository non-claims explicit. +- Risk model boundary: `docs/risk-model-boundary.md` states which fields affect + risk classification, which fields are context only, and what the model never + infers. - Non-goals: vulnerability scanning, CVE resolution, exploitability scoring, package safety verdicts, hidden enrichment, or production PyPI claims. diff --git a/docs/risk-model-boundary.md b/docs/risk-model-boundary.md index 6c33fc6..245987c 100644 --- a/docs/risk-model-boundary.md +++ b/docs/risk-model-boundary.md @@ -1,75 +1,102 @@ -# Risk model boundary +# Risk Model Boundary -This document defines the SBR-02 boundary for the SBOM risk model: which inputs -can change risk findings, which inputs are context only, and which conclusions the -tool must never infer. +This page defines the SBR-02 boundary for the +[`sbom-diff-and-risk`](../tools/sbom-diff-and-risk/README.md) risk model: which +inputs can change risk findings, which inputs are context only, and which +conclusions the tool must never infer. -The current risk model is a deterministic heuristic layer implemented in -`tools/sbom-diff-and-risk/src/sbom_diff_risk/risk.py`. It is not a vulnerability -scanner, malware detector, legal reviewer, or package trust oracle. +The model is a deterministic local heuristic layer. It is not a vulnerability +scanner, not a CVE resolver, and not a dependency safety verdict. -## Risk-affecting inputs +Implementation references: -Only the following inputs may affect emitted risk buckets. +- [`risk.py`](../tools/sbom-diff-and-risk/src/sbom_diff_risk/risk.py) +- [`diffing.py`](../tools/sbom-diff-and-risk/src/sbom_diff_risk/diffing.py) +- [`models.py`](../tools/sbom-diff-and-risk/src/sbom_diff_risk/models.py) +- [`dependency-risk-heuristics.md`](../tools/sbom-diff-and-risk/docs/dependency-risk-heuristics.md) -| Input | Risk effect | Boundary | +## Fields that affect risk classification + +The risk model evaluates the added component set and the changed component set. +Removed components are reported as diff output but do not currently produce +risk findings. + +Some fields affect whether a component enters the added or changed set. A +smaller set of fields directly selects the emitted risk bucket. + +### Diff membership inputs + +| Input or field | Affects | Boundary | | --- | --- | --- | -| Diff category: added component | Emits `new_package`. | The component exists in the after input and not in the before input. This is a change signal only. | -| Diff category: changed component | Enables version, hygiene, and stale-evaluation findings on the after component. | Removed and unchanged components are not evaluated by `evaluate_risks`. | -| `ComponentChange.before.version` and `ComponentChange.after.version` | Emit `major_upgrade` or `version_change_unclassified`. | `major_upgrade` requires both versions to parse as strict SemVer `x.y.z` and the after major version to be higher. If both versions are present and changed but do not qualify, the finding is `version_change_unclassified`. | -| `Component.license_id` | Emits `unknown_license`. | Missing, empty, `UNKNOWN`, and `NOASSERTION` are unknown. Other license strings are not interpreted for compliance or risk severity. | -| `Component.purl` | Participates in `suspicious_source`. | If both `purl` and `source_url` are missing, source provenance is suspicious. If `purl` exists and `source_url` is missing, the source is not suspicious solely for missing `source_url`. | -| `Component.source_url` | Emits `suspicious_source` when the value is missing with no `purl`, local, non-HTTPS, or otherwise suspicious. | Suspicious examples include `http://`, `git+`, `git://`, `ssh://`, `file://`, relative paths, absolute local paths, missing URL host, IP-address hosts, `localhost`, `localdomain`, and `.local` hosts. | -| Source allowlist | Participates in `suspicious_source` for single-label hosts. | The allowlist is not a general denylist. In the current implementation, an unallowlisted host is suspicious only when an allowlist is configured and the host has no dot. | -| `stale_enrichment_enabled` | Controls `not_evaluated` for stale package checks. | When false, the model emits `not_evaluated` instead of guessing staleness. When true, the placeholder finding is suppressed; the current model still does not infer `stale_package`. | - -## Context-only inputs - -These fields may be useful for display, parsing, reporting, policy evaluation, or -future enrichment, but they do not currently select a risk bucket in the risk -model. - -| Input | Current role | +| Component identity from `purl`, `bom_ref`, or `ecosystem` plus `name` | Whether a component is considered added, removed, or shared across inputs. | Identity is used by the diff layer before risk evaluation. It is not a package reputation signal. | +| Component signature fields `name`, `version`, `ecosystem`, `purl`, `license_id`, `supplier`, `source_url`, `bom_ref`, and `raw_type` | Whether a shared component is classified as changed. | A metadata-only change can enter risk evaluation, but bucket selection still comes only from the direct checks below. | + +### Direct bucket inputs + +| Input or field | Affects | Boundary | +| --- | --- | --- | +| Added component membership | `new_package` | A component that exists only in the after input receives `new_package`. This does not mean the package is unsafe. | +| `before.version` and `after.version` on changed components | `major_upgrade` or `version_change_unclassified` | Parseable SemVer major increases receive `major_upgrade`; other before/after version changes receive `version_change_unclassified`. Missing versions do not get a version-change risk bucket. | +| `license_id` on added components and changed-after components | `unknown_license` | Missing, empty, `UNKNOWN`, and `NOASSERTION` license values receive `unknown_license`. The model does not infer license compatibility. | +| `purl` and `source_url` on added components and changed-after components | `suspicious_source` | Missing source provenance, suspicious schemes, local paths, IP hosts, localhost-style hosts, and selected unqualified hosts can receive `suspicious_source`. | +| Source host allowlist passed to risk evaluation | `suspicious_source` only | The allowlist narrows source-host hygiene checks. It is not an approval list for dependency safety. | +| `stale_enrichment_enabled` | `not_evaluated` | When stale enrichment is disabled, the model records `not_evaluated` instead of guessing `stale_package`. When the flag is enabled, that offline placeholder is suppressed. | + +Current risk bucket names are: + +- `new_package` +- `major_upgrade` +- `version_change_unclassified` +- `unknown_license` +- `stale_package` +- `suspicious_source` +- `not_evaluated` + +`stale_package` is reserved for explicit stale-package enrichment. The offline +default does not infer it; it emits `not_evaluated` for that question. + +## Context-only fields + +These fields can appear in reports, policy explanations, or enrichment +evidence, but they do not directly select a core risk bucket. + +| Field or evidence | How to read it | | --- | --- | -| `Component.name` | Report identity and stable finding ordering. It does not by itself imply risk. | -| `Component.ecosystem` | Normalized package context used elsewhere in the toolchain. It does not currently change risk buckets. | -| `Component.supplier` | Context only. The risk model does not infer trust, ownership, or maintainer identity from it. | -| `Component.bom_ref` | SBOM identity context only. | -| `Component.raw_type` | Parser/source context only. | -| `Component.evidence` | Parser evidence context only. | -| `Component.provenance` | Enrichment evidence for reporting or policy layers only. The risk model does not convert provenance availability, attestation availability, or enrichment errors into risk buckets. | -| `Component.scorecard` | Scorecard evidence for reporting or policy layers only. The risk model does not convert score, checks, or repository mapping into risk buckets. | -| `ComponentChange.key` | Finding identity for changed components. It does not decide the bucket. | -| `ComponentChange.classification` | Diff context only. The version values drive version-related risk findings. | -| `ReportEnrichmentMetadata` | Report context only. Network flags, candidate counts, and status counts do not change risk buckets. | - -Policy evaluation is a separate layer. A policy may warn, fail, or suppress based -on findings or enrichment evidence, but that does not change what the risk model -itself is allowed to infer. - -## Never infer - -The risk model must never infer or imply any of the following unless a future, -explicitly documented feature adds a dedicated evidence source and tests. - -- A package is vulnerable, exploitable, compromised, malicious, or safe. -- A package has or does not have CVEs, advisories, exploit chains, or reachable - vulnerable code. -- A package is trustworthy because it has a familiar name, domain, supplier, - repository, PyPI provenance record, or Scorecard result. -- Missing metadata, missing provenance, missing attestations, or enrichment - errors prove compromise. -- License compliance, legal acceptability, or redistribution permission beyond - the narrow `unknown_license` metadata check. -- Maintainer identity, project ownership, organization affiliation, or source - authenticity from package names, supplier strings, URLs, or repository mapping. -- Runtime reachability, deployment exposure, production usage, or transitive - impact. -- Package staleness when stale enrichment is disabled. The correct output is - `not_evaluated`, not an invented stale or fresh conclusion. -- Risk severity beyond the emitted bucket name and rationale. -- Network-derived facts when enrichment has not explicitly performed network - access. +| `name` | Report identity, finding ordering, and diff-signature context. It does not by itself imply risk. | +| `ecosystem` | Package context and identity fallback. It is not treated as ecosystem risk. | +| `supplier` | Report metadata and diff-signature context. It is not treated as maintainer trust. | +| `bom_ref` | Component identity fallback and report context. It is not a security proof. | +| `raw_type` | Parser/report context and diff-signature context. It is not a risk score. | +| `evidence` | Parser-specific supporting data. The current risk classifier does not derive hidden findings from it. | +| `provenance` | Optional evidence used by reporting and policy paths when explicitly enabled. It does not prove dependency safety. | +| `scorecard` | Optional OpenSSF Scorecard evidence used by reporting and policy paths when explicitly enabled. It does not prove repository trustworthiness. | +| Report `metadata`, `notes`, `summary`, and `policy_evaluation` | Output context. They explain what ran and how policy consumed findings; they do not add hidden risk buckets. | + +Policy evaluation is a separate layer. A policy may pass, warn, fail, or ask +for consumer-side review based on findings or enrichment evidence, but policy +evaluation does not change what the risk model itself is allowed to infer. + +## Never inferred + +The model never infers: + +- CVE, advisory, exploitability, or vulnerability status +- package safety or unsafe-package verdicts +- malicious intent, maintainer identity, publisher trust, or account ownership +- current PyPI package truth unless an explicit enrichment path produced + evidence for that run +- current repository reputation from mocked or checked-in example artifacts +- production PyPI release status +- climate, weather, or meteorology portfolio claims for this repository +- hidden network enrichment in the default local path +- license compatibility or legal suitability +- dependency freshness when stale-package enrichment is disabled +- risk severity beyond the emitted bucket name and rationale +- runtime reachability, deployment exposure, production usage, or transitive + impact + +When the model lacks evidence for a question, it should leave the question +unanswered or emit `not_evaluated`; it should not fill the gap with a guess. ## Bucket boundaries @@ -83,12 +110,19 @@ explicitly documented feature adds a dedicated evidence source and tests. | `not_evaluated` | A check was intentionally not answered, currently stale-package evaluation in offline mode. | Safe, unsafe, stale, or fresh. | | `stale_package` | Reserved for future explicit stale-package enrichment. | Must not be emitted from missing data or guesswork. | +## Reading the output + +`risk_counts` is a count of local heuristic review findings. It is useful for +review triage, policy gating, and SARIF/report summaries. It is not a security +rating and should not be rewritten as a dependency safety verdict. + ## Maintenance checklist -Update this document and the focused risk tests when any of these change: +Update this document and the focused docs tests when any of these change: -- `tools/sbom-diff-and-risk/src/sbom_diff_risk/risk.py` -- `tools/sbom-diff-and-risk/src/sbom_diff_risk/models.py` -- parser normalization that changes the populated `Component` fields +- [`risk.py`](../tools/sbom-diff-and-risk/src/sbom_diff_risk/risk.py) +- [`diffing.py`](../tools/sbom-diff-and-risk/src/sbom_diff_risk/diffing.py) +- [`models.py`](../tools/sbom-diff-and-risk/src/sbom_diff_risk/models.py) +- parser normalization that changes populated `Component` fields - enrichment behavior that becomes a direct risk-model input -- policy behavior that might be confused with risk-model bucket generation +- policy behavior that might be confused with risk bucket generation diff --git a/scripts/validate-reviewer-routes.py b/scripts/validate-reviewer-routes.py index 1304926..9cac655 100644 --- a/scripts/validate-reviewer-routes.py +++ b/scripts/validate-reviewer-routes.py @@ -14,6 +14,7 @@ Path("README.md"), Path("docs/reviewer-brief.md"), Path("docs/repo-scope-map.md"), + Path("docs/risk-model-boundary.md"), Path("tools/sbom-diff-and-risk/docs/reviewer-path.md"), Path("projects/precipitation-anomaly-diagnostics/docs/reviewer-path.md"), Path("projects/precipitation-anomaly-diagnostics-lab/docs/reviewer-path.md"), @@ -44,6 +45,7 @@ Path("README.md"): { "docs/reviewer-brief.md", "docs/repo-scope-map.md", + "docs/risk-model-boundary.md", "tools/sbom-diff-and-risk/docs/reviewer-path.md", "tools/sbom-diff-and-risk/docs/reviewer-evidence-pack.md", "projects/precipitation-anomaly-diagnostics/docs/reviewer-path.md", @@ -53,6 +55,7 @@ Path("docs/reviewer-brief.md"): { "README.md", "docs/repo-scope-map.md", + "docs/risk-model-boundary.md", "tools/sbom-diff-and-risk/docs/reviewer-path.md", "tools/sbom-diff-and-risk/docs/example-artifact-regeneration.md", "projects/precipitation-anomaly-diagnostics/docs/reviewer-path.md", @@ -60,8 +63,15 @@ "projects/python-weather-diagnostics-toolkit/docs/reviewer-path.md", }, Path("docs/repo-scope-map.md"): set(), + Path("docs/risk-model-boundary.md"): { + "tools/sbom-diff-and-risk/docs/dependency-risk-heuristics.md", + "tools/sbom-diff-and-risk/src/sbom_diff_risk/diffing.py", + "tools/sbom-diff-and-risk/src/sbom_diff_risk/models.py", + "tools/sbom-diff-and-risk/src/sbom_diff_risk/risk.py", + }, Path("tools/sbom-diff-and-risk/docs/reviewer-path.md"): { ".github/workflows/reviewer-route-contract-ci.yml", + "docs/risk-model-boundary.md", "scripts/validate-reviewer-routes.py", "tools/sbom-diff-and-risk/docs/reviewer-brief.md", "tools/sbom-diff-and-risk/docs/reviewer-evidence-pack.md", @@ -146,6 +156,21 @@ "not a CVE resolver", "not a production PyPI release claim", ), + Path("docs/risk-model-boundary.md"): ( + "Fields that affect risk classification", + "Context-only fields", + "Never inferred", + "not a vulnerability scanner", + "not a CVE resolver", + "not a dependency safety verdict", + "new_package", + "major_upgrade", + "version_change_unclassified", + "unknown_license", + "stale_package", + "suspicious_source", + "not_evaluated", + ), Path("tools/sbom-diff-and-risk/docs/reviewer-path.md"): ( "Artifact evidence map", "Reviewer route contract", diff --git a/tools/sbom-diff-and-risk/docs/reviewer-path.md b/tools/sbom-diff-and-risk/docs/reviewer-path.md index 4cb51f8..7dbb7e2 100644 --- a/tools/sbom-diff-and-risk/docs/reviewer-path.md +++ b/tools/sbom-diff-and-risk/docs/reviewer-path.md @@ -62,6 +62,7 @@ Then read: - [report-schema.md](report-schema.md) - [policy-decision-explainability.md](policy-decision-explainability.md) +- [risk-model-boundary.md](../../../docs/risk-model-boundary.md) - [github-code-scanning.md](github-code-scanning.md) Look for these reviewer anchors: @@ -71,6 +72,8 @@ Look for these reviewer anchors: - `summary.enrichment` appears only when enrichment evidence exists - policy findings explain `decision_reason`, `policy_rule`, `matched_threshold`, and `observed_value` +- the risk model boundary separates risk inputs from context-only fields and + non-claims - SARIF is intentionally narrow and does not mirror every report finding Stop here if you need to understand the review outputs without running code. diff --git a/tools/sbom-diff-and-risk/tests/test_risk_model_boundary_docs.py b/tools/sbom-diff-and-risk/tests/test_risk_model_boundary_docs.py new file mode 100644 index 0000000..7115b79 --- /dev/null +++ b/tools/sbom-diff-and-risk/tests/test_risk_model_boundary_docs.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path + +from sbom_diff_risk.models import RiskBucket + +REPO_ROOT = Path(__file__).resolve().parents[3] +RISK_MODEL_BOUNDARY = REPO_ROOT / "docs" / "risk-model-boundary.md" + + +def _risk_model_boundary_text() -> str: + return RISK_MODEL_BOUNDARY.read_text(encoding="utf-8") + + +def _normalized_text(text: str) -> str: + return " ".join(text.split()) + + +def test_risk_model_boundary_names_every_risk_bucket() -> None: + text = _risk_model_boundary_text() + + for bucket in RiskBucket: + assert f"`{bucket.value}`" in text + + +def test_risk_model_boundary_names_inputs_and_nonclaims() -> None: + text = _risk_model_boundary_text() + normalized = _normalized_text(text) + + for phrase in ( + "`before.version`", + "`after.version`", + "`license_id`", + "`purl`", + "`source_url`", + "allowlist", + "`stale_enrichment_enabled`", + "not a vulnerability scanner", + "not a CVE resolver", + "not a dependency safety verdict", + "hidden network enrichment", + ): + assert phrase in text or _normalized_text(phrase) in normalized