Skip to content

Commit 2a93d03

Browse files
bomly-guyclaude
andcommitted
fix(osv): keep OTP applications off Hex identity
Review feedback on the Erlang mapping, both correct. Mapping the erlang ecosystem to hex claimed Hex provenance for OTP applications, which are discovered from *.app manifests and ship with the runtime or release rather than resolving from Hex. Syft already emits a distinct pkg:otp for them; the ecosystem-level case was overriding that, so a name collision with a real Hex package could produce a false advisory match. Map hex at the package-manager level only (rebar, mix), give otp its own type, and leave a bare erlang value — with no manager to say which registry applies — on the non-spec pkg:erlang rather than guessing. Drop the same erlang -> Hex inference from ecosystemToOSV, so the name fallback cannot reintroduce it. Erlang coverage is unaffected: pkg:hex/cowboy@2.10.0 still returns two advisories from the live API. Mapping pkg:hex back to Elixir on SBOM ingest was also wrong. The standard codecs do not carry Component.Ecosystem — CycloneDX drops it, SPDX rebuilds it from the PURL — so every round-tripped Erlang dependency would have come back as Elixir and been labelled Mix. Leave the ambiguous type unresolved and map pkg:otp, which names exactly one ecosystem, to Erlang. Added an encode/decode round-trip test over both codecs that fails on the mislabel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e82cc5c commit 2a93d03

6 files changed

Lines changed: 218 additions & 27 deletions

File tree

