diff --git a/CHANGELOG.md b/CHANGELOG.md index 064a503c..0b3907c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Fixed +- **Maven / Java scans now report their vulnerabilities (they previously showed + zero).** Trivy names Maven packages `groupId:artifactId` (a colon), but the + finding→component matcher percent-encoded that colon (`group%3Aartifact`) + instead of splitting it onto the PURL namespace path (`group/artifact`), and + it compared PURLs by exact string — so a stored + `pkg:maven/g/a@v?type=jar` never matched a Trivy finding. Every Maven/Java + component was skipped as "unknown component", so a Java SBOM (uploaded or + source-scanned) reported **0 CVEs even when Trivy found many** (e.g. a Spring + Boot SBOM: Trivy found 65, TRUSCA persisted 0). `_build_purl` now splits the + Maven colon, and the component lookup ignores PURL qualifiers (`?type=jar` + etc. — Trivy omits them, cdxgen/BomLens include them, and they are not part of + the package identity). The `_build_purl` unit fixtures now use the real + `groupId:artifactId` form (hardening rule **H-1**: a synthetic slash fixture + had masked this). Follow-up: a Maven persist-boundary integration test with a + real Trivy fixture + `?type=jar` component. + +## [0.19.1] — 2026-07-25 + ### Fixed - **SBOM ingest no longer 500s on a fresh deployment.** The backend runs as a non-root user while the worker runs as root, and they share the `/workspace` diff --git a/apps/backend/services/vulnerability_matching.py b/apps/backend/services/vulnerability_matching.py index 80007d6e..25a00a0a 100644 --- a/apps/backend/services/vulnerability_matching.py +++ b/apps/backend/services/vulnerability_matching.py @@ -254,8 +254,8 @@ def _build_purl( → "pkg:npm/lodash@4.17.20" ("npm", "@types/node", "20.0.0") → "pkg:npm/%40types/node@20.0.0" - ("maven", "org.apache.commons.text", "1.10.0") - → "pkg:maven/org.apache.commons/text@1.10.0" + ("maven", "org.apache.commons:commons-text", "1.10.0") + → "pkg:maven/org.apache.commons/commons-text@1.10.0" ("gomod", "github.com/foo/bar", "v1.2.3") → "pkg:golang/github.com/foo/bar@v1.2.3" """ @@ -281,9 +281,24 @@ def _build_purl( if not name or not version: return None - # Namespace split for ecosystems that carry one in cdxgen output. + # Namespace split for ecosystems that carry one in cdxgen / SBOM output. namespace: str | None = None - if purl_type in _NAMESPACED_TYPES and "/" in name: + if purl_type == "maven" and ":" in name: + # Trivy reports Maven packages as ``groupId:artifactId`` (exactly one + # colon separates the two coordinates). The canonical Maven PURL — and + # what cdxgen / BomLens emit — is ``pkg:maven/{groupId}/{artifactId}``, + # so split on that colon and put the groupId on the namespace path. + # Without this the colon was percent-encoded (``group%3Aartifact``), + # which never string-matched the stored ``group/artifact`` PURL, so + # EVERY Maven finding was skipped as an unknown component — a Java SBOM + # (source scan or upload) always reported zero vulnerabilities. This is + # the class hardening rule H-1 warns about: a synthetic Trivy fixture + # whose PkgName already used ``/`` masked the real ``group:artifact``. + group, _, artifact = name.partition(":") + if group and artifact: + namespace = group + name = artifact + elif purl_type in _NAMESPACED_TYPES and "/" in name: first_slash = name.find("/") namespace_part = name[:first_slash] remainder = name[first_slash + 1 :] @@ -830,7 +845,11 @@ def persist_trivy_findings( A malformed individual entry (non-dict, missing fields, unknown ecosystem) is skipped with an INFO log — never aborts the loop. """ - from models import ComponentVersion, VulnerabilityFinding # local import + from models import ( # local import + ComponentVersion, + ScanComponent, + VulnerabilityFinding, + ) if not isinstance(trivy_report, dict): log.warning( @@ -896,10 +915,42 @@ def persist_trivy_findings( ) continue + # Match against a component THIS scan actually detected, ignoring + # PURL qualifiers (``?type=jar`` etc.): Trivy omits qualifiers while + # cdxgen / BomLens include them, and the packaging ``type`` is not + # part of package identity for CVE matching. ``_build_purl`` already + # returns a qualifier-less PURL, so compare it against the stored + # PURL with everything from the first ``?`` stripped. + # + # The lookup is JOINed to ``ScanComponent`` and constrained to this + # scan for two reasons: (1) ``purl_with_version`` is globally unique, + # so an UNSCOPED qualifier-stripped match could return a component + # version belonging to a DIFFERENT project/scan and bind the finding + # to it; scoping makes that impossible by construction. (2) The + # candidate set is just this scan's components (indexed by + # ``scan_id``), so stripping the qualifier per-row stays cheap. + # ``ORDER BY purl_with_version`` + ``LIMIT 1`` makes the pick + # DETERMINISTIC across re-match runs — otherwise, when a scan carries + # two qualifier variants of one GAV (e.g. ``jffi@1.3.1`` and + # ``jffi@1.3.1?classifier=native``), a non-deterministic row would + # flip ``cv.id`` between runs and reset the X1 SLA-aging key. With + # ``LIMIT 1`` the result is at most one row, so ``scalar_one_or_none`` + # is exact. (A scan carrying such variants attaches the CVE to only + # the first variant; attaching to every in-scan variant is a tracked + # follow-up.) cv = session.execute( - select(ComponentVersion).where( - ComponentVersion.purl_with_version == purl + select(ComponentVersion) + .join( + ScanComponent, + ScanComponent.component_version_id == ComponentVersion.id, + ) + .where(ScanComponent.scan_id == scan_uuid) + .where( + func.split_part(ComponentVersion.purl_with_version, "?", 1) + == purl ) + .order_by(ComponentVersion.purl_with_version) + .limit(1) ).scalar_one_or_none() if cv is None: log.info( diff --git a/apps/backend/tests/integration/scan/test_first_detected_sla_db.py b/apps/backend/tests/integration/scan/test_first_detected_sla_db.py index 306c6317..f8faaaca 100644 --- a/apps/backend/tests/integration/scan/test_first_detected_sla_db.py +++ b/apps/backend/tests/integration/scan/test_first_detected_sla_db.py @@ -33,7 +33,12 @@ from sqlalchemy import create_engine, delete, func, select from sqlalchemy.orm import Session, sessionmaker -from models import Component, ComponentVersion, VulnerabilityFinding +from models import ( + Component, + ComponentVersion, + ScanComponent, + VulnerabilityFinding, +) from services.vulnerability_matching import persist_trivy_findings BACKEND_ROOT = Path(__file__).resolve().parent.parent.parent.parent @@ -138,36 +143,57 @@ async def _build() -> tuple[uuid.UUID, list[uuid.UUID]]: return asyncio.run(_build()) -def _seed_fixture_component_versions(session: Session) -> None: - """Insert Component/ComponentVersion rows matching the fixture's PURLs. - - ``persist_trivy_findings`` matches by ``purl_with_version`` — cdxgen owns - the component graph in production; here we stand it up directly so the - recorded Trivy report resolves against real rows. +def _seed_fixture_component_versions( + session: Session, scan_ids: list[uuid.UUID] +) -> None: + """Insert Component/ComponentVersion rows matching the fixture's PURLs and + link each ``scan_id`` to them via ``ScanComponent``. + + ``persist_trivy_findings`` matches by ``purl_with_version`` AND scopes the + lookup to the scan's ``ScanComponent`` set (so a qualifier-less PURL match + can never bind a finding to another project's component). cdxgen owns the + component graph in production and writes ScanComponent before matching; here + we stand both up directly so the recorded Trivy report resolves. The re-scan + SLA tests reuse the same versions across ``scan1``/``scan2``, so every scan + that is expected to match must get its own ScanComponent link. """ for package_type, name, version, purl_with_version in _FIXTURE_PURLS: purl = purl_with_version.rsplit("@", 1)[0] - existing = session.execute( + cv = session.execute( select(ComponentVersion).where( ComponentVersion.purl_with_version == purl_with_version ) ).scalar_one_or_none() - if existing is not None: - continue - component = session.execute( - select(Component).where(Component.purl == purl) - ).scalar_one_or_none() - if component is None: - component = Component(purl=purl, package_type=package_type, name=name) - session.add(component) - session.flush() - session.add( - ComponentVersion( + if cv is None: + component = session.execute( + select(Component).where(Component.purl == purl) + ).scalar_one_or_none() + if component is None: + component = Component( + purl=purl, package_type=package_type, name=name + ) + session.add(component) + session.flush() + cv = ComponentVersion( component_id=component.id, version=version, purl_with_version=purl_with_version, ) - ) + session.add(cv) + session.flush() + for scan_id in scan_ids: + linked = session.execute( + select(ScanComponent).where( + ScanComponent.scan_id == scan_id, + ScanComponent.component_version_id == cv.id, + ) + ).scalar_one_or_none() + if linked is None: + session.add( + ScanComponent( + scan_id=scan_id, component_version_id=cv.id + ) + ) session.commit() @@ -214,7 +240,7 @@ def _capture_first_detected( def test_rescan_carries_first_detected_forward(sync_session: Session) -> None: project_id, (scan1, scan2) = _seed_project_with_scans(2) - _seed_fixture_component_versions(sync_session) + _seed_fixture_component_versions(sync_session, [scan1, scan2]) backdated = datetime.now(UTC).replace(microsecond=0) - timedelta(days=30) # Scan 1: fresh persist — every finding stamps a non-NULL clock start. @@ -250,7 +276,7 @@ def test_rescan_falls_back_to_created_at_for_legacy_null_rows( ) -> None: """Pre-0041 rows (NULL first_detected_at) inherit via COALESCE(created_at).""" project_id, (scan1, scan2) = _seed_project_with_scans(2) - _seed_fixture_component_versions(sync_session) + _seed_fixture_component_versions(sync_session, [scan1, scan2]) legacy_created = datetime.now(UTC).replace(microsecond=0) - timedelta(days=90) persist_trivy_findings(sync_session, scan_uuid=scan1, trivy_report=_trivy_report()) @@ -281,7 +307,7 @@ def test_rematch_sequence_preserves_first_detected_on_single_scan_project( only the captured ``prior_first_detected`` overlay can preserve the clock — exactly the rematch task's sequence.""" project_id, (scan1,) = _seed_project_with_scans(1) - _seed_fixture_component_versions(sync_session) + _seed_fixture_component_versions(sync_session, [scan1]) backdated = datetime.now(UTC).replace(microsecond=0) - timedelta(days=45) persist_trivy_findings(sync_session, scan_uuid=scan1, trivy_report=_trivy_report()) @@ -316,7 +342,7 @@ def test_rematch_without_prior_capture_resets_clock(sync_session: Session) -> No clock — proving the capture is what carries it (not an accident of the group query).""" project_id, (scan1,) = _seed_project_with_scans(1) - _seed_fixture_component_versions(sync_session) + _seed_fixture_component_versions(sync_session, [scan1]) backdated = datetime.now(UTC).replace(microsecond=0) - timedelta(days=45) persist_trivy_findings(sync_session, scan_uuid=scan1, trivy_report=_trivy_report()) diff --git a/apps/backend/tests/integration/test_vulnerability_rematch_db.py b/apps/backend/tests/integration/test_vulnerability_rematch_db.py index f22416fb..c4ddddbf 100644 --- a/apps/backend/tests/integration/test_vulnerability_rematch_db.py +++ b/apps/backend/tests/integration/test_vulnerability_rematch_db.py @@ -46,7 +46,13 @@ from httpx import ASGITransport, AsyncClient from integrations.trivy import TrivyFailed, TrivyResult -from models import Component, ComponentVersion, Scan, VulnerabilityFinding +from models import ( + Component, + ComponentVersion, + Scan, + ScanComponent, + VulnerabilityFinding, +) from services.source_preservation_service import preserve_scan_source from tests._helpers import ( make_membership, @@ -213,7 +219,11 @@ async def test_rematch_surfaces_new_critical_cve( scan = await make_scan(session, project=project, status="succeeded") # Seed the catalog Component + ComponentVersion the SBOM names — the # persistence path skips unknown purls (logged + no row inserted), so - # without these rows the rematch would silently no-op. + # without these rows the rematch would silently no-op. It also needs a + # ScanComponent linking this scan to the version: persist_trivy_findings + # scopes its component lookup to the current scan (so a qualifier-less + # PURL match can never bind a finding to another project's component), + # and a real scan always writes ScanComponent before Trivy matching. component = Component( purl=f"pkg:npm/{pkg_name}", package_type="npm", @@ -221,11 +231,17 @@ async def test_rematch_surfaces_new_critical_cve( ) session.add(component) await session.flush() + component_version = ComponentVersion( + component_id=component.id, + version=_PKG_VERSION, + purl_with_version=pkg_purl, + ) + session.add(component_version) + await session.flush() session.add( - ComponentVersion( - component_id=component.id, - version=_PKG_VERSION, - purl_with_version=pkg_purl, + ScanComponent( + scan_id=scan.id, + component_version_id=component_version.id, ) ) await session.commit() diff --git a/apps/backend/tests/unit/services/test_vulnerability_matching.py b/apps/backend/tests/unit/services/test_vulnerability_matching.py index 17b986ed..dc8a2080 100644 --- a/apps/backend/tests/unit/services/test_vulnerability_matching.py +++ b/apps/backend/tests/unit/services/test_vulnerability_matching.py @@ -155,6 +155,25 @@ def test_normalize_references_non_list_returns_empty() -> None: [ ("npm", "lodash", "4.17.20", "pkg:npm/lodash@4.17.20"), ("pypi", "django", "4.2.0", "pkg:pypi/django@4.2.0"), + # Maven — REAL Trivy PkgName is ``groupId:artifactId`` (a COLON). An + # earlier fixture here used a slash (``org.apache.commons/commons-text``), + # which is NOT what Trivy emits and masked the bug where the colon was + # percent-encoded to ``%3A`` and never matched the stored ``group/artifact`` + # PURL — Java SBOMs reported zero vulnerabilities (hardening rule H-1: + # a synthetic fixture hid a real-tool format). Both forms must resolve. + ( + "maven", + "org.apache.commons:commons-text", + "1.10.0", + "pkg:maven/org.apache.commons/commons-text@1.10.0", + ), + ( + "maven", + "org.springframework:spring-context", + "6.1.1", + "pkg:maven/org.springframework/spring-context@6.1.1", + ), + # Defensive: a slash-form maven name (not emitted by Trivy) still splits. ( "maven", "org.apache.commons/commons-text", @@ -180,11 +199,16 @@ def test_build_purl_ecosystem_alias_maps() -> None: # Trivy uses "pip" for python; we normalise to "pypi" because cdxgen # writes pkg:pypi/... assert _build_purl("pip", "django", "4.2.0") == "pkg:pypi/django@4.2.0" - # "jar" / "gradle" all collapse to "maven". + # "jar" / "gradle" all collapse to "maven", and the real Trivy + # ``groupId:artifactId`` colon form resolves to the canonical slash PURL. assert ( - _build_purl("jar", "org.foo/bar", "1.0") + _build_purl("jar", "org.foo:bar", "1.0") == "pkg:maven/org.foo/bar@1.0" ) + assert ( + _build_purl("gradle", "ch.qos.logback:logback-classic", "1.4.11") + == "pkg:maven/ch.qos.logback/logback-classic@1.4.11" + ) # W6-#41 follow-up: Trivy 0.50+ ``trivy sbom`` emits hyphenated