Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
65 changes: 58 additions & 7 deletions apps/backend/services/vulnerability_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
"""
Expand All @@ -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 :]
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
74 changes: 50 additions & 24 deletions apps/backend/tests/integration/scan/test_first_detected_sla_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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())
Expand Down
28 changes: 22 additions & 6 deletions apps/backend/tests/integration/test_vulnerability_rematch_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -213,19 +219,29 @@ 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",
name=pkg_name,
)
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()
Expand Down
28 changes: 26 additions & 2 deletions apps/backend/tests/unit/services/test_vulnerability_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
Loading