Skip to content

fix(scan): Maven/Java SBOMs reported zero CVEs — PURL match mismatch - #523

Merged
haksungjang merged 3 commits into
mainfrom
fix/maven-purl-cve-matching
Jul 25, 2026
Merged

fix(scan): Maven/Java SBOMs reported zero CVEs — PURL match mismatch#523
haksungjang merged 3 commits into
mainfrom
fix/maven-purl-cve-matching

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

Symptom

A real Spring Boot SBOM uploaded to the Demo Sandbox showed 0 vulnerabilities, while trivy sbom on the exact same file finds 65. Every Maven/Java component was logged as trivy_finding_skipped_unknown_component, inserted=0 unique_pairs=65.

Root cause

Two PURL-format mismatches in the finding→component matcher (services/vulnerability_matching.py), so an exact-string PURL lookup never hit:

stored component (from SBOM) Trivy finding (_build_purl)
separator pkg:maven/org.springframework/spring-context@6.1.1 pkg:maven/org.springframework%3Aspring-context@6.1.1
qualifier …@6.1.1?type=jar …@6.1.1 (none)
  • Trivy reports Maven PkgName as groupId:artifactId (a colon). _build_purl only split a namespace on /, so it percent-encoded the colon to %3A instead of the canonical group/artifact path.
  • The lookup compared purl_with_version by exact string, so the ?type=jar qualifier (cdxgen/BomLens emit it, Trivy omits it) also broke the match.

This affects both source scans and SBOM uploads for JVM ecosystems (maven/gradle/jar/pom) — any Java project reported 0 CVEs.

Fix

  • _build_purl: split Maven groupId:artifactId on the colon onto the PURL namespace path (pkg:maven/group/artifact@ver). Other ecosystems unchanged.
  • Lookup: match ignoring PURL qualifiers — split_part(purl_with_version, '?', 1) == purl; .first() (not scalar_one_or_none) since two rows can legitimately share a core PURL.

Tests

  • _build_purl unit fixtures now use the real groupId:artifactId colon form (Spring, Tomcat, Logback). The prior fixture used a slash — the exact synthetic-fixture blind spot hardening rule H-1 warns about. Verified locally: maven colon → pkg:maven/group/artifact@ver; npm/golang/pypi unchanged.
  • Follow-up (noted in CHANGELOG): a Maven persist-boundary integration test with a real Trivy fixture + a ?type=jar component (the existing rematch integration test only covers npm, which has no colon/qualifier issue — which is why this slipped through).

Verification

Will be verified end-to-end on the live demo after release: re-upload the Spring Boot SBOM → expect ~65 CVEs surfaced instead of 0.

Trivy names Maven packages groupId:artifactId (a colon). _build_purl only
split a namespace on '/', so the colon was percent-encoded to
'group%3Aartifact' instead of the canonical 'group/artifact', and the
finding->component lookup compared purl_with_version by EXACT string. A stored
component 'pkg:maven/g/a@v?type=jar' therefore never matched the Trivy finding
'pkg:maven/g%3Aa@v', so every Maven/Java component was skipped as an unknown
component and the project showed 0 vulnerabilities even when Trivy found many
(observed: a Spring Boot SBOM where trivy found 65, TRUSCA persisted 0).

- _build_purl: for maven, split groupId:artifactId on the colon onto the PURL
  namespace path.
- lookup: match ignoring PURL qualifiers (?type=jar etc.) via
  split_part(purl_with_version,'?',1) == purl; .first() (not scalar_one_or_none)
  since two rows can share a core PURL.
- tests: _build_purl fixtures now use the REAL groupId:artifactId colon form.
  The prior slash fixture masked the bug (hardening rule H-1).

Affects both source scans and SBOM uploads for JVM ecosystems (maven/gradle/
jar/pom).
Address security review of the qualifier-insensitive PURL match:

- The qualifier-strip lookup was UNSCOPED and used .first() with no ORDER BY.
  purl_with_version is globally unique, so stripping the qualifier could match
  a ComponentVersion from a DIFFERENT project/scan and bind the finding to it,
  and a nondeterministic pick flipped cv.id between rematch runs, resetting the
  X1 SLA-aging key. Now JOIN ScanComponent and constrain to the current scan
  (both source and ingest persist ScanComponent before Trivy matching; rematch
  keeps the original scan's rows), ORDER BY purl_with_version + LIMIT 1 for a
  deterministic single row, and keep scalar_one_or_none (LIMIT 1 => <=1 row).
  Scoping also shrinks the candidate set to the scan's components, so stripping
  the qualifier per row no longer defeats the global unique index.

- test_vulnerability_rematch_db: seed a ScanComponent so the scoped lookup
  resolves (a real scan always writes it before matching).

Known follow-up (CHANGELOG): a scan carrying two qualifier variants of one GAV
attaches the CVE to only the first; a Maven persist-boundary integration test
with a ?type=jar component + real Trivy colon fixture.
persist_trivy_findings now scopes its component lookup to the scan's
ScanComponent set. This test called it directly while seeding only
Component/ComponentVersion (no ScanComponent), so every lookup would miss and
inserted==0, failing the X1 SLA assertions. Seed a ScanComponent per
(scan, component_version) — the re-scan SLA cases reuse the same versions
across scan1/scan2, so link every scan expected to match. Mirrors what a real
scan writes (persist_sbom_components) before Trivy matching.
@haksungjang
haksungjang merged commit a24173d into main Jul 25, 2026
30 of 31 checks passed
@haksungjang
haksungjang deleted the fix/maven-purl-cve-matching branch July 25, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant