24 KiB
Here’s a crisp, first‑time‑friendly blueprint for Smart‑Diff—a minimal‑noise way to highlight only changes that actually shift security risk, not every tiny SBOM/VEX delta.
What “Smart‑Diff” means (in plain terms)
Smart‑Diff is the smallest set of changes between two builds/releases that materially change risk. We only surface a change when it affects exploitability or policy—not when a dev-only transitive bumped a patch with no runtime path.
Count it as a Smart‑Diff only if at least one of these flips:
- Reachability: new reachable vulnerable code appears, or previously reachable code becomes unreachable.
- VEX status: a CVE’s status changes (e.g., to
not_affected). - Version vs affected ranges: a dependency crosses into/out of a known vulnerable range.
- KEV/EPSS/Policy: CISA KEV listing, EPSS spike, or your org policy gates change.
Ignore:
- CVEs that are both unreachable and VEX = not_affected.
- Pure patch‑level churn that doesn’t cross an affected range and isn’t KEV‑listed.
- Dev/test‑only deps with no runtime path.
Minimal data model (practical)
- DiffSet { added, removed, changed } for packages, symbols, CVEs, and policy gates.
- AffectedGraph { package → symbol → call‑site }: reachability edges from entrypoints to vulnerable sinks.
- EvidenceLink { attestation | VEX | KEV | scanner trace } per item, so every claim is traceable.
Core algorithms (what makes it “smart”)
- Reachability‑aware set ops: run set diffs only on reachable vuln findings.
- SemVer gates: treat “crossing an affected range” as a boolean boundary; patch bumps inside a safe range don’t alert.
- VEX merge logic: vendor or internal VEX that says
not_affectedsuppresses noise unless KEV contradicts. - EPSS‑weighted priority: rank surfaced diffs by latest EPSS; KEV always escalates to top.
- Policy overlays: org rules (e.g., “block any KEV,” “warn if EPSS > 0.7”) applied last.
Example (why it’s quieter, but safer)
- OpenSSL 3.0.10 → 3.0.11 with VEX
not_affectedfor a CVE: Smart‑Diff marks risk down and closes the prior alert. - A transitive dev dependency changes with no runtime path: Smart‑Diff logs only, no red flag.
Implementation plan (Stella Ops‑ready)
1) Inputs
- SBOM (CycloneDX/SPDX) old vs new
- VEX (OpenVEX/CycloneDX VEX)
- Vuln feeds (NVD, vendor), CISA KEV, EPSS
- Reachability traces (per language analyzers)
2) Normalize
- Map all deps to purl, normalize versions, index CVEs → affected ranges.
- Ingest VEX and attach to CVE ↔ component with clear status precedence.
3) Build graphs
- Generate/refresh AffectedGraph per build: entrypoints → call stacks → vulnerable symbols.
- Tag each finding with
{reachable?, vex_status, kev?, epss, policy_flags}.
4) Diff
-
Compute DiffSet between builds for:
- Reachable findings
- VEX statuses
- Version/range crossings
- Policy/KEV/EPSS gates
5) Prioritize & suppress
- Drop items that are unreachable AND not_affected.
- Collapse patch‑level churn unless KEV‑listed.
- Sort remaining by KEV first, then EPSS, then runtime blast‑radius (fan‑in/fan‑out).
6) Evidence
-
Attach EvidenceLink to each surfaced change:
- VEX doc (line/ID)
- KEV entry
- EPSS score + timestamp
- Reachability call stack (top 1‑3 paths)
7) UX
-
Pipeline‑first: output a Smart‑Diff report JSON + concise CLI table:
risk ↑/↓, reason (reachability/VEX/KEV/EPSS), component@version, CVE, one example call‑stack.
-
UI is an explainer: expand to full stack, VEX note, KEV link, and “minimum safe change” suggestion.
Module sketch (your stack)
-
Services:
Sbomer.Diff,Vexer.Merge,Scanner.Reachability,Feedser.KEV/EPSS,Policy.Engine,SmartDiff.Service -
Store: PostgreSQL (SoR), Valkey cache (ephemeral). Tables:
components,cves,vex_entries,reachability_edges,smartdiff_events,evidence_links. -
APIs:
POST /smartdiff/compare→ returns filtered diff + prioritiesGET /smartdiff/:id/evidence→ links to VEX/KEV/EPSS + trace
-
CI usage:
stella smart-diff --old sbomA.json --new sbomB.json --vex vex.json --out smartdiff.json
Guardrails (to keep it deterministic)
- Freeze feed snapshots per run (hash KEV/EPSS CSVs + VEX docs).
- Version the merge rules (VEX precedence + policy) and emit in the report header.
- Log the exact semver comparisons that triggered/exempted an alert.
If you want, I can draft the Postgres schema, the .NET 10 DTOs for DiffSet and AffectedGraph, and a CLI prototype (stella smart-diff) you can drop into your pipeline.
Noted: the services are Concelier (feeds: KEV/EPSS/NVD/vendor snapshots) and Excititor (VEX merge + status resolution). I’ll use those names going forward.
Below is a product + business analysis implementation spec that a developer can follow to build the Smart‑Diff capability you described.
1) Product objective
Problem
Classic SBOM/VEX diffs are noisy: they surface all dependency/CVE churn, even when nothing changes in actual exploitable risk.
Goal
Produce a Smart‑Diff report between two builds/releases that highlights only changes that materially impact security risk, with evidence attached.
Success criteria
- Noise reduction: >80% fewer diff items vs raw SBOM diff for typical builds (measured by count).
- No missed “high-risk flips”: any change that creates or removes a reachable vulnerable path must appear.
- Traceability: every surfaced Smart‑Diff item has at least one evidence link (VEX entry, reachability trace, KEV reference, feed snapshot hash, scanner output).
2) Scope
In scope (MVP)
-
Compare two “build snapshots”:
{SBOM, VEX, reachability traces, vuln feed snapshot, policy snapshot} -
Detect & report these change types:
- Reachability flips (reachable ↔ unreachable)
- VEX status changes (e.g.,
affected→not_affected) - Version crosses vuln boundary (safe ↔ affected range)
- KEV/EPSS/policy gate flips (e.g., becomes KEV-listed)
-
Suppress noise using explicit rules (see section 6)
-
Output:
- JSON report for CI
- concise CLI output (table)
- optional UI list view (later)
Out of scope (for now)
- Full remediation planning / patch PR automation
- Cross-repo portfolio aggregation (doable later)
- Advanced exploit intelligence beyond KEV/EPSS
3) Key definitions (developers must implement these exactly)
3.1 Finding
A “finding” is a tuple:
FindingKey = (component_purl, component_version, cve_id)
…and includes computed fields:
reachable: bool | unknownvex_status: enum(see 3.3)in_affected_range: bool | unknownkev: boolepss_score: float | nullpolicy_flags: set<string>evidence_links: list<EvidenceLink>
3.2 Material risk change (Smart‑Diff item)
A change is “material” if it changes the computed RiskState for any FindingKey or creates/removes a FindingKey that is in-scope after suppression rules.
3.3 VEX status vocabulary
Normalize all incoming VEX statuses into a fixed internal enum:
AFFECTEDNOT_AFFECTEDFIXEDUNDER_INVESTIGATIONUNKNOWN(no statement or unparseable)
Note: Use OpenVEX/CycloneDX VEX mappings, but internal logic must operate on the above set.
4) System context and responsibilities
You already have a modular setup. Developers should implement Smart‑Diff as a pipeline over these components:
Components (names aligned to your system)
-
Sbomer
- Ingest SBOM(s), normalize to purl/version graph
-
Scanner.Reachability
- Produce reachability traces: entrypoints → call paths → vulnerable symbol/sink
-
Concelier
- Fetch + snapshot vulnerability intelligence (NVD/vendor/OSV as applicable), CISA KEV, EPSS
- Provide feed snapshot identifiers (hashes) per run
-
Excititor
- Ingest and merge VEX sources
- Resolve a final
vex_statusper (component, cve) - Provide precedence + explanation
-
Policy.Engine
- Evaluate org rules against a computed finding (e.g., “block if KEV”)
-
SmartDiff.Service
- Compute risk states for “old” and “new”
- Diff them
- Suppress noise
- Rank + output report with evidence
5) Developer deliverables
Deliverable A: Smart‑Diff computation library
A deterministic library that takes:
OldSnapshotandNewSnapshot(see section 7)- returns a
SmartDiffReport
Deliverable B: Service endpoint
POST /smartdiff/compare returns report JSON.
Deliverable C: CLI command
stella smart-diff --old <dir|file> --new <dir|file> [--policy policy.json] --out smartdiff.json
6) Smart‑Diff rules
Developers must implement these as explicit, testable rule functions.
6.1 Suppression rules (noise filters)
A finding is suppressed if ALL apply:
reachable == false(orunknowntreated as false only if you explicitly decide; recommended: unknown is not suppressible)vex_status == NOT_AFFECTEDkev == false- no policy requires it (e.g., “report all vuln findings” override)
Patch churn suppression
-
If a component version changes but:
in_affected_rangeremains false in both versions, AND- no KEV/policy flag flips,
- then suppress (don’t surface).
Dev/test dependency suppression (optional if you already tag scopes)
- If SBOM scope indicates
dev/testANDreachable == false, suppress. - If reachability is unknown, do not suppress by scope alone (avoid false negatives).
6.2 Material change detection rules
Surface a Smart‑Diff item when any of the following changes between old and new:
Rule R1: Reachability flip
reachablechanges:false → true(risk ↑) ortrue → false(risk ↓)- Include at least one call path as evidence if reachable is true.
Rule R2: VEX status flip
-
vex_statuschanges meaningfully:AFFECTED ↔ NOT_AFFECTEDUNDER_INVESTIGATION → NOT_AFFECTEDetc.
-
Changes involving
UNKNOWNshould be shown but ranked lower unless KEV.
Rule R3: Affected range boundary
-
in_affected_rangeflips:false → true(risk ↑)true → false(risk ↓)
-
This is the main guard against patch churn noise.
Rule R4: Intelligence / policy flip
kevchangesfalse → trueorepss_scorecrosses a configured threshold- any
policy_flagchanges severity (warn → block)
7) Snapshot contract (what Smart‑Diff compares)
Define a stable internal format:
{
"snapshot_id": "build-2025.12.14+sha.abc123",
"created_at": "2025-12-14T12:34:56Z",
"sbom": { "...": "CycloneDX or SPDX raw" },
"vex_documents": [ { "...": "OpenVEX/CycloneDX VEX raw" } ],
"reachability": {
"analyzer": "java-callgraph@1.2.0",
"entrypoints": ["com.app.Main#main"],
"paths": [
{
"component_purl": "pkg:maven/org.example/foo@1.2.3",
"cve": "CVE-2024-1234",
"sink": "org.example.foo.VulnClass#vulnMethod",
"callstack": ["...", "..."]
}
]
},
"concelier_feed_snapshot": {
"kev_hash": "sha256:...",
"epss_hash": "sha256:...",
"vuln_db_hash": "sha256:..."
},
"policy_snapshot": { "policy_hash": "sha256:...", "rules": [ ... ] }
}
Implementation note
- SBOM/VEX can remain “raw”, but you must also build normalized indexes (in-memory or stored) for diffing.
8) Data normalization requirements
8.1 Component identity
- Use purl as canonical component ID.
- Normalize casing, qualifiers, and version string normalization per ecosystem.
8.2 Vulnerability identity
- Use
CVE-*as primary key where available. - If you ingest OSV IDs too, map them to CVE when possible but keep OSV ID in evidence.
8.3 Affected range evaluation
Implement:
bool? IsVersionInAffectedRange(version, affectedRanges)
Return null (unknown) if version cannot be parsed or range semantics are unknown.
9) Excititor: VEX merge requirements
Developers should implement Excititor as a deterministic resolver:
9.1 Inputs
-
List of VEX documents, each with metadata:
source(vendor/internal/scanner)issued_atsignature/attestationinfo (if present)
9.2 Output
For each (component_purl, cve_id):
final_statuswinning_statement_idprecedence_reasonall_statements[](for audit)
9.3 Precedence rules (recommendation)
Implement as ordered priority (highest wins), unless overridden by your org:
- Internal signed VEX (security team attested)
- Vendor signed VEX
- Internal unsigned VEX
- Scanner/VEX-like annotations
- None →
UNKNOWN
Conflict handling:
- If two same-priority statements disagree, pick newest by
issued_at, but record conflict and surface it as a low-priority Smart‑Diff meta-item (optional).
10) Concelier: feed snapshot requirements
Concelier must provide deterministic inputs to Smart‑Diff.
10.1 What Concelier stores
- KEV list snapshot
- EPSS snapshot
- Vulnerability database snapshot (your choice: NVD mirror, OSV, vendor advisories)
10.2 Required APIs (internal)
GET /concelier/snapshots/latestGET /concelier/snapshots/{hash}GET /concelier/kev/{snapshotHash}/is_listed?cve=CVE-...GET /concelier/epss/{snapshotHash}/score?cve=CVE-...
10.3 Determinism
Smart‑Diff report must include the snapshot hashes used, so the result can be reproduced.
11) RiskState computation (core dev logic)
Implement a pure function:
RiskState ComputeRiskState(FindingKey key, Snapshot snapshot)
Inputs used
- SBOM: to confirm component exists, scope, runtime path
- Concelier feeds: KEV, EPSS, affected ranges
- Excititor: VEX status
- Reachability analyzer output
- Policy engine: flags based on org rules
Output
{
"finding_key": { "purl": "...", "version": "...", "cve": "..." },
"reachable": true,
"vex_status": "AFFECTED",
"in_affected_range": true,
"kev": false,
"epss": 0.42,
"policy": {
"decision": "WARN|BLOCK|ALLOW",
"flags": ["epss_over_0_4"]
},
"evidence": [
{ "type": "reachability_trace", "ref": "trace:abc", "detail": "short call stack..." },
{ "type": "vex", "ref": "openvex:doc123#stmt7" },
{ "type": "concelier_snapshot", "ref": "sha256:..." }
]
}
12) Diff engine specification
12.1 Inputs
OldRiskStates: map<FindingKey, RiskState>NewRiskStates: map<FindingKey, RiskState>
You build these maps by:
-
Enumerating candidate findings in each snapshot:
- from vulnerability matching against SBOM components (affected ranges)
- plus any VEX statements referencing components
-
Joining with reachability traces
-
Resolving status via Excititor
-
Applying Concelier intelligence + policy
12.2 Diff output types
Return SmartDiffItem with:
change_type:ADDED|REMOVED|CHANGEDrisk_direction:UP|DOWN|NEUTRALreason_codes:[REACHABILITY_FLIP, VEX_FLIP, RANGE_FLIP, KEV_FLIP, POLICY_FLIP, EPSS_THRESHOLD]old_state/new_statepriority_scoreevidence_links[]
12.3 Suppress AFTER diff, not before
Important: compute diff on full sets, then suppress items by rules, because:
- suppression itself can flip (e.g., VEX becomes
not_affected→ item disappears, which is meaningful as “risk down”).
13) Priority scoring & ranking
Implement a deterministic score:
Hard ordering
kev == truein new state → top tier- Reachable in new state (
reachable == true) → next tier
Numeric scoring (example)
score =
+ 1000 if new.kev
+ 500 if new.reachable
+ 200 if reason includes RANGE_FLIP to affected
+ 150 if VEX_FLIP to AFFECTED
+ 0..100 based on EPSS (epss * 100)
+ policy weight: +300 if decision BLOCK, +100 if WARN
Always include score_breakdown in report for explainability.
14) Evidence requirements (must implement)
Every Smart‑Diff item must include at least one evidence link, and ideally 2–4:
EvidenceLink schema:
{
"type": "vex|reachability|kev|epss|scanner|sbom|policy",
"ref": "stable identifier",
"summary": "one-line human readable",
"blob_hash": "sha256 of raw evidence payload (optional)"
}
Examples:
type=kev: ref isconcelier:kev@{snapshotHash}#CVE-2024-1234type=reachability: ref isreach:{snapshotId}:{traceId}type=vex: ref isopenvex:{docHash}#statement:{id}
15) API specification
15.1 Compare endpoint
POST /smartdiff/compare
Request:
{
"old_snapshot_id": "buildA",
"new_snapshot_id": "buildB",
"options": {
"include_suppressed": false,
"max_items": 200,
"epss_threshold": 0.7
}
}
Response:
{
"report_id": "smartdiff:2025-12-14:xyz",
"old": { "snapshot_id": "buildA", "feed_hashes": { ... } },
"new": { "snapshot_id": "buildB", "feed_hashes": { ... } },
"summary": {
"risk_up": 3,
"risk_down": 8,
"reachable_new": 2,
"kev_new": 1,
"suppressed": 143
},
"items": [
{
"change_type": "CHANGED",
"risk_direction": "UP",
"priority_score": 1680,
"reason_codes": ["REACHABILITY_FLIP","RANGE_FLIP"],
"finding_key": {
"purl": "pkg:maven/org.example/foo",
"version_old": "1.2.3",
"version_new": "1.2.4",
"cve": "CVE-2024-1234"
},
"old_state": { "...": "RiskState" },
"new_state": { "...": "RiskState" },
"evidence": [ ... ]
}
]
}
15.2 Evidence endpoint
GET /smartdiff/{report_id}/evidence/{evidence_ref}
Returns raw stored evidence (or a signed URL if you store blobs elsewhere).
16) CLI behavior
Command:
stella smart-diff \
--old ./snapshots/buildA \
--new ./snapshots/buildB \
--policy ./policy.json \
--out ./smartdiff.json
CLI output (human):
-
Summary line:
risk ↑ 3 | risk ↓ 8 | new reachable 2 | new KEV 1 -
Then top N items sorted by priority, each one line:
↑ REACHABILITY_FLIP foo@1.2.4 CVE-2024-1234 (EPSS 0.42) path: Main→...→vulnMethod
Exit code:
0if policy decision overall is ALLOW/WARN2if any item triggers policy BLOCK in new snapshot (configurable)
17) Storage schema (Postgres) — implementation-ready
You can implement in a single schema to start; split later.
Core tables
snapshots
snapshot_id (pk)created_atsbom_hashpolicy_hashkev_hashepss_hashvuln_db_hashmetadata jsonb
components
component_id (pk)(internal UUID)snapshot_id (fk)purlversionscope(runtime/dev/test/unknown)direct bool- indexes on
(snapshot_id, purl)and(purl, version)
findings
finding_id (pk)snapshot_id (fk)purlversioncvereachable bool nullvex_status textin_affected_range bool nullkev boolepss real nullpolicy_decision textpolicy_flags text[]- index
(snapshot_id, purl, cve)
reachability_traces
trace_id (pk)snapshot_id (fk)purlcvesinkcallstack jsonb- index
(snapshot_id, purl, cve)
vex_statements
stmt_id (pk)snapshot_id (fk)purlcvesourceissued_atstatusdoc_hashraw jsonb- index
(snapshot_id, purl, cve)
smartdiff_reports
report_id (pk)created_atold_snapshot_idnew_snapshot_idoptions jsonbsummary jsonb
smartdiff_items
item_id (pk)report_id (fk)change_typerisk_directionpriority_scorereason_codes text[]purlcveold_versionnew_versionold_state jsonbnew_state jsonb
evidence_links
evidence_id (pk)report_id (fk)item_id (fk)typerefsummaryblob_hash
18) Implementation plan (developer-focused)
Phase 1 — MVP (end-to-end working)
-
Normalize SBOM
- Parse CycloneDX/SPDX
- Build
componentslist with purl + version + scope
-
Concelier integration
- Load KEV + EPSS snapshots (even from local files initially)
- Expose snapshot hashes
-
Excititor integration
- Parse OpenVEX/CycloneDX VEX
- Implement precedence rules and output
final_status
-
Affected range matching
- For each component, query vulnerability DB snapshot for affected ranges
- Produce candidate findings
(purl, version, cve)
-
Reachability ingestion
- Accept reachability JSON traces (even if generated elsewhere initially)
- Mark
reachable=truewhen trace exists for (purl,cve)
-
Compute RiskState
- For each finding compute
kev,epss,policy_decision
- For each finding compute
-
Diff + suppression + ranking
- Generate
SmartDiffReport
- Generate
-
Outputs
- JSON report + CLI table
- Store report + items in Postgres
Acceptance tests for Phase 1:
-
Given a known pair of snapshots, Smart‑Diff only includes:
- reachable vulnerable changes
- VEX flips
- affected range boundary flips
- KEV flips
-
Patch churn not crossing ranges is absent.
Phase 2 — Determinism & evidence hardening
- Store raw evidence blobs (VEX doc hash, trace payload hash)
- Ensure feed snapshots are immutable and referenced by hash
- Add
score_breakdown - Add conflict surfacing for VEX merge
Phase 3 — Performance & scale
- Incremental computation (only recompute affected components changed)
- Cache Concelier lookups by
(snapshotHash, cve) - Batch range matching queries
- Add pagination and
max_itemsenforcement
19) Edge cases developers must handle
-
Reachability unknown
- If no analyzer output exists, set
reachable = null - Do not suppress solely based on
reachable=null
- If no analyzer output exists, set
-
Version parse failures
in_affected_range = null- Surface range-related changes only when one side is determinable
-
Component renamed / purl drift
- Consider purl normalization rules (namespace casing, qualifiers)
- If purl changes but is same artifact, treat as new component (unless you implement alias mapping later)
-
Multiple CVE sources / duplicates
- Deduplicate by CVE ID per component+version
-
Conflicting VEX statements
- Pick winner deterministically, but log conflict evidence
-
KEV listed but VEX says not affected
-
Still suppress? Recommended:
- Do not suppress; surface as “KEV listed but VEX not_affected” and rank high (KEV tier)
-
-
Policy config changes
- Treat policy hash difference as a diff dimension; surface “policy flip” items even if underlying vuln unchanged
20) Testing strategy (must implement)
Unit tests
- SemVer compare + affected range evaluation
- Excititor precedence resolution
- Suppression rules (table-driven tests)
- Priority scoring determinism
Integration tests
-
Build synthetic snapshots:
- A: vuln present, unreachable, VEX not_affected
- B: same vuln reachable
- Assert Smart‑Diff surfaces exactly one item with
REACHABILITY_FLIP
-
KEV flip test:
- Same findings, KEV list changes between Concelier snapshots
- Assert item surfaces with
KEV_FLIP
Regression suite
Keep a folder of snapshot pairs and expected outputs:
fixtures/snapA,fixtures/snapB,expected.smartdiff.json
21) What the developer should code first (practical order)
-
DTOs:
Snapshot,Component,VexStatement,ReachTrace,FindingKey,RiskState,SmartDiffItem,SmartDiffReport
-
Pure functions:
NormalizePurlIsVersionInAffectedRangeResolveVexStatus(Excititor)ComputeRiskStateDiffRiskStatesApplySuppressionScoreAndRank
-
Persistence:
- store snapshots and computed findings
-
API + CLI wrappers
If you want, I can also provide:
- a concrete JSON Schema for
SmartDiffReport - C# (.NET 10) interfaces + class skeletons for
ConcelierClient,ExcititorResolver, andSmartDiffService - a fixture set (sample SBOM/VEX/reach traces) to bootstrap the test suite