Skip to content

SBOM components with version set are silently dropped from vulnerability matching when their PURL omits @version #3007

Description

@bianyeyu

SBOM components with version set are silently dropped from vulnerability matching when their PURL omits @version

Description

For SBOM-sourced packages, imodels.Version() sources the version exclusively from the component's PURL round-trip (toCachedPackageInfo()purl.ToPackage(purl).Version) and does not fall back to the extractor-populated pkg.Version — the function even carries a TODO(v2): SBOM special case marking this path as temporary. When a CycloneDX component carries "version": "4.17.16" but its PURL is the spec-valid pkg:npm/lodash — the PURL specification makes the version fragment optional — the derived version is empty, and the pre-match filter (internal/scalibrannotator/filter/filter.go:81) classifies the component as unscannable and removes it from OSV matching entirely.

The scan then reports no findings and exits 0. The only signal is an aggregate INFO line (Filtered 1 local/unscannable package/s from the scan) that names neither the component nor any warning.

This differs from the intentionally-unscannable case covered by one_specific_supported_sbom_with_invalid_PURLs in cmd/osv-scanner/scan/source/command_test.go: there the PURLs are malformed; here the PURL is valid per spec, and the missing information is already in hand — the CycloneDX extractor has populated pkg.Version from component.version (osv-scalibr extractor/filesystem/sbom/cdx/cdx.go:151, falling back to the PURL version only when absent at :165), and post-#2935 matching consumes exactly that field. Only the filter's version lookup shadows it with the empty PURL-derived value — #2935 already moved Name() and Ecosystem() off the PURL cache, leaving Version() as the remaining SBOM special case. docs/scan-source.md states "SPDX and CycloneDX SBOMs using Package URLs are supported", but supporting PURLs should not mean discarding a version the document provides next to the PURL.

SPDX appears to share the same code path (spdxpurl.MakePackageURL reproduces the original version-less PURL; PackageVersion is parsed separately) though I only demonstrated CycloneDX.

Reproduction

$ mkdir treatment control
$ cat > treatment/bom.json <<'EOF'
{"bomFormat":"CycloneDX","specVersion":"1.5",
 "serialNumber":"urn:uuid:12345678-1234-1234-1234-123456789012",
 "components":[{"type":"library","name":"lodash","version":"4.17.16","purl":"pkg:npm/lodash"}]}
EOF
$ cat > control/bom.json <<'EOF'
{"bomFormat":"CycloneDX","specVersion":"1.5",
 "serialNumber":"urn:uuid:12345678-1234-1234-1234-123456789013",
 "components":[{"type":"library","name":"lodash","version":"4.17.16","purl":"pkg:npm/lodash@4.17.16"}]}
EOF

$ osv-scanner scan --format json ./treatment ; echo "exit=$?"
Filtered 1 local/unscannable package/s from the scan.
  "results": []          # truncated; exit=0

$ osv-scanner scan --format json ./control | python3 -c "import json,sys; d=json.load(sys.stdin); [print(p['package']['name'], p['package']['version'], [g['ids'] for g in p.get('groups',[])]) for r in d['results'] for p in r.get('packages',[])]"
lodash 4.17.16 [['GHSA-29mw-wpgm-hmr9'], ['GHSA-35jh-r3h4-6jhm', 'GHSA-r5fr-rjxr-66jc'], ['GHSA-f23m-r3pf-42rh', 'GHSA-xxjr-mmjv-4gpg'], ['GHSA-p6mc-m468-83gw']]
$ osv-scanner scan --format json ./control > /dev/null ; echo "exit=$?"
exit=1

The only difference is the @version suffix on the PURL: one yields a silent clean pass, the other four vulnerability groups (six advisories after alias expansion). With --all-packages, the treatment run reattaches the package displaying version: "" with zero vulnerabilities (vulnerability_result.go:58 reports through the same imodels.Version()); matching itself never runs, because the filter already dropped the component.

Expected behavior

A component with name, ecosystem, and a concrete component.version should be matched against OSV even when its PURL omits the optional version fragment; at minimum the filter should fall back to pkg.Version before declaring the package unscannable.

Suggested fix

Keep PURL precedence, fall back only when empty — leaving the existing fall-through intact:

-	if purlCache := toCachedPackageInfo(pkg); purlCache != nil {
+	if purlCache := toCachedPackageInfo(pkg); purlCache != nil && purlCache.Version != "" {
 		return purlCache.Version
 	}

This preserves current behavior for version-bearing PURLs and avoids changing PURL-vs-component precedence for existing inputs.

Environment

  • osv-scanner built from HEAD 3ae687b7c5e7dc04bc68ca270c3fd1d605bab75a (post-v2.5.1); the SBOM branch of Version() is present at current HEAD (marked TODO(v2) in-source)
  • go1.26.5 darwin/arm64

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions