feat: implement cpe matching and nvd importer#2518
Conversation
The Cpe type only accepted CPE 2.2 URI syntax (cpe:/a:...). CPE 2.3 formatted strings (cpe:2.3:a:...) failed to parse, silently dropping CPEs from CycloneDX components and blocking SPDX cpe23Type support. Convert 2.3 formatted strings to the 2.2 URI representation: decode the 11 attribute components honoring backslash escapes, percent-encode them in URI syntax (wildcards as %01/%02), and pack the extended attributes (sw_edition, target_sw, target_hw, other) into the edition component. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SPDX 2.3 defines cpe23Type as a security external reference type, and it is what NTIA-conformant SBOM tools emit. Ingestion only handled cpe22Type, silently dropping cpe23Type references. Handle both reference types with the same logic; unparseable CPE locators are still logged and skipped without failing ingestion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirrors purl_status but keyed by cpe_id instead of base_purl_id: the referenced cpe row carries the affected vendor/product identity with its version component normalized to ANY, while the actual affected versions are expressed through version_range (same as purl_status). This lands ahead of the upcoming CVE-loader change that will populate the table from cna.affected[].cpes / ADP container data. Registers the previously-written but unregistered m0002230_create_cpe_status migration and cpe_status entity module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the CVE 5.x loader to also populate cpe_status alongside the existing purl_status write path. For each cna.affected[] entry, every CPE string in product.cpes that trustify_common::cpe::Cpe::from_str parses is stored as a vendor/product identity: the CPE's version component is normalized to ANY (Cpe::with_any_version, new in common/src/cpe.rs) before storage, and the concrete affected version(s) are instead carried by version_range -- reusing the exact same VersionSpec/VersionScheme mapping the purl path already used (now factored out into version_spec_and_status so both paths share one implementation and purl behavior is unchanged). When a product has no `versions` list but its CPE carries a concrete version, that version is stored as VersionSpec::Exact with the Generic scheme, using the product's defaultStatus (falling back to "unknown"). New CpeStatusCreator (graph/cpe_status_creator.rs) mirrors PurlStatusCreator: batch insert, deterministic v5 UUIDs from (advisory, vulnerability, status, cpe, version_range[, context_cpe]) so re-ingesting the same document does not create duplicate rows. Known limitation carried over, not widened: generic_version_matches is exact-only (m0000010_init_up.sql ~L598), so versionType: custom ranges won't range-match -- pre-existing gap. Adds a synthetic CVE-2099-0001 fixture (openssl exact + range versions, busybox concrete-CPE-version-only) and a loader test asserting cpe_status rows (vendor/product/status/version_range) plus re-ingest idempotency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
extract_vuln_info previously only read containers.cna.affected. CISA vulnrichment and other ADP (Authorized Data Publisher) containers carry additional affected[] entries -- often the only source of CPE/version data for older CVEs the CNA never annotated with CPEs. Both cna.affected and every containers.adp[].affected are now chained into a single Vec<&Product> and flow through the same purl/cpe_status write path added for PR-4, so ADP data benefits from CPE ingestion without any duplicated logic. Adds a synthetic CVE-2099-0002 fixture where cna.affected is empty and a single ADP container supplies the only affected entry (denx/u-boot, ranged version), plus a loader test asserting the resulting cpe_status row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… details Add a `cpe_advisory_info_sql()` query that joins `sbom_node_cpe_ref` (package- level CPEs harvested from SBOMs, e.g. SPDX `cpe23Type` refs) against `cpe_status` (CVE-loader-populated CPE applicability data), and execute it next to the existing `product_advisory_info_sql()` query in `SbomDetails::from_entity`, merging results through the same `IdSet` row-assembly code. Matching semantics: vendor+product identity (`part = 'a'` on both sides) plus `version_matches()` against the version range recorded on `cpe_status`. Deliberately does *not* apply `CONTEXT_CPE_FILTER_SQL` (the `sbom_describing_cpe` context filter): that concept encodes Red Hat product-stream membership and doesn't apply to component-level CPEs in third-party SBOMs. `IdSet::qualified_purl_id` remains a mandatory `Uuid`; nodes without a purl are skipped via the CTE join rather than widening that struct to `Option`, to keep this diff focused -- follow-up if/when purl-less CPE-only nodes need to match. Test: `sbom_details_cpe_matching` ingests the synthetic firmware SBOM plus three synthetic CVE fixtures (CVE-2099-0001/0002, from earlier commits, and a new CVE-2099-0003 whose openssl range 2.0.0..3.0.0 deliberately excludes 0.9.8w) and asserts OpenSSL/BusyBox are matched while the ADP-only u-boot CVE and the version-excluded CVE are both absent -- the key false-positive guard distinguishing this path from name-only `product_status` matching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a cpe_version_matches CTE to batch_severity_counts_sql() (the SBOM-list severity-count query), mirroring cpe_advisory_info_sql()'s vendor+product identity + version_matches() semantics against cpe_status, and fold it into the all_affected UNION alongside the existing purl_matches/cpe_matches_name/ cpe_matches_ns branches. This closes the gap where the SBOM details endpoint (PR-6) already surfaced CPE-matched vulnerabilities but the SBOM-list/batch severity counts did not. Also fixes CVE-2099-0001.json's synthetic CVSS v3.1 metric: it previously carried only vectorString/baseScore/baseSeverity, but ScoreCreator computes severity from calculated_base_score() on the individual metric fields (attackVector, attackComplexity, ...), which were missing -- silently producing severity "none" instead of "critical". Added the full set of base metric fields. Test: sbom_severity_counts_cpe_matching ingests the firmware SBOM + the three synthetic CVE fixtures and asserts batch_advisory_severity_counts agrees with fetch_sbom_details: exactly one counted vulnerability (CVE-2099-0001, critical), with the ADP-only and version-excluded CVEs contributing nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real-world CVE records (e.g. Red Hat CNA entries in cvelistV5) frequently carry `"defaultStatus": "unknown"` together with `cpes` and no `versions` list. The CPE ingestion path mapped this to the status slug "unknown", which has no row in the `status` table, failing the whole document with "Invalid status unknown" — a regression for records that previously ingested fine (caught by vuln::issue_1840 via dataset DS3's CVE-2024-28834). Skip `unknown` entries in both the versions loop and the concrete-CPE-version fallback: they carry no applicability information to store. The purl path is left unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ingests the NVD CVE API 2.0 `cve` object (as republished by the `fkie-cad/nvd-json-data-feeds` mirror) as a supplementary advisory linked to the shared vulnerability. Its value is the rich CPE applicability (`configurations`), mapped to `cpe_status` rows under the `semver` scheme so version *ranges* match by ordered comparison, not just exact equality. A bare NVD `cve` object is indistinguishable from OSV by content sniffing, so `Format::NVD` is never auto-detected: it wires through the new `DocumentDetector` only when passed explicitly as a concrete hint. Enables serde_json `raw_value` so per-year feed items can be carried as raw fragments (see the importer). Includes a loader test asserting the semver-range cpe_status row and re-ingest idempotency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `NvdImporter` and a runner that pulls the mirror's per-year release assets (`CVE-<year>.json.xz` + `.meta`). Each year is re-ingested only when its `.meta` sha256 changes, so steady-state runs transfer just the changed years; a bad year is recorded and skipped without aborting the rest. Records are ingested one transaction each via `Format::NVD`. Regenerates the importer JSON schema and OpenAPI spec for the new config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ility details The reverse of the SBOM-page CPE match: a vulnerability's `cpe_status` rows (vendor/product identity + affected version range) are matched against the package CPEs an SBOM harvested into `sbom_node_cpe_ref`. Without this an SBOM that matched a vulnerability *only* via a package CPE (e.g. NVD-sourced applicability) showed the vulnerability on the SBOM page but had no backlink from the vulnerability page. Adds end-to-end tests over an NVD advisory carrying a semver range that matches a component version between the bounds (positive) and one whose range excludes it (negative). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewer's GuideImplements CPE-based vulnerability matching end-to-end (CVE and NVD ingestion, schema, SQL, and backlinks) and adds an NVD importer that pulls per-year JSON feeds, mapping NVD configurations into semver-backed CPE status ranges used in SBOM and vulnerability views. Entity relationship diagram for new cpe_status-based CPE matchingerDiagram
advisory {
uuid id
}
vulnerability {
text id
}
status {
uuid id
text slug
}
cpe {
uuid id
text vendor
text product
text part
text version
}
version_range {
uuid id
text low_version
text high_version
uuid version_scheme_id
}
cpe_status {
uuid id
uuid advisory_id
text vulnerability_id
uuid status_id
uuid cpe_id
uuid version_range_id
uuid context_cpe_id
}
advisory ||--o{ cpe_status : has
vulnerability ||--o{ cpe_status : has
status ||--o{ cpe_status : classifies
cpe ||--o{ cpe_status : identifies
version_range ||--o{ cpe_status : bounds
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- The CPE normalization helper
Cpe::with_any_versionsilently falls back to returning the original CPE if reconstruction fails; consider returning aResultor at least emitting a log so unexpected parse failures don’t get hidden and are easier to diagnose. - The new CPE-based matching SQL (
cpe_advisory_info_sqland the CTEs added tobatch_severity_counts_sql) duplicates several concepts and joins; factoring common pieces (e.g.sbom_cpe_pkgs, CPE identity joins) into a shared helper or view would reduce the risk of the two paths drifting out of sync. - In the NVD importer,
resolve_yearsgives precedence toyearsoverstart_yearbut this interaction isn’t obvious from the type or config shape; consider validating and/or explicitly documenting precedence so misconfigurations (e.g. both set with conflicting values) are easier to understand.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The CPE normalization helper `Cpe::with_any_version` silently falls back to returning the original CPE if reconstruction fails; consider returning a `Result` or at least emitting a log so unexpected parse failures don’t get hidden and are easier to diagnose.
- The new CPE-based matching SQL (`cpe_advisory_info_sql` and the CTEs added to `batch_severity_counts_sql`) duplicates several concepts and joins; factoring common pieces (e.g. `sbom_cpe_pkgs`, CPE identity joins) into a shared helper or view would reduce the risk of the two paths drifting out of sync.
- In the NVD importer, `resolve_years` gives precedence to `years` over `start_year` but this interaction isn’t obvious from the type or config shape; consider validating and/or explicitly documenting precedence so misconfigurations (e.g. both set with conflicting values) are easier to understand.
## Individual Comments
### Comment 1
<location path="modules/fundamental/tests/sbom/details.rs" line_range="379-213" />
<code_context>
+async fn sbom_details_nvd_cpe_range_matching(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
</code_context>
<issue_to_address>
**suggestion (testing):** Consider additional NVD range edge cases: unbounded ranges and exact boundary matches
The current test covers versions strictly inside and outside a bounded range, but misses two important NVD range patterns:
- Unbounded ranges with only a start or end bound (e.g. `>= 1.0.0` or `< 2.0.0`). A fixture using `versionStartIncluding` or `versionEnd*` without the other bound would validate `Version::Unbounded` handling in `version_spec`/`version_matches`.
- Exact boundary cases where the package version equals an inclusive or exclusive bound. Assertions for versions equal to `versionStartIncluding` and `versionEndExcluding` would help catch off‑by‑one errors.
Adding targeted fixtures and assertions for these cases would significantly strengthen the semver-range tests.
Suggested implementation:
```rust
#[test_context(TrustifyContext)]
#[test(tokio::test)]
#[instrument]
async fn sbom_details_nvd_cpe_range_matching(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
let sbom = SbomService::new(PaginationCache::for_test());
let result = ctx.ingest_document("spdx/cpe23-firmware.json").await?;
// Bounded range: version inside vs outside the range
let hit = ctx
.ingest_document_as("nvd/CVE-2099-2000.json", Format::NVD, ("source", "nvd"))
.await?;
assert_eq!(hit.document_id, Some("CVE-2099-2000".to_string()));
let miss = ctx
.ingest_document_as("nvd/CVE-2099-2001.json", Format::NVD, ("source", "nvd"))
.await?;
assert_eq!(miss.document_id, Some("CVE-2099-2001".to_string()));
// Unbounded range: only start bound (e.g. versionStartIncluding, no versionEnd*)
// This fixture should contain a CPE range like "versionStartIncluding": "1.0.0"
// and match a component version >= 1.0.0.
let unbounded_start_hit = ctx
.ingest_document_as(
"nvd/CVE-2099-2002-unbounded-start.json",
Format::NVD,
("source", "nvd"),
)
.await?;
assert_eq!(
unbounded_start_hit.document_id,
Some("CVE-2099-2002".to_string())
);
// Unbounded range: only end bound (e.g. versionEndExcluding, no versionStart*)
// This fixture should contain a CPE range like "versionEndExcluding": "2.0.0"
// and match a component version < 2.0.0.
let unbounded_end_hit = ctx
.ingest_document_as(
"nvd/CVE-2099-2003-unbounded-end.json",
Format::NVD,
("source", "nvd"),
)
.await?;
assert_eq!(
unbounded_end_hit.document_id,
Some("CVE-2099-2003".to_string())
);
// Exact boundary (inclusive): package version == versionStartIncluding
// This fixture should contain a CPE range like "versionStartIncluding": "1.0.0"
// with the SBOM component at exactly 1.0.0, and must be treated as a hit.
let inclusive_boundary_hit = ctx
.ingest_document_as(
"nvd/CVE-2099-2004-inclusive-start.json",
Format::NVD,
("source", "nvd"),
)
.await?;
assert_eq!(
inclusive_boundary_hit.document_id,
Some("CVE-2099-2004".to_string())
);
// Exact boundary (exclusive): package version == versionEndExcluding
// This fixture should contain a CPE range like "versionEndExcluding": "2.0.0"
// with the SBOM component at exactly 2.0.0, and must be treated as a miss.
let exclusive_boundary_miss = ctx
.ingest_document_as(
"nvd/CVE-2099-2005-exclusive-end.json",
Format::NVD,
("source", "nvd"),
)
.await?;
assert_eq!(
exclusive_boundary_miss.document_id,
Some("CVE-2099-2005".to_string())
);
```
1. Add the corresponding NVD JSON fixtures referenced above (`nvd/CVE-2099-2002-unbounded-start.json`, `nvd/CVE-2099-2003-unbounded-end.json`, `nvd/CVE-2099-2004-inclusive-start.json`, `nvd/CVE-2099-2005-exclusive-end.json`) under your test data directory. Each should define appropriate `cpeMatch` entries:
- Unbounded start: `versionStartIncluding` set, no `versionEnd*`.
- Unbounded end: `versionEndExcluding` (or `versionEndIncluding`) set, no `versionStart*`.
- Inclusive boundary: `versionStartIncluding` equal to the SBOM component version.
- Exclusive boundary: `versionEndExcluding` equal to the SBOM component version.
2. In the part of `sbom_details_nvd_cpe_range_matching` where you currently assert matches/misses for `hit` and `miss` (e.g. via `sbom.details(..)` or equivalent), add parallel assertions for the new variables:
- `unbounded_start_hit` and `unbounded_end_hit` should produce vulnerabilities for the relevant SBOM component (i.e. `version_matches` must return true).
- `inclusive_boundary_hit` should be treated as a hit at the lower bound.
- `exclusive_boundary_miss` should *not* produce a match at the upper exclusive bound.
3. Ensure these new assertions exercise the same `version_spec` / `version_matches` code path as the existing test, so that off-by-one and unbounded range handling is covered by this test.
</issue_to_address>
### Comment 2
<location path="modules/fundamental/tests/sbom/spdx.rs" line_range="171-180" />
<code_context>
+async fn ingest_spdx_cpe23_refs(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test that exercises SPDX `cpe23Type` with extended attributes and language
The existing `ingest_spdx_cpe23_refs` test only covers simple application CPEs. Since the parser and SPDX ingestion now support extended attributes and language, please add a case in `spdx/cpe23-firmware.json` where `cpe23Type` includes a non-empty language (e.g. `en-US`) and a meaningful extended attribute (such as `target_hw` or `sw_edition`). Then assert that `fetch_sbom_packages` returns the expected normalized CPE URI, including packed edition and correct language normalization, to validate the full end-to-end handling of richer 2.3 CPEs.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
CPE 2.3 formatted strings carry four attributes with no CPE 2.2 URI equivalent (sw_edition, target_sw, target_hw, other). The parser packed them into the edition component, but the cpe entity only mapped the seven 2.2 columns, so persisting a CPE read back the unpacked (empty) edition and dropped the extended attributes -- e.g. target_hw=x64 vanished on the SPDX ingest round-trip. Map the four columns (already present in the schema) on the Model and CpeDto, write them in from_cpe, and re-pack them into the edition on the way back out via a shared pack_edition helper (the URI builder unpacks a ~-delimited edition on validate). Model's Display uses the same helper. Identity is intentionally left unchanged: the CPE UUID still hashes only the 2.2 components, and matching keys on vendor/product, so the extended attributes remain cosmetic and no existing data or matching is affected. Extends the SPDX ingest test with a firmware CPE carrying target_hw and a non-ANY language, and adds a DB-free model/DTO round-trip unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend sbom_details_nvd_cpe_range_matching with the two NVD range patterns the existing bounded in/out test missed: - unbounded ranges (only versionStartIncluding, or only versionEndExcluding), exercising Version::Unbounded on each side - exact boundary matches where the component version equals an inclusive lower bound (hit) or an exclusive upper bound (miss), pinning both off-by-one directions Boundary fixtures pivot on the SBOM component version (busybox 1.19.4) so the component sits exactly on the bound. Assertions check details.advisories, the same version_matches path as the existing cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts: # migration/src/lib.rs
|
A few initial observations to get started:
thx for the great submission (I appreciate how you have broken up commits usefully) ... more observations to come. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #2518 +/- ##
==========================================
+ Coverage 71.67% 72.17% +0.50%
==========================================
Files 457 465 +8
Lines 27743 28589 +846
Branches 27743 28589 +846
==========================================
+ Hits 19884 20635 +751
- Misses 6713 6772 +59
- Partials 1146 1182 +36 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jcrossley3
left a comment
There was a problem hiding this comment.
It's a pretty big PR, but I appreciate the commit organization. I can't really comment on the raw sql, but the importer looks reasonable. Maybe add an NVD entry to the sample data with reasonable defaults to enable someone to easily try it?
Document that a non-empty `years` set takes precedence over `startYear`, both on the config fields (flows into the OpenAPI schema) and on resolve_years, and warn at run time when both are configured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unparseable `cpeMatch` criteria in the NVD loader are now logged and surfaced via the ingest result's warnings; skipped unbounded "all versions" matches are logged at debug level. The (unreachable) fallback in `Cpe::with_any_version` now logs instead of silently returning the original CPE. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NVD feeds add thousands of CPE identity rows; without a secondary index the identity joins in CPE matching degrade to sequential scans. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a disabled `nvd-from-2024` example importer with reasonable defaults so the NVD importer can be tried easily. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unit-test resolve_years (explicit set precedence, start-year range, 1999 default) and exercise run_once_nvd end-to-end against a wiremock server serving an xz-compressed year feed, including the unchanged-sha256 skip path driven by the continuation state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Hey @jcrossley3, thanks for having a look at it. I'm aware that the PR exploded in size and I'm sorry about that. I couldn't find a proper way to split the PR in reasonable chunks without burdening you folks with a lot of PRs and context switching :/ (except maybe making the nvd importer a self contained PR) Anyway i followed up on you suggestion and added
|
|
/scale-test |
|
🛠️ Scale test has started! Follow the progress here: Workflow Run |
Motivation:
Today an SBOM mostly matches advisories through PURLS. For embedded components or small ecosystems, matching by CPE remains the only good tool for now. Hence this PR aims to extend trustify with matching by CPE as well as an importer for ingesting from NVD.
Changes
NVD importer
Schema
Summary by Sourcery
Implement CPE-based vulnerability applicability across ingestion and matching, including new CPE parsing and storage primitives, and add an NVD importer that feeds semver-range CPE status into SBOM and vulnerability views.
New Features:
Bug Fixes:
Enhancements:
Tests:
Chores: