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
SBOM components with
versionset are silently dropped from vulnerability matching when their PURL omits@versionDescription
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-populatedpkg.Version— the function even carries aTODO(v2): SBOM special casemarking this path as temporary. When a CycloneDX component carries"version": "4.17.16"but its PURL is the spec-validpkg: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_PURLsincmd/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 populatedpkg.Versionfromcomponent.version(osv-scalibrextractor/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 movedName()andEcosystem()off the PURL cache, leavingVersion()as the remaining SBOM special case.docs/scan-source.mdstates "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.MakePackageURLreproduces the original version-less PURL;PackageVersionis parsed separately) though I only demonstrated CycloneDX.Reproduction
The only difference is the
@versionsuffix 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 displayingversion: ""with zero vulnerabilities (vulnerability_result.go:58reports through the sameimodels.Version()); matching itself never runs, because the filter already dropped the component.Expected behavior
A component with name, ecosystem, and a concrete
component.versionshould be matched against OSV even when its PURL omits the optional version fragment; at minimum the filter should fall back topkg.Versionbefore declaring the package unscannable.Suggested fix
Keep PURL precedence, fall back only when empty — leaving the existing fall-through intact:
This preserves current behavior for version-bearing PURLs and avoids changing PURL-vs-component precedence for existing inputs.
Environment
3ae687b7c5e7dc04bc68ca270c3fd1d605bab75a(post-v2.5.1); the SBOM branch ofVersion()is present at current HEAD (markedTODO(v2)in-source)