internal/matchers/osv/matcher.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,9 +607,12 @@ func ecosystemToOSV(eco string) string {
607607
case "scala":
608608
// Scala artifacts publish to Maven Central.
609609
return "Maven"
610-
case "elixir", "erlang":
611-
// Both publish to Hex.
610+
case "elixir":
612611
return "Hex"
612+
// Deliberately absent: erlang. It spans Hex (rebar) and OTP (*.app), and
613+
// only the PURL says which — rebar dependencies already carry pkg:hex and
614+
// match on that. Naming Hex here would query OTP runtime applications as
615+
// Hex packages, where a name collision produces a false advisory match.
613616
case "ocaml":
614617
return "opam"
615618
case "github-actions":

internal/matchers/osv/matcher_test.go

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -476,22 +476,41 @@ func TestDeclaredEcosystemsAreQueryable(t *testing.T) {
476476
t.Fatal("OSV should declare the ecosystems osv.dev covers")
477477
}
478478

479+
// Coverage is per package manager, not per ecosystem: erlang is covered
480+
// through rebar (pkg:hex) while OTP applications shipped with the runtime
481+
// are not in OSV at all, so one queryable manager is what the declaration
482+
// actually claims.
479483
for _, eco := range declared {
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
484+
var managers []sdk.PackageManager
485+
for _, manager := range sdk.AllPackageManagers() {
486+
if manager.Ecosystem() == eco {
487+
managers = append(managers, manager)
488+
}
489489
}
490-
if osvResolvesPURL(purl) {
490+
if len(managers) == 0 {
491+
t.Errorf("descriptor declares %q but no package manager resolves to it", eco)
491492
continue
492493
}
493-
if ecosystemToOSV(string(eco)) == "" {
494-
t.Errorf("descriptor declares %q but %q is not an OSV PURL type and ecosystemToOSV has no name for it", eco, purl)
494+
495+
queryable := false
496+
for _, manager := range managers {
497+
dep := &sdk.Dependency{Coordinates: sdk.Coordinates{
498+
Name: "example",
499+
Version: "1.0.0",
500+
Ecosystem: eco,
501+
PackageManager: manager,
502+
}}
503+
purl := sdk.CanonicalPackageURLFromDependency(dep)
504+
if purl == "" {
505+
t.Errorf("descriptor declares %q but %q produces no canonical PURL", eco, manager)
506+
continue
507+
}
508+
if osvResolvesPURL(purl) || ecosystemToOSV(string(eco)) != "" {
509+
queryable = true
510+
}
511+
}
512+
if !queryable {
513+
t.Errorf("descriptor declares %q but none of its package managers produce an OSV PURL type, and ecosystemToOSV has no name for it", eco)
495514
}
496515
}
497516
}
@@ -523,6 +542,50 @@ func TestBuildQueryFallsBackWhenPURLTypeIsNotOSVIndexed(t *testing.T) {
523542
}
524543
}
525544

545+
// OTP applications are discovered from *.app manifests and ship with the
546+
// runtime rather than resolving from Hex. Querying them as Hex packages — by
547+
// PURL or by name — risks a false advisory match on a name collision, so they
548+
// must stay on their own unindexed pkg:otp identity. Rebar dependencies do
549+
// resolve from Hex and must keep matching.
550+
func TestBuildQueryDoesNotQueryOTPApplicationsAsHex(t *testing.T) {
551+
otp := &sdk.Dependency{Coordinates: sdk.Coordinates{
552+
Name: "kernel",
553+
Version: "9.2",
554+
Ecosystem: sdk.EcosystemErlang,
555+
PackageManager: sdk.PackageManagerOTP,
556+
}}
557+
purl := sdk.CanonicalPackageURLFromDependency(otp)
558+
if purl != "pkg:otp/kernel@9.2" {
559+
t.Fatalf("OTP PURL = %q, want %q", purl, "pkg:otp/kernel@9.2")
560+
}
561+
562+
_, query, ok := buildQuery(otp, purl)
563+
if !ok {
564+
t.Fatal("expected a query to be built")
565+
}
566+
var purlPkg PurlPackage
567+
if err := json.Unmarshal(query.Package, &purlPkg); err != nil {
568+
t.Fatalf("expected PurlPackage JSON, got a name query: %v", err)
569+
}
570+
if purlPkg.Purl != "pkg:otp/kernel@9.2" {
571+
t.Errorf("PURL = %q, want %q", purlPkg.Purl, "pkg:otp/kernel@9.2")
572+
}
573+
574+
rebar := &sdk.Dependency{Coordinates: sdk.Coordinates{
575+
Name: "cowboy",
576+
Version: "2.10.0",
577+
Ecosystem: sdk.EcosystemErlang,
578+
PackageManager: sdk.PackageManagerRebar,
579+
}}
580+
rebarPURL := sdk.CanonicalPackageURLFromDependency(rebar)
581+
if rebarPURL != "pkg:hex/cowboy@2.10.0" {
582+
t.Fatalf("rebar PURL = %q, want %q", rebarPURL, "pkg:hex/cowboy@2.10.0")
583+
}
584+
if !osvResolvesPURL(rebarPURL) {
585+
t.Error("rebar dependencies resolve from Hex and must stay queryable")
586+
}
587+
}
588+
526589
// When neither the PURL type nor the ecosystem is known to OSV, keep sending
527590
// the PURL: it costs one slot in a batch we are already making, and dropping
528591
// the package would lose the only signal we have.

internal/sbom/identity.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@ func parsePURL(value string) *packageurl.PackageURL {
1515
// whose spec name differs from the Bomly ecosystem name. Without these, a PURL
1616
// Bomly itself emitted would not round-trip through SBOM ingest: ParseEcosystem
1717
// only knows Bomly's own identifiers.
18+
//
19+
// Types that two ecosystems share are deliberately absent. pkg:hex is emitted
20+
// for both Elixir (mix) and Erlang (rebar), and nothing in the PURL says which;
21+
// since the standard codecs do not carry Component.Ecosystem — CycloneDX drops
22+
// it and SPDX rebuilds it from the PURL — guessing here would relabel every
23+
// round-tripped Erlang dependency as Elixir, and packageManagerForPURLType
24+
// would then call it Mix. Leaving it unknown keeps the ambiguity visible.
1825
var purlTypeEcosystems = map[string]sdk.Ecosystem{
1926
"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,
27+
// pkg:otp, unlike pkg:hex, names exactly one ecosystem.
28+
"otp": sdk.EcosystemErlang,
2329
"hackage": sdk.EcosystemHaskell,
2430
"cran": sdk.EcosystemR,
2531
"opam": sdk.EcosystemOCaml,
@@ -33,8 +39,9 @@ var purlTypeEcosystems = map[string]sdk.Ecosystem{
3339
"conan": sdk.EcosystemCPP,
3440
"cocoapods": sdk.EcosystemSwift,
3541
"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.
42+
// pkg:maven covers Scala too and is ambiguous in the same way as pkg:hex,
43+
// but ParseEcosystem already resolved it to maven before this table
44+
// existed; dropping it now would regress every Java SBOM to unknown.
3845
"maven": sdk.EcosystemMaven,
3946
"githubactions": sdk.EcosystemGitHub,
4047
}

internal/sbom/sbom_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,15 +734,106 @@ func mustSyftJSONFixture(t *testing.T) []byte {
734734
// type whose spec name differs from the ecosystem name (pkg:deb for dpkg,
735735
// pkg:cran for r, ...) needs an entry in purlTypeEcosystems. See issue #317.
736736
func TestEcosystemFromPURLTypeRoundTripsEmittedPURLs(t *testing.T) {
737+
// pkg:hex is emitted for both Elixir (mix) and Erlang (rebar) and nothing
738+
// in the PURL says which, so it is deliberately left unresolved rather
739+
// than guessed. Everything else must round-trip.
740+
ambiguous := map[string]bool{"hex": true}
741+
737742
for _, manager := range sdk.AllPackageManagers() {
738743
ecosystem := manager.Ecosystem()
739744
if ecosystem == sdk.EcosystemUnknown {
740745
continue
741746
}
742747
purlType := sdk.PackageURLTypeForValues(ecosystem, manager)
748+
if ambiguous[purlType] {
749+
continue
750+
}
743751
got := ecosystemFromPURLType(purlType)
744752
if got == sdk.EcosystemUnknown {
745753
t.Errorf("ecosystemFromPURLType(%q) = unknown; %q packages would lose their ecosystem on SBOM ingest", purlType, ecosystem)
746754
}
747755
}
748756
}
757+
758+
// The standard codecs do not carry Component.Ecosystem — CycloneDX drops it and
759+
// SPDX rebuilds it from the PURL — so a change to the emitted purl type can
760+
// silently relabel a package on the way back in. OTP applications must survive
761+
// as Erlang, and a Hex dependency must not come back as the wrong ecosystem.
762+
func TestEncodeDecodeRoundTripPreservesErlangIdentity(t *testing.T) {
763+
targets := []Target{TargetSPDX23JSON, TargetCycloneDX16JSON}
764+
765+
cases := []struct {
766+
name string
767+
manager sdk.PackageManager
768+
depName string
769+
version string
770+
wantPURL string
771+
wantEcosystem sdk.Ecosystem
772+
}{{
773+
name: "otp application",
774+
manager: sdk.PackageManagerOTP,
775+
depName: "kernel",
776+
version: "9.2",
777+
wantPURL: "pkg:otp/kernel@9.2",
778+
wantEcosystem: sdk.EcosystemErlang,
779+
}, {
780+
// pkg:hex cannot say whether it came from rebar or mix, so the only
781+
// correct answer on the way back in is "unknown" — never a confident
782+
// mislabel as Elixir.
783+
name: "rebar dependency",
784+
manager: sdk.PackageManagerRebar,
785+
depName: "cowboy",
786+
version: "2.10.0",
787+
wantPURL: "pkg:hex/cowboy@2.10.0",
788+
wantEcosystem: sdk.EcosystemUnknown,
789+
}}
790+
791+
for _, tc := range cases {
792+
for _, target := range targets {
793+
t.Run(tc.name+"/"+string(target), func(t *testing.T) {
794+
dep := sdk.NewDependencyRef(tc.depName, tc.version)
795+
dep.Ecosystem = sdk.EcosystemErlang
796+
dep.PackageManager = tc.manager
797+
dep.PURL = dep.CanonicalPURL()
798+
if dep.PURL != tc.wantPURL {
799+
t.Fatalf("emitted PURL = %q, want %q", dep.PURL, tc.wantPURL)
800+
}
801+
802+
g := sdk.New()
803+
if err := g.AddNode(dep); err != nil {
804+
t.Fatalf("add node: %v", err)
805+
}
806+
807+
out, err := MarshalDepGraphJSON(g, target, BuildOptions{
808+
DocumentName: "erlang-round-trip",
809+
DocumentNS: "https://example.com/sbom/erlang-round-trip",
810+
ToolName: "bomly-cli-test",
811+
Created: time.Date(2026, 2, 28, 12, 0, 0, 0, time.UTC),
812+
}, EncodeOptions{})
813+
if err != nil {
814+
t.Fatalf("marshal %s: %v", target, err)
815+
}
816+
817+
doc, err := UnmarshalJSON(out, target)
818+
if err != nil {
819+
t.Fatalf("unmarshal %s: %v", target, err)
820+
}
821+
decoded, err := ToGraph(doc)
822+
if err != nil {
823+
t.Fatalf("to graph: %v", err)
824+
}
825+
826+
node, ok := decoded.Node(tc.wantPURL)
827+
if !ok {
828+
t.Fatalf("decoded graph has no node %q", tc.wantPURL)
829+
}
830+
if node.Ecosystem != tc.wantEcosystem {
831+
t.Errorf("ecosystem = %q, want %q", node.Ecosystem, tc.wantEcosystem)
832+
}
833+
if tc.wantEcosystem == sdk.EcosystemUnknown && node.PackageManager == sdk.PackageManagerMix {
834+
t.Errorf("ambiguous pkg:hex must not be labelled %q", sdk.PackageManagerMix)
835+
}
836+
})
837+
}
838+
}
839+
}

sdk/package_manager_test.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,11 @@ func TestPackageURLTypeForValuesUsesSpecTypes(t *testing.T) {
146146
want string
147147
}{
148148
{EcosystemErlang, PackageManagerRebar, "hex"},
149-
{EcosystemErlang, PackageManagerOTP, "hex"},
150149
{EcosystemElixir, PackageManagerMix, "hex"},
150+
// OTP applications ship with the runtime rather than resolving from
151+
// Hex, so they must not be claimed as Hex packages: a name collision
152+
// with a real Hex package would produce a false advisory match.
153+
{EcosystemErlang, PackageManagerOTP, "otp"},
151154
{EcosystemHaskell, PackageManagerCabal, "hackage"},
152155
{EcosystemHaskell, PackageManagerStack, "hackage"},
153156
{EcosystemR, PackageManagerRPackage, "cran"},
@@ -181,27 +184,38 @@ func TestPackageURLTypeForValuesUsesSpecTypes(t *testing.T) {
181184
}
182185

183186
// 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+
// container packages), so for ecosystems backed by a single registry the
188+
// ecosystem alone has to be enough to reach a purl type in the spec.
189+
//
190+
// The exceptions are the ecosystems that span two registries, where the
191+
// package manager is the only thing that says which one applies: swift covers
192+
// SwiftPM and CocoaPods, erlang covers Hex and OTP. Guessing for those would
193+
// name a registry the package may not be published to.
187194
func TestPackageURLTypeForEcosystemAlone(t *testing.T) {
195+
multiRegistry := map[Ecosystem]bool{
196+
EcosystemSwift: true,
197+
EcosystemErlang: true,
198+
}
188199
specTypes := map[string]bool{
189200
"apk": true, "cargo": true, "cocoapods": true, "composer": true,
190201
"conan": true, "cran": true, "deb": true, "gem": true,
191202
"githubactions": true, "golang": true, "hackage": true, "hex": true,
192-
"maven": true, "npm": true, "nuget": true, "opam": true,
203+
"maven": true, "npm": true, "nuget": true, "opam": true, "otp": true,
193204
"pub": true, "pypi": true, "rpm": true, "swift": true,
194205
}
195206

196207
for _, manager := range AllPackageManagers() {
197208
ecosystem := manager.Ecosystem()
209+
if multiRegistry[ecosystem] {
210+
continue
211+
}
198212
withManager := PackageURLTypeForValues(ecosystem, manager)
199213
if !specTypes[withManager] {
200214
// Ecosystems Bomly reports but the purl spec has no type for
201215
// (conda, homebrew, nix, ...) are out of scope here.
202216
continue
203217
}
204-
if got := PackageURLTypeForValues(ecosystem); got != withManager && ecosystem != EcosystemSwift {
218+
if got := PackageURLTypeForValues(ecosystem); got != withManager {
205219
t.Errorf("PackageURLTypeForValues(%q) = %q, want %q (as with manager %q)", ecosystem, got, withManager, manager)
206220
}
207221
}

sdk/purl.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ func buildPackageURLFallback(purlType, namespace, name, version string) string {
9292
// name needs an explicit case here — without one we emit a type that is not in
9393
// the purl spec, and consumers keyed on the type (OSV, SBOM ingest) silently
9494
// fail to match. See issue #317.
95+
//
96+
// Ecosystems that span more than one registry are the exception: erlang covers
97+
// both Hex (rebar) and OTP (*.app), so it is mapped at the package-manager
98+
// level only. A bare erlang value with no manager to disambiguate keeps the
99+
// non-spec pkg:erlang rather than guessing a registry the package may not be
100+
// published to.
95101
func PackageURLTypeForValues(values ...any) string {
96102
for _, value := range values {
97103
normalized := strings.ToLower(strings.TrimSpace(packageURLTypeValue(value)))
@@ -113,9 +119,16 @@ func PackageURLTypeForValues(values ...any) string {
113119
return "githubactions"
114120
case "conan", "cpp":
115121
return "conan"
116-
case "mix", "hex", "elixir", "erlang", "rebar", "otp":
117-
// Elixir and Erlang both publish to Hex.
122+
case "mix", "hex", "elixir", "rebar":
123+
// Elixir (mix) and Erlang (rebar) both resolve from Hex.
118124
return "hex"
125+
case "otp":
126+
// OTP applications are discovered from *.app manifests. They ship
127+
// with the runtime or the release rather than resolving from Hex,
128+
// so they get their own type — the same one Syft emits for them.
129+
// Claiming Hex here would let a name collision with a real Hex
130+
// package produce a false advisory match.
131+
return "otp"
119132
case "haskell", "cabal", "stack", "hackage":
120133
return "hackage"
121134
case "r", "r-package", "cran":

0 commit comments

Comments
 (0)