Skip to content

Commit e82cc5c

Browse files
bomly-guyclaude
andcommitted
fix(osv): emit spec purl types and make the name fallback reachable
Five ecosystems declared as OSV-supported could never match. Bomly emitted a purl type outside the spec for each of them, and the name+ecosystem fallback that would have rescued them was unreachable. PackageURLTypeForValues consults an explicit switch for every value and otherwise returns the first non-empty value verbatim, so any ecosystem without an explicit case leaked its Bomly identifier into the PURL: pkg:erlang, pkg:haskell, pkg:r, pkg:ocaml, pkg:dpkg. Add explicit cases so those map to hex, hackage, cran, opam and deb. Verified against the live OSV API: pkg:cran/commonmark@1.8 and pkg:deb/debian/curl@7.64.0-4 return advisories where pkg:r/... and pkg:dpkg/... return nothing. Do the same for the ecosystems that only mapped correctly because the package manager rescued them (rust, dotnet, dart, elixir, cpp). The manager is not always populated, so the ecosystem alone now suffices. Swift is deliberately left out: it is a purl type in its own right, and an explicit case would beat cocoapods whenever the ecosystem is checked first. Because SBOM ingest recovers the ecosystem from the purl type via ParseEcosystem, which only knows Bomly's own identifiers, add an explicit inverse table so a PURL Bomly emitted still round-trips. Finally, make the OSV name+ecosystem fallback reachable: buildQuery now uses it when the canonical PURL's type is not one OSV indexes. Where neither the type nor the ecosystem is known to OSV, the PURL query is still sent, so no package loses the query it gets today. Fixes #317 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0f567df commit e82cc5c

6 files changed

Lines changed: 275 additions & 34 deletions

File tree

internal/matchers/osv/matcher.go

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99
"os"
1010
"path/filepath"
11+
"strings"
1112
"time"
1213

1314
"github.com/bomly-dev/bomly-cli/internal/logging"
@@ -198,12 +199,6 @@ func (a *Matcher) Descriptor() sdk.MatcherDescriptor {
198199
// OSS-Fuzz based rather than a Conan package ecosystem. Julia,
199200
// Bitnami, Android, and the Linux kernel are covered by OSV but have
200201
// no Bomly ecosystem to map onto.
201-
//
202-
// erlang, haskell, r, ocaml, and dpkg are listed because OSV indexes
203-
// them, but they return nothing today: we emit a PURL type OSV does
204-
// not recognise (pkg:erlang rather than pkg:hex, pkg:dpkg rather than
205-
// pkg:deb, and so on) and the name-based fallback below is
206-
// unreachable. See issue #317.
207202
SupportedEcosystems: []sdk.Ecosystem{
208203
sdk.EcosystemNPM,
209204
sdk.EcosystemMaven,
@@ -505,33 +500,84 @@ func buildQuery(dep *sdk.Dependency, purl string) (cache.Key, BatchQuery, bool)
505500
return cache.Key{}, BatchQuery{}, false
506501
}
507502

508-
// Prefer PURL
509-
if purl != "" {
503+
ecosystem := ecosystemToOSV(string(dep.Ecosystem))
504+
505+
// Prefer the PURL, but only when OSV can actually resolve its type. A PURL
506+
// whose type OSV does not index comes back empty rather than erroring, so
507+
// the package looks clean rather than unchecked; a name + ecosystem query
508+
// at least gives it a chance. With no ecosystem name to fall back to, the
509+
// PURL is still the best (and only) thing we have. See issue #317.
510+
if purl != "" && (ecosystem == "" || osvResolvesPURL(purl)) {
510511
key := cache.NewKey(purl, "", "", "")
511512
purlPkg := PurlPackage{Purl: purl}
512513
raw, _ := json.Marshal(purlPkg)
513514
return key, BatchQuery{Package: raw}, true
514515
}
515516

516517
// Fall back to name + ecosystem + version
517-
ecosystem := ecosystemToOSV(string(dep.Ecosystem))
518518
if ecosystem == "" {
519519
return cache.Key{}, BatchQuery{}, false
520520
}
521521

522-
key := cache.NewKey("", dep.Name, ecosystem, dep.Version)
523-
namePkg := NamePackage{Name: dep.Name, Ecosystem: ecosystem}
522+
name := strings.TrimSpace(dep.Name)
523+
if name == "" {
524+
return cache.Key{}, BatchQuery{}, false
525+
}
526+
527+
key := cache.NewKey("", name, ecosystem, dep.Version)
528+
namePkg := NamePackage{Name: name, Ecosystem: ecosystem}
524529
raw, _ := json.Marshal(namePkg)
525530
return key, BatchQuery{Package: raw, Version: dep.Version}, true
526531
}
527532

533+
// osvPURLTypes are the package-url types OSV resolves to one of its indexed
534+
// ecosystems. Anything outside this set (cocoapods, conan, generic, ...) has no
535+
// OSV ecosystem behind it, so a PURL query for it can only ever come back empty.
536+
//
537+
// See https://google.github.io/osv.dev/data/#covered-ecosystems.
538+
//
539+
// Distro types (deb, rpm, apk) additionally need the distro namespace to match
540+
// — pkg:deb/debian/curl@... resolves, pkg:deb/curl@... does not. Container and
541+
// image scans get that namespace from the upstream PURL; a bare dpkg package
542+
// with no PURL of its own cannot be matched, and there is no name+ecosystem
543+
// fallback for it either since OSV keys Debian advisories by release
544+
// ("Debian:12") rather than by distro alone.
545+
var osvPURLTypes = map[string]struct{}{
546+
"apk": {},
547+
"cargo": {},
548+
"composer": {},
549+
"cran": {},
550+
"deb": {},
551+
"gem": {},
552+
"githubactions": {},
553+
"golang": {},
554+
"hackage": {},
555+
"hex": {},
556+
"maven": {},
557+
"npm": {},
558+
"nuget": {},
559+
"opam": {},
560+
"pub": {},
561+
"pypi": {},
562+
"rpm": {},
563+
"swift": {},
564+
}
565+
566+
// osvResolvesPURL reports whether OSV indexes the package-url type of purl.
567+
func osvResolvesPURL(purl string) bool {
568+
parsed := sdk.ParsePackageURL(purl)
569+
if parsed == nil {
570+
return false
571+
}
572+
_, ok := osvPURLTypes[strings.ToLower(strings.TrimSpace(parsed.Type))]
573+
return ok
574+
}
575+
528576
// ecosystemToOSV maps Bomly ecosystem identifiers to OSV ecosystem names.
529577
// See: https://ossf.github.io/osv-schema/#affectedpackage-field
530578
//
531-
// Currently unreachable: buildQuery only consults this when the PURL is empty,
532-
// and the caller skips those packages before buildQuery runs. Kept and
533-
// extended so the mapping is correct for whenever the fallback is revived —
534-
// see issue #317.
579+
// Reached from buildQuery whenever the canonical PURL's type is not one OSV
580+
// indexes, which is the only way a package with a PURL can still be queried.
535581
func ecosystemToOSV(eco string) string {
536582
switch eco {
537583
case "npm":

internal/matchers/osv/matcher_test.go

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -466,28 +466,82 @@ func TestExtractSeverity_CVSSVectorTakesPrecedenceOverGHSAText(t *testing.T) {
466466
}
467467
}
468468

469-
// Every ecosystem the descriptor claims must have an OSV ecosystem name to go
470-
// with it, so the declared coverage and the query mapping cannot drift apart.
471-
// The reverse does not hold: OS ecosystems are queried by PURL rather than by
472-
// name, so they are declared without appearing in ecosystemToOSV.
469+
// Every ecosystem the descriptor claims must produce a query OSV can actually
470+
// resolve — a PURL whose type OSV indexes, or a name + ecosystem pair. A
471+
// declared ecosystem that produces neither returns empty results rather than
472+
// erroring, so it looks clean rather than unchecked. See issue #317.
473473
func TestDeclaredEcosystemsAreQueryable(t *testing.T) {
474-
viaPURL := map[sdk.Ecosystem]bool{
475-
sdk.EcosystemAPK: true,
476-
sdk.EcosystemDPKG: true,
477-
sdk.EcosystemRPM: true,
478-
}
479-
480474
declared := (&Matcher{}).Descriptor().SupportedEcosystems
481475
if len(declared) == 0 {
482476
t.Fatal("OSV should declare the ecosystems osv.dev covers")
483477
}
484478

485479
for _, eco := range declared {
486-
if viaPURL[eco] {
480+
dep := &sdk.Dependency{Coordinates: sdk.Coordinates{
481+
Name: "example",
482+
Version: "1.0.0",
483+
Ecosystem: eco,
484+
}}
485+
purl := sdk.CanonicalPackageURLFromDependency(dep)
486+
if purl == "" {
487+
t.Errorf("descriptor declares %q but it produces no canonical PURL", eco)
488+
continue
489+
}
490+
if osvResolvesPURL(purl) {
487491
continue
488492
}
489493
if ecosystemToOSV(string(eco)) == "" {
490-
t.Errorf("descriptor declares %q but ecosystemToOSV has no name for it", eco)
494+
t.Errorf("descriptor declares %q but %q is not an OSV PURL type and ecosystemToOSV has no name for it", eco, purl)
491495
}
492496
}
493497
}
498+
499+
// A PURL type OSV does not index must not be sent as a PURL query when a name +
500+
// ecosystem query is available: OSV answers the unknown type with an empty
501+
// result rather than an error.
502+
func TestBuildQueryFallsBackWhenPURLTypeIsNotOSVIndexed(t *testing.T) {
503+
dep := &sdk.Dependency{Coordinates: sdk.Coordinates{
504+
Name: "AFNetworking",
505+
Version: "4.0.1",
506+
Ecosystem: sdk.EcosystemSwift,
507+
PURL: "pkg:cocoapods/AFNetworking@4.0.1",
508+
}}
509+
510+
_, query, ok := buildQuery(dep, sdk.CanonicalPackageURLFromDependency(dep))
511+
if !ok {
512+
t.Fatal("expected a query to be built")
513+
}
514+
var namePkg NamePackage
515+
if err := json.Unmarshal(query.Package, &namePkg); err != nil {
516+
t.Fatalf("expected NamePackage JSON: %v", err)
517+
}
518+
if namePkg.Ecosystem != "SwiftURL" {
519+
t.Errorf("Ecosystem = %q, want %q", namePkg.Ecosystem, "SwiftURL")
520+
}
521+
if query.Version != "4.0.1" {
522+
t.Errorf("Version = %q, want %q", query.Version, "4.0.1")
523+
}
524+
}
525+
526+
// When neither the PURL type nor the ecosystem is known to OSV, keep sending
527+
// the PURL: it costs one slot in a batch we are already making, and dropping
528+
// the package would lose the only signal we have.
529+
func TestBuildQueryKeepsPURLWhenNoEcosystemName(t *testing.T) {
530+
dep := &sdk.Dependency{Coordinates: sdk.Coordinates{
531+
Name: "zlib",
532+
Version: "1.3",
533+
Ecosystem: sdk.EcosystemCPP,
534+
}}
535+
536+
_, query, ok := buildQuery(dep, sdk.CanonicalPackageURLFromDependency(dep))
537+
if !ok {
538+
t.Fatal("expected a query to be built")
539+
}
540+
var purlPkg PurlPackage
541+
if err := json.Unmarshal(query.Package, &purlPkg); err != nil {
542+
t.Fatalf("expected PurlPackage JSON: %v", err)
543+
}
544+
if purlPkg.Purl != "pkg:conan/zlib@1.3" {
545+
t.Errorf("PURL = %q, want %q", purlPkg.Purl, "pkg:conan/zlib@1.3")
546+
}
547+
}

internal/sbom/identity.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,40 @@ func parsePURL(value string) *packageurl.PackageURL {
1111
return sdk.ParsePackageURL(strings.TrimSpace(value))
1212
}
1313

14+
// purlTypeEcosystems inverts sdk.PackageURLTypeForValues for the purl types
15+
// whose spec name differs from the Bomly ecosystem name. Without these, a PURL
16+
// Bomly itself emitted would not round-trip through SBOM ingest: ParseEcosystem
17+
// only knows Bomly's own identifiers.
18+
var purlTypeEcosystems = map[string]sdk.Ecosystem{
19+
"golang": sdk.EcosystemGo,
20+
// Elixir and Erlang share pkg:hex; Elixir is the far more common source of
21+
// a hex PURL, and the component's own ecosystem field wins when present.
22+
"hex": sdk.EcosystemElixir,
23+
"hackage": sdk.EcosystemHaskell,
24+
"cran": sdk.EcosystemR,
25+
"opam": sdk.EcosystemOCaml,
26+
"deb": sdk.EcosystemDPKG,
27+
"cargo": sdk.EcosystemRust,
28+
"nuget": sdk.EcosystemDotNet,
29+
"pypi": sdk.EcosystemPython,
30+
"gem": sdk.EcosystemRuby,
31+
"composer": sdk.EcosystemPHP,
32+
"pub": sdk.EcosystemDart,
33+
"conan": sdk.EcosystemCPP,
34+
"cocoapods": sdk.EcosystemSwift,
35+
"swift": sdk.EcosystemSwift,
36+
// pkg:maven covers Scala too, but Java is the overwhelmingly common case
37+
// and there is nothing in the PURL to tell them apart.
38+
"maven": sdk.EcosystemMaven,
39+
"githubactions": sdk.EcosystemGitHub,
40+
}
41+
1442
func ecosystemFromPURLType(purlType string) sdk.Ecosystem {
1543
normalized := strings.ToLower(strings.TrimSpace(purlType))
44+
if ecosystem, ok := purlTypeEcosystems[normalized]; ok {
45+
return ecosystem
46+
}
1647
switch normalized {
17-
case "golang":
18-
return sdk.EcosystemGo
1948
case "":
2049
return sdk.EcosystemUnknown
2150
default:

internal/sbom/sbom_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,3 +728,21 @@ func mustSyftJSONFixture(t *testing.T) []byte {
728728
}
729729
return []byte(strings.TrimSpace(out.String()))
730730
}
731+
732+
// A PURL Bomly emitted must name an ecosystem Bomly recognises when it is read
733+
// back in. ParseEcosystem only knows Bomly's own identifiers, so every purl
734+
// type whose spec name differs from the ecosystem name (pkg:deb for dpkg,
735+
// pkg:cran for r, ...) needs an entry in purlTypeEcosystems. See issue #317.
736+
func TestEcosystemFromPURLTypeRoundTripsEmittedPURLs(t *testing.T) {
737+
for _, manager := range sdk.AllPackageManagers() {
738+
ecosystem := manager.Ecosystem()
739+
if ecosystem == sdk.EcosystemUnknown {
740+
continue
741+
}
742+
purlType := sdk.PackageURLTypeForValues(ecosystem, manager)
743+
got := ecosystemFromPURLType(purlType)
744+
if got == sdk.EcosystemUnknown {
745+
t.Errorf("ecosystemFromPURLType(%q) = unknown; %q packages would lose their ecosystem on SBOM ingest", purlType, ecosystem)
746+
}
747+
}
748+
}

sdk/package_manager_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,78 @@ func TestPackageURLTypeForGitHubActions(t *testing.T) {
135135
}
136136
}
137137

138+
// Every declared ecosystem must resolve to a type that exists in the purl spec.
139+
// Without an explicit case the fallback returns the Bomly identifier verbatim,
140+
// which produced pkg:erlang / pkg:haskell / pkg:r / pkg:ocaml / pkg:dpkg and
141+
// made those packages unmatchable downstream. See issue #317.
142+
func TestPackageURLTypeForValuesUsesSpecTypes(t *testing.T) {
143+
cases := []struct {
144+
ecosystem Ecosystem
145+
manager PackageManager
146+
want string
147+
}{
148+
{EcosystemErlang, PackageManagerRebar, "hex"},
149+
{EcosystemErlang, PackageManagerOTP, "hex"},
150+
{EcosystemElixir, PackageManagerMix, "hex"},
151+
{EcosystemHaskell, PackageManagerCabal, "hackage"},
152+
{EcosystemHaskell, PackageManagerStack, "hackage"},
153+
{EcosystemR, PackageManagerRPackage, "cran"},
154+
{EcosystemOCaml, PackageManagerOpam, "opam"},
155+
{EcosystemDPKG, PackageManagerDPKG, "deb"},
156+
157+
// Regression guards for the ecosystems that already mapped correctly.
158+
{EcosystemNPM, PackageManagerNPM, "npm"},
159+
{EcosystemGo, PackageManagerGoMod, "golang"},
160+
{EcosystemPython, PackageManagerPip, "pypi"},
161+
{EcosystemMaven, PackageManagerMaven, "maven"},
162+
{EcosystemScala, PackageManagerSBT, "maven"},
163+
{EcosystemRust, PackageManagerCargo, "cargo"},
164+
{EcosystemRuby, PackageManagerBundler, "gem"},
165+
{EcosystemPHP, PackageManagerComposer, "composer"},
166+
{EcosystemDotNet, PackageManagerNuGet, "nuget"},
167+
{EcosystemDart, PackageManagerPub, "pub"},
168+
{EcosystemSwift, PackageManagerSwiftPM, "swift"},
169+
{EcosystemSwift, PackageManagerCocoaPods, "cocoapods"},
170+
{EcosystemCPP, PackageManagerConan, "conan"},
171+
{EcosystemAPK, PackageManagerAPK, "apk"},
172+
{EcosystemRPM, PackageManagerRPM, "rpm"},
173+
{EcosystemGitHub, PackageManagerGitHubActions, "githubactions"},
174+
}
175+
176+
for _, tc := range cases {
177+
if got := PackageURLTypeForValues(tc.ecosystem, tc.manager); got != tc.want {
178+
t.Errorf("PackageURLTypeForValues(%q, %q) = %q, want %q", tc.ecosystem, tc.manager, got, tc.want)
179+
}
180+
}
181+
}
182+
183+
// The package manager is not always populated (SBOM ingest, syft-sourced
184+
// container packages), so the ecosystem alone has to be enough to reach a purl
185+
// type in the spec. CocoaPods is the one exception: it shares the swift
186+
// ecosystem, and swift is itself a valid purl type.
187+
func TestPackageURLTypeForEcosystemAlone(t *testing.T) {
188+
specTypes := map[string]bool{
189+
"apk": true, "cargo": true, "cocoapods": true, "composer": true,
190+
"conan": true, "cran": true, "deb": true, "gem": true,
191+
"githubactions": true, "golang": true, "hackage": true, "hex": true,
192+
"maven": true, "npm": true, "nuget": true, "opam": true,
193+
"pub": true, "pypi": true, "rpm": true, "swift": true,
194+
}
195+
196+
for _, manager := range AllPackageManagers() {
197+
ecosystem := manager.Ecosystem()
198+
withManager := PackageURLTypeForValues(ecosystem, manager)
199+
if !specTypes[withManager] {
200+
// Ecosystems Bomly reports but the purl spec has no type for
201+
// (conda, homebrew, nix, ...) are out of scope here.
202+
continue
203+
}
204+
if got := PackageURLTypeForValues(ecosystem); got != withManager && ecosystem != EcosystemSwift {
205+
t.Errorf("PackageURLTypeForValues(%q) = %q, want %q (as with manager %q)", ecosystem, got, withManager, manager)
206+
}
207+
}
208+
}
209+
138210
func TestAllPackageManagersReturnsCopy(t *testing.T) {
139211
managers := AllPackageManagers()
140212
if len(managers) == 0 {

0 commit comments

Comments
 (0)