diff --git a/docs/adrs/00020-vulnerability-correlation-engine.md b/docs/adrs/00020-vulnerability-correlation-engine.md new file mode 100644 index 000000000..9ed6ce82c --- /dev/null +++ b/docs/adrs/00020-vulnerability-correlation-engine.md @@ -0,0 +1,632 @@ +# 00020. Vulnerability Correlation Engine: Status Quo and Future Direction + +Date: 2026-07-23 + +Verified against: `e47dacf3e8e11430b2c4528da7d54679291b700f` (2026-07-20) + +## Status + +DRAFT + +## Context + +Trustify correlates vulnerability advisories with SBOM packages to answer two fundamental +questions: + +- **Direction A** — "what vulnerabilities affect this SBOM?" +- **Direction B** — "what SBOMs are affected by this vulnerability?" + +These read as inverses of one another, and conceptually they are. In the implementation they +are not: each direction has its own query code, its own scoping rules, and its own status +semantics. This ADR is organised around that split, because the most consequential finding is +that the two directions are **not guaranteed to agree**. + +The document is pinned to a commit rather than a date alone, since it records implementation +detail that moves quickly. CPE matching in particular changed shortly before this revision +(see the `broadened CPE matching` change on HEAD). + +## Part 1 — Shared Foundations + +Everything in this part is direction-independent: it describes what ingestion writes into the +database, which both directions then read. + +### Data Model + +The correlation system rests on five entity groups: + +| Entity Group | Key Tables | Purpose | +|---|---|---| +| Vulnerabilities | `vulnerability`, `advisory_vulnerability`, `advisory_vulnerability_score` | CVE identity, per-advisory metadata, CVSS scores | +| Advisories | `advisory`, `source_document` | Document identity, dedup by SHA-256, deprecation chain | +| Packages (PURL) | `base_purl` → `versioned_purl` → `qualified_purl` | 3-tier normalized PURL hierarchy | +| Products (CPE) | `cpe`, `sbom_describing_cpe` | WFN-decomposed CPEs, materialized SBOM-level CPE index | +| Status (correlation pivot) | `purl_status`, `cpe_status`, `product_status`, `version_range` | Advisory assertions linking vulnerabilities to packages with version ranges | + +The status tables are the correlation pivot. Each row records: "Advisory X asserts that +vulnerability Y has status Z for packages matching identity P within version range R, scoped +to product context C." + +Both directions read the same three status tables. They differ in which side of the join is +the anchor, and in what they do with the scoping columns. + +### Ingestion Formats + +| Format | Loader | PURL Extraction | CPE Extraction | Version Ranges | +|---|---|---|---|---| +| CSAF | `CsafLoader` | From product tree PURLs | From product tree CPEs | From fixed-version inference | +| CVE 5.x | `CveLoader` | Maven only (divination heuristic) | From `affected[].cpes` | From `versions[]` entries | +| OSV | `OsvLoader` | From ecosystem→PURL translation | None | From `Event::Introduced/Fixed` pairs | +| NVD | `NvdLoader` | None (CPE-only loader) | From CPE match strings | From CPE version ranges | + +### Version Matching + +The `version_matches()` PL/pgSQL function dispatches to scheme-specific comparators. The +current definition is in `migration/src/m0002080_add_cargo_version_scheme.rs` +(`CREATE OR REPLACE`), **not** the original in `m0000010_init_up.sql` — reading the init SQL +alone yields a stale dispatch table missing both `cargo` and the dedicated Go comparator. + +| Scheme | Ecosystems | Implementation | +|---|---|---| +| `semver` | npm, NuGet, Packagist, Hex, Swift, Pub, Cargo, Gem | Semver parsing and comparison | +| `golang` | Go | Dedicated `golang_version_matches()` (gover semantics, added in `m0001130_gover_cmp_fns`) | +| `rpm` | RPM-based distros | Epoch:version-release ordering | +| `maven` | Maven Central | Maven version ordering | +| `python` | PyPI | PEP 440 | +| `generic` | Unknown ecosystems | **Exact string equality only** | +| `git` | Git-based references | Commit hash matching | + +`trustify_entity::version_scheme::VersionScheme` is the authoritative list of schemes, but not +every variant is reachable from every ingestion path — see L10. + +### Status Values and VEX + +Five status values are stored: `affected`, `fixed`, `not_affected`, `under_investigation`, +`recommended`. VEX documents are ingested as CSAF advisories — the same `purl_status` and +`product_status` tables carry VEX assertions. + +How these five are *filtered* at query time differs by direction; see L11. + +## Part 2 — Direction A: "What vulnerabilities affect this SBOM?" + +Anchor: a known `sbom_id`. Entry points are `modules/fundamental/src/sbom/model/details.rs` +and the raw SQL in `modules/fundamental/src/sbom/model/raw_sql.rs`. + +### REST API entry points + +Two endpoints trigger Direction A correlation: + +| Endpoint | Purpose | Query strategies | Response shape | +|---|---|---|---| +| `GET /v3/sbom/{id}/advisory` | Full advisory/vulnerability details for one SBOM | A1 + A2 + A3 (union of all three) | `Vec` with per-vulnerability status, scores, matched PURLs and CPEs | +| `GET /v3/sbom?advisories=true` | SBOM list with per-SBOM severity counts | All three, combined in a single batch SQL | `PaginatedResults` with `advisories: SbomAdvisorySummary` (severity→count map) | + +#### Call chain: `GET /v3/sbom/{id}/advisory` + +``` +get_sbom_advisories (endpoints/mod.rs:383) + → SbomService::fetch_sbom_details (service/sbom.rs:96) + → SbomDetails::from_entity (model/details.rs:77) + → [A1] SeaORM PURL query (details.rs:89–165) + → [A2] product_advisory_info_sql (details.rs:170–186, raw_sql.rs:336) + → [A3] cpe_advisory_info_sql (details.rs:193–207, raw_sql.rs:265) + → bulk entity lookups (details.rs:250–383) + → SbomAdvisory::from_models (details.rs:459) +``` + +The endpoint accepts an optional `status` query parameter (defaults to `["affected"]`), +which is forwarded as the `$2::text[]` bind parameter to A2 and A3. A1 applies the same +filter in its SeaORM `WHERE` clause. + +#### Call chain: `GET /v3/sbom?advisories=true` + +``` +v3::all (endpoints/mod.rs:246) + → SbomService::fetch_sboms (service/sbom.rs:206) + → SbomSummary::from_entities (model/mod.rs:152) + → batch_advisory_severity_counts (service/sbom.rs:660) + → batch_severity_counts_sql (raw_sql.rs:51) +``` + +`batch_severity_counts_sql` combines all three matching strategies (PURL version-range, +product name, CPE identity) in a single SQL query across multiple SBOM IDs. It returns +`(sbom_id, severity, count)` tuples collapsed by `DISTINCT ON (sbom_id, vulnerability_id)` +to avoid double-counting the same vulnerability from different strategies. + +Three independent matching strategies execute and their results are unioned. + +### A1 — PURL identity + version range (SeaORM) + +``` +sbom_package → sbom_node_purl_ref → qualified_purl → versioned_purl → base_purl + JOIN purl_status ON base_purl_id + JOIN version_range + WHERE version_matches(versioned_purl.version, version_range) + AND context_cpe_filter(sbom_describing_cpe) +``` + +Primary path. Matches by package identity (type/namespace/name) and checks version membership +via `version_matches()`. Scoped by the context CPE filter below. + +### A2 — Product name matching (raw SQL) + +``` +product_status.package = base_purl.name + OR product_status.package = CONCAT(base_purl.namespace, '/', base_purl.name) +``` + +Handles CSAF advisories that reference products by name rather than PURL. Exact string +equality, no normalization. Implemented as two separate CTEs (`cpe_matches_name` and +`cpe_matches_ns`). Also scoped by the context CPE filter. + +### A3 — CPE identity matching (raw SQL) + +``` +sbom_node_cpe_ref → cpe (vendor/product match, part = 'a') + JOIN cpe_status ON cpe_id + WHERE version_matches(sbom_package.version, version_range) +``` + +Matches SBOMs carrying CPE references against CVE records specifying affected CPEs. +Deliberately **not** scoped by the context CPE filter. + +### Scoping mechanism: the context CPE filter + +A1 and A2 are scoped by the SBOM's product context. The `sbom_describing_cpe` materialized +table records which CPEs describe each SBOM's root node (`CONTEXT_CPE_FILTER_SQL`). Matches +are allowed when: + +- The advisory's `context_cpe_id` is NULL (universal applicability), OR +- The context CPE matches an SBOM describing CPE (vendor + product + major version), OR +- The SBOM has no describing CPEs (unscoped SBOMs match everything) + +### Limitations specific to Direction A + +**L4. Context CPE generalization is major-version only.** +The filter generalizes by `split_part(version, '.', 1)`, matching only the major version +component. An advisory scoped to `cpe:/a:redhat:openshift:4.8` matches any SBOM describing CPE +with version `4.*`. Minor-version-specific applicability is lost. + +**L5. A3 lacks context scoping.** +Omitting the context filter from CPE identity matching is correct for third-party SBOMs — the +context-CPE concept encodes Red Hat product-stream membership and is meaningless for +component-level CPEs harvested from e.g. SPDX `cpe23Type` external references. But it means +CPE-based matches for *product* SBOMs can produce false positives: a vulnerability affecting +`cpe:/a:apache:log4j` matches every SBOM containing a log4j CPE regardless of product context. +The rationale is documented on `cpe_advisory_info_sql()`, and `batch_severity_counts_sql()` +mirrors it so list-level severity counts agree with the details endpoint. + +**L7. SBOM summary N+1 queries.** +`modules/fundamental/src/sbom/model/mod.rs:138` carries `TODO: consider improving the n-select +issues here` — SBOM summary construction issues per-package queries for PURLs, CPEs, and +licenses. + +## Part 3 — Direction B: "What SBOMs are affected by this vulnerability?" + +Anchor: a `vulnerability_id`. Entry point is `GET /v3/vulnerability/{id}`, served through +`VulnerabilityAdvisorySummary::from_entities()` in +`modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs`. + +This is **not** a mirror of Direction A. It is a separate implementation with three +correspondingly-shaped queries, a different scoping backbone, and different status semantics. + +### REST API entry point + +A single endpoint triggers Direction B correlation: + +| Endpoint | Purpose | Query strategies | Response shape | +|---|---|---|---| +| `GET /v3/vulnerability/{id}` | Full vulnerability details with affected SBOMs | B1 + B2 + B3 (union), plus conditional legacy query | `VulnerabilityDetails` with per-advisory `Vec` | + +The list endpoint `GET /v3/vulnerability` returns only `VulnerabilitySummary` (head metadata) +and does **not** execute any correlation queries. + +#### Call chain: `GET /v3/vulnerability/{id}` + +``` +get (endpoints/mod.rs:106) + → VulnerabilityService::fetch_vulnerability (service/mod.rs:107) + → VulnerabilityDetails::from_entity (model/details/mod.rs:41) + → VulnerabilityAdvisorySummary::from_entities (vulnerability_advisory.rs:127) + → [B1] SeaORM PURL query (vulnerability_advisory.rs:140–174) + → [B2] product_status raw SQL (vulnerability_advisory.rs:182–258) + → [B3] cpe_status raw SQL (vulnerability_advisory.rs:266–345) + → [Legacy] purl_status query (vulnerability_advisory.rs:354–359) + only if B1+B2+B3 returned empty + → per-advisory partition loop (vulnerability_advisory.rs:364–412) + → VulnerabilitySbomStatus::from_models (vulnerability_advisory.rs:595) +``` + +The endpoint accepts an optional `scores` query parameter. When `true`, CVSS score vectors +are included per advisory. The correlation queries themselves are not affected by this +parameter. + +#### Related endpoints (not Direction B) + +Two analyze endpoints find vulnerabilities given PURLs — a different code path that +shares no query logic with Direction B: + +| Endpoint | Purpose | +|---|---| +| `POST /v3/vulnerability/analyze` | Given PURLs, find matching vulnerabilities (v3 response) | +| `POST /v2/vulnerability/analyze` | Same, deprecated v2 response format | + +### Result shape + +The endpoint returns one `VulnerabilityAdvisorySummary` **per advisory** that mentions the +vulnerability. Each summary carries: + +```rust +pub struct VulnerabilityAdvisorySummary { + head: VulnerabilityAdvisoryHead, + purls: HashMap>, // legacy, see L18 + sboms: Vec, + number_of_vulnerabilities: u64, +} + +pub struct VulnerabilitySbomStatus { + head: SbomHead, // sbom_id, document_id, name, … + version: Option, // from the DESCRIBES package + purl_statuses: HashMap>, // status slug → matched PURLs +} +``` + +So the answer to "what SBOMs are affected" is delivered as: *per advisory*, a list of SBOMs, +each with its matched PURLs bucketed by status slug. There is no severity rollup and no +per-SBOM count — those exist only in Direction A. + +### Execution flow + +1. Three queries (B1, B2, B3) each run **once for the whole vulnerability**, independently of + which advisory asserted what. +2. Their rows are collected into a single flat `Vec` (`vuln_sbom_statuses`). + Each catcher carries `advisory_id`, the `sbom`, `sbom_node`, `sbom_package`, `status`, and + the matched `qualified_purl`. +3. If and only if that vector is **empty**, a fourth query (`purl_status_query`) runs to + populate the legacy `purls` field — see L18. +4. The flat vector is then partitioned per advisory by `advisory_id` filter, inside a loop over + `advisory_vulnerabilities`. +5. Within each advisory, `VulnerabilitySbomStatus::from_models()` folds rows into a + `HashMap` keyed by `sbom_id`, inserting each matched PURL under its status slug. + +Note the consequence of steps 1 and 4: matching is global, attribution is a post-hoc filter. +The queries never restrict to a particular advisory, so an advisory contributing zero rows +still produces a summary with an empty `sboms` list. + +### The DESCRIBES spine (common to all three queries) + +Every one of B1, B2 and B3 inner-joins the SBOM's describing package: + +```sql +JOIN sbom_node ON sbom_node.sbom_id = sbom.sbom_id AND sbom_node.node_id = sbom.node_id +JOIN package_relates_to_package + ON package_relates_to_package.sbom_id = sbom_node.sbom_id + AND relationship = $2 -- Relationship::Describes +JOIN sbom_package + ON sbom_package.sbom_id = package_relates_to_package.sbom_id + AND sbom_package.node_id = package_relates_to_package.right_node_id +``` + +This is Direction B's actual scoping backbone, and it has no counterpart in Direction A. Its +purpose is to supply the product identity and version reported on each result +(`VulnerabilitySbomStatus.version`), but because the joins are inner, it also *filters*: an +SBOM with no DESCRIBES relationship, or whose describing node has no `sbom_package` row, is +invisible to Direction B entirely. See L16. + +### B1 — PURL identity + version range (SeaORM) + +``` +purl_status (WHERE vulnerability_id = $1) + → base_purl → versioned_purl → qualified_purl → sbom_node_purl_ref → sbom + → [DESCRIBES spine] + WHERE version_matches(versioned_purl.version, version_range) + AND status.slug != 'not_affected' +``` + +The counterpart of A1, expressed with SeaORM joins and a `Func::cust(VersionMatches)` call. +Two departures from A1: there is **no context CPE filter** (L13), and the DESCRIBES spine is +required. + +### B2 — Product name matching (raw SQL) + +```sql +FROM product_status +JOIN cpe ON product_status.context_cpe_id = cpe.id +JOIN status ON product_status.status_id = status.id +JOIN product ON cpe.product = product.cpe_key +JOIN product_version ON product.id = product_version.product_id +JOIN product_version_range ON product_status.product_version_range_id = product_version_range.id +JOIN version_range ON product_version_range.version_range_id = version_range.id + AND version_matches(product_version.version, version_range.*) +JOIN sbom ON product_version.sbom_id = sbom.sbom_id +JOIN base_purl ON product_status.package LIKE CONCAT(base_purl.namespace, '/', base_purl.name) + OR product_status.package = base_purl.name +JOIN versioned_purl … JOIN qualified_purl … JOIN sbom_node_purl_ref +-- [DESCRIBES spine] +WHERE product_status.vulnerability_id = $1 + AND product_status.package IS NOT NULL + AND status.slug != 'not_affected' +``` + +The counterpart of A2, but the scoping mechanism is entirely different. Where A2 filters by +describing-CPE membership (`sbom_describing_cpe`), B2 resolves `cpe.product` to +`product.cpe_key` and version-matches the SBOM's `product_version` against +`product_version_range`. These are independent mechanisms over different tables (L14). + +Note also `product_status.context_cpe_id` is joined with an **inner** join here, so +product-status rows with a NULL context CPE — the "universally applicable" case that Direction +A explicitly admits — are dropped. And the `LIKE` on the namespace-qualified form is a +divergence from A2's `=` (L12). + +The in-code comment explains the raw form: some joins are not expressible in SeaORM, and +iterating on raw SQL is easier until the logic stabilizes, with the intent to convert once it +does. + +### B3 — Package-level CPE identity matching (raw SQL) + +```sql +FROM cpe_status +JOIN status ON cpe_status.status_id = status.id +JOIN advisory ON cpe_status.advisory_id = advisory.id AND advisory.deprecated = false +JOIN version_range ON cpe_status.version_range_id = version_range.id +JOIN cpe AS adv_cpe ON cpe_status.cpe_id = adv_cpe.id AND adv_cpe.part = 'a' +JOIN cpe AS pkg_cpe ON pkg_cpe.vendor = adv_cpe.vendor + AND pkg_cpe.product = adv_cpe.product + AND pkg_cpe.part = 'a' +JOIN sbom_node_cpe_ref ON sbom_node_cpe_ref.cpe_id = pkg_cpe.id +JOIN sbom ON sbom.sbom_id = sbom_node_cpe_ref.sbom_id +JOIN sbom_package AS matched_pkg ON … node_id = sbom_node_cpe_ref.node_id +JOIN sbom_node_purl_ref ON … node_id = sbom_node_cpe_ref.node_id +JOIN qualified_purl ON qualified_purl.id = sbom_node_purl_ref.qualified_purl_id +-- [DESCRIBES spine] +WHERE cpe_status.vulnerability_id = $1 + AND status.slug != 'not_affected' + AND version_matches(COALESCE(NULLIF(pkg_cpe.version, '*'), matched_pkg.version), version_range.*) +``` + +Explicitly the reverse of `cpe_advisory_info_sql()` — the in-code comment states its purpose is +to prevent an SBOM that matches only via a package CPE (e.g. NVD-sourced applicability) from +showing the vulnerability on the SBOM page with no backlink here. + +Version resolution matches A3: the package CPE's own version wins unless it is the `*` +wildcard, in which case the owning `sbom_package.version` is the fallback. + +Like A3, it applies no context CPE scoping, for the same documented reason. + +### Limitations specific to Direction B + +**L13. Direction B applies no context CPE scoping to PURL matches.** +B1 has no equivalent of `CONTEXT_CPE_FILTER_SQL`. An advisory scoped to a specific Red Hat +product stream will surface every SBOM containing a version-matching PURL, regardless of +whether that SBOM belongs to the stream. This is the L5 false-positive problem, but on the +*primary* PURL path rather than only the CPE path. + +**L14. B2 uses a product-level scoping path with no counterpart in Direction A.** +Direction A scopes product-name matches by describing-CPE membership; B2 scopes them by +`product` / `product_version` / `product_version_range` version matching. Neither is derived +from the other, so keeping them consistent is a manual exercise. B2 additionally drops +product-status rows with a NULL `context_cpe_id` through an inner join, where Direction A +treats NULL context as universally applicable. + +**L16. The DESCRIBES spine silently filters results.** +All three queries inner-join `package_relates_to_package` on `relationship = Describes` and +require the describing node to have an `sbom_package` row. SBOMs lacking a DESCRIBES +relationship — or whose describing node is a file rather than a package — return no rows in +any of B1, B2 or B3, and therefore never appear as affected, even when Direction A matches +them. The join exists to supply the reported product version, so a filtering side-effect is +being paid for a projection need. + +**L17. B3 drops CPE-only nodes.** +B3 requires the matched node to carry a `sbom_node_purl_ref` in addition to its +`sbom_node_cpe_ref`, because the result assembly needs a `qualified_purl`. A package +identified solely by CPE with no PURL is therefore never reported. This mirrors the +`Nodes without a qualified_purl_id are skipped` note on `cpe_advisory_info_sql()` in Direction +A, so the two directions are at least consistent here — but the restriction is a data-model +artifact, not a correlation decision. + +**L18. The `purls` field is legacy and conditionally populated.** +`VulnerabilityAdvisorySummary.purls` is populated by a fourth query that runs **only when no +SBOMs matched at all**. The in-code comment states it is legacy, retained because tests depend +on it, does not make much sense with the current APIs, and costs performance without much +benefit. The practical effect is that the field's presence encodes "nothing matched" rather +than carrying independent information, which is not discoverable from the API schema. + +**L19. Result assembly issues N+1 queries.** +Two sites inside the per-advisory loop: `number_of_vulnerabilities` runs a `.count()` per +advisory, and `SbomHead::from_entity()` runs per newly-encountered SBOM inside +`VulnerabilitySbomStatus::from_models()`. This is the Direction B analogue of L7. + +**L20. Per-SBOM `version` is first-write-wins.** +`VulnerabilitySbomStatus::from_models()` sets `version` from the first row folded for a given +`sbom_id` and never revisits it. Where the DESCRIBES spine yields more than one describing +package row, the reported version is whichever row the query returned first — the query +carries no `ORDER BY`, so this is not deterministic across plan changes. + +## Part 4 — Directional Asymmetries + +These limitations only become visible when the two directions are compared. They are the +reason this ADR is organised by direction. + +**L11. Status filter semantics differ between directions.** +Direction A's severity counts filter `status.slug = 'affected'`, and its detail endpoints take +an explicit status allow-list (`$2::text[]`, empty meaning "all"). Direction B hardcodes +`status.slug != 'not_affected'` in all three of B1, B2 and B3 — which admits `affected`, +`fixed`, `under_investigation` and `recommended` alike. + +A `fixed` assertion therefore contributes a row in Direction B that Direction A's severity +counting would exclude. The same advisory/SBOM/vulnerability triple can appear on the +vulnerability page and not in the SBOM's affected count. + +**L12. Product name matching uses different operators in each direction.** +Direction A joins with `=` on both forms. Direction B uses `LIKE` for the namespace-qualified +form: + +```sql +-- Direction B +JOIN base_purl ON product_status.package LIKE CONCAT(base_purl.namespace, '/', base_purl.name) + OR product_status.package = base_purl.name +``` + +Beyond the inconsistency, `LIKE` treats `_` and `%` in the *pattern* as wildcards. Because the +pattern side is `product_status.package` (advisory-supplied data), an advisory naming a +package containing an underscore — common in PyPI and Cargo names — will match base PURLs +differing at that position. This is a latent false-positive source in Direction B that does +not exist in Direction A. + +**L6. No cross-advisory deduplication of status assertions.** +When multiple non-deprecated advisories assert the same vulnerability status for the same +package, all assertions appear in detail-endpoint results in both directions. There is no +priority or dedup logic at the level of status assertions. + +Two narrower mechanisms exist and are *not* covered by this limitation: +- `vulnerability.authoritative_advisory_id` selects a single advisory's scores for + vulnerability details (ADR 00015). +- `batch_severity_counts_sql()` applies `DISTINCT ON (sbom_id, vulnerability_id)` to collapse + severity across advisories, preferring the highest scored severity. + +Neither generalizes to status (`affected` / `not_affected` / `fixed`) assertions, which is +where conflicting VEX claims actually surface. + +**L15. No consistency test spans the two directions.** +There is no test asserting that "SBOM S appears in the affected list for vulnerability V" iff +"vulnerability V appears in the affected list for SBOM S". Given L11, L12, L13 and L14, that +invariant does not currently hold, and no test would catch further divergence. + +## Part 5 — Ingestion-Side Limitations (affect both directions) + +These constrain what ends up in the status tables, so both directions inherit them equally. + +**L1. CVE PURL divination is Maven-only.** +`CveLoader` extracts PURLs from CVE `affected` entries via a heuristic in +`modules/ingestor/src/service/advisory/cve/divination.rs`. Only Maven packages are handled +(matching `collection_url` against Maven Central). CVE records for npm, PyPI, Go, Rust and +other ecosystems with `package_name` fields produce no PURL-based matches — they fall through +to CPE-only matching. + +**L2. Generic version scheme cannot compare ranges.** +When `version_scheme_id = 'generic'`, `generic_version_matches` performs exact string equality +only (against `low_version` where `low_inclusive`, or `high_version` where `high_inclusive`). +Any advisory using a version range with the generic scheme silently fails to match packages +whose version differs by even a single character. This affects OSV documents with unknown +ecosystems, and — more significantly — a large class of CSAF advisories via L10. + +**L3. Product name matching has no normalization.** +`product_status.package` is compared to `base_purl.name` as a raw string in both directions. +Case differences (`openssl` vs `OpenSSL`), naming variations (`commons-compress` vs +`org.apache.commons:commons-compress`) and encoding differences all cause missed matches. + +**L8. Red Hat-specific heuristic in the CSAF loader.** +The `StatusCreator` in `modules/ingestor/src/service/advisory/csaf/creator.rs` contains a +Red Hat-specific heuristic: when a CSAF advisory reports `fixed` status with a Red Hat vendor +CPE (`cpe.vendor() == "redhat"`), it also synthesizes an `affected` status for the unbounded +range below the fixed version. The in-code comment marks this as a deliberate special case to +be refactored into vendor-specific parsing if more exceptions arise. It is not configurable +and does not generalize to other vendors. + +**L9. Missing advisory version tracking.** +All three loaders have TODO comments about missing version tracking for advisory records: +CSAF `// TODO: consider failing if the version doesn't parse`, CVE `// TODO: check if we have +some kind of version information`, OSV `// TODO(#899): check if we have some kind of version +information`. + +**L10. Version scheme assignment is asymmetric across loaders.** +`CsafLoader` derives the scheme from the PURL type via `VersionScheme::from(purl.ty.as_str())` +in `csaf/creator.rs`. That `From<&str>` in `entity/src/version_scheme.rs` recognises only +`commit`/`git`, `custom`, `maven`, `npm`, `python`, `rpm` and `semver`; everything else falls +through to `VersionScheme::Generic`. + +Consequently a CSAF advisory carrying `pkg:cargo/…`, `pkg:golang/…`, `pkg:nuget/…`, +`pkg:gem/…`, `pkg:hex/…`, `pkg:swift/…`, `pkg:pub/…` or `pkg:composer/…` is stored with the +generic scheme and matches only on exact version equality (L2), despite working comparators +existing for all of those ecosystems. `OsvLoader` by contrast assigns each ecosystem's scheme +explicitly. The SQL dispatch table therefore advertises more capability than the CSAF +ingestion path can reach. + +## Limitation Index + +| ID | Scope | Summary | +|---|---|---| +| L1 | Ingestion | CVE PURL divination is Maven-only | +| L2 | Ingestion | Generic scheme is exact-equality only | +| L3 | Ingestion | Product name matching unnormalized | +| L4 | Direction A | Context CPE generalization is major-version only | +| L5 | Direction A | A3 (CPE identity) lacks context scoping | +| L6 | Both | No cross-advisory dedup of status assertions | +| L7 | Direction A | SBOM summary N+1 queries | +| L8 | Ingestion | Red Hat-specific CSAF heuristic | +| L9 | Ingestion | Missing advisory version tracking | +| L10 | Ingestion | Version scheme assignment asymmetric across loaders | +| L11 | Asymmetry | Status filter semantics differ between directions | +| L12 | Asymmetry | Product name join uses `=` in A, `LIKE` in B | +| L13 | Direction B | No context CPE scoping on B1 | +| L14 | Direction B | B2 scopes via `product_version_range`, no counterpart in A | +| L15 | Asymmetry | No cross-direction consistency test | +| L16 | Direction B | DESCRIBES spine silently filters SBOMs | +| L17 | Direction B | B3 drops CPE-only nodes (no PURL ref) | +| L18 | Direction B | `purls` field legacy, conditionally populated | +| L19 | Direction B | Result assembly issues N+1 queries | +| L20 | Direction B | Per-SBOM `version` is first-write-wins, unordered | + +L1–L10 retain the identifiers used in the previous revision of this ADR; L11–L20 are new. + +## Future Direction + +_To be determined in future ADR. The following areas are identified for evaluation but no decisions have +been made._ + +* graph cache should be used +* Convergence of the two directions +* Matching quality across ecosystems +* Optimize query-time correlation performance. +* Remove vendor-specific heuristics. +* Advisory conflict resolution and prioritization for status assertions. + +## Consequences + +This ADR is a status-quo record with no code changes. It establishes a shared understanding of +the correlation engine's architecture and limitations as a foundation for future refactoring +decisions. + +Organising by direction makes explicit something the previous structure obscured: the two +questions are answered by two independent implementations, and their agreement is currently an +assumption rather than a property. + +## References + +- [ADR 00014](00014-advisory-vulnerability-scores.md) — advisory/vulnerability score model +- [ADR 00015](00015-authoritative-advisory-scores.md) — authoritative advisory linking + +Direction A: +- `modules/fundamental/src/sbom/endpoints/mod.rs` — `GET /v3/sbom/{id}/advisory` (line 383), + `GET /v3/sbom` with `?advisories=true` (line 246) +- `modules/fundamental/src/sbom/service/sbom.rs` — `fetch_sbom_details` (line 96), + `batch_advisory_severity_counts` (line 660) +- `modules/fundamental/src/sbom/model/details.rs` — `SbomDetails::from_entity` (line 77), + A1 (lines 89–165), A2 call (lines 170–186), A3 call (lines 193–207) +- `modules/fundamental/src/sbom/model/raw_sql.rs` — `CONTEXT_CPE_FILTER_SQL` (line 6), + `batch_severity_counts_sql` (line 51), `cpe_advisory_info_sql` (line 265), + `product_advisory_info_sql` (line 336) +- `modules/fundamental/src/sbom/model/mod.rs` — SBOM summary assembly (L7) + +Direction B: +- `modules/fundamental/src/vulnerability/endpoints/mod.rs` — `GET /v3/vulnerability/{id}` + (line 106), `GET /v3/vulnerability` (line 76), + `POST /v3/vulnerability/analyze` (line 157) +- `modules/fundamental/src/vulnerability/service/mod.rs` — `fetch_vulnerability` (line 107) +- `modules/fundamental/src/vulnerability/model/details/mod.rs` — + `VulnerabilityDetails::from_entity` (line 41) +- `modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs` — + `VulnerabilityAdvisorySummary::from_entities` (line 127): B1 (lines 140–174), + B2 (lines 182–258), B3 (lines 266–345), legacy purl query (lines 354–359), + `VulnerabilitySbomStatus::from_models` (line 595) +- `modules/fundamental/src/purl/service/` — PURL status queries (Direction A counterpart, + not Direction B; referenced for L18 legacy field) + +Ingestion: +- `modules/ingestor/src/service/advisory/` — advisory ingestion loaders +- `modules/ingestor/src/service/advisory/csaf/creator.rs` — CSAF `StatusCreator` (L8) +- `modules/ingestor/src/service/advisory/cve/divination.rs` — Maven PURL divination (L1) +- `entity/src/version_scheme.rs` — scheme enum and `From<&str>` mapping (L10) + +Migrations: +- `migration/src/m0002080_add_cargo_version_scheme.rs` — current `version_matches()` definition +- `migration/src/m0001130_gover_cmp_fns/` — Go version comparator \ No newline at end of file