From 29d4816575f560ffa29c87b77aa4829808da9df3 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Tue, 4 Aug 2026 11:01:06 -0400 Subject: [PATCH] feat: report the perl interpreter as a cpan package The `perl-binary` classifier now emits `pkg:cpan/perl@` instead of `pkg:generic/perl@`. `perl` is itself a CPAN distribution and carries advisories under that name, so typing it as `generic` left every one of them unreachable. This changes an existing purl. Anything keyed on `pkg:generic/perl` (allowlists, policy, SBOM diffs) needs updating. Builds on the cpan cataloging branch, which adds the package type this uses. Signed-off-by: Alex Goodman --- internal/capabilities/generate/merge.go | 15 ++++- syft/pkg/cataloger/binary/capabilities.yaml | 4 +- .../binary/classifier_cataloger_test.go | 64 +++++++++++++++++-- syft/pkg/cataloger/binary/classifiers.go | 4 +- .../cataloger/internal/binutils/classifier.go | 11 ++++ .../internal/binutils/classifier_package.go | 2 +- .../internal/binutils/classifier_test.go | 30 +++++++++ 7 files changed, 119 insertions(+), 11 deletions(-) diff --git a/internal/capabilities/generate/merge.go b/internal/capabilities/generate/merge.go index b5d3b9b02..678c7eb29 100644 --- a/internal/capabilities/generate/merge.go +++ b/internal/capabilities/generate/merge.go @@ -502,6 +502,19 @@ func convertToJSONSchemaTypesFromMetadata(metadataTypes []string) []string { return result } +// packageTypeConstName renders a package type using its go constant name, which is what the detector +// "type" field reports (e.g. "BinaryPkg"). +// a switch rather than a derivation, since pkg.Type values don't reliably map back to their constant +// names (e.g. "java-archive" is JavaPkg); add an arm when a classifier declares a new type. +func packageTypeConstName(t pkg.Type) string { + switch t { + case pkg.CpanPkg: + return "CpanPkg" + default: + return "BinaryPkg" + } +} + // EnrichWithBinaryClassifier enriches an entry with binary classifier detectors if it's the binary-classifier-cataloger func (e *EnrichmentData) EnrichWithBinaryClassifier(catalogerName string, entry *capabilities.CatalogerEntry) { // special handling for binary-classifier-cataloger: auto-generate one detector per classifier @@ -523,7 +536,7 @@ func (e *EnrichmentData) EnrichWithBinaryClassifier(catalogerName string, entry Name: classifier.Package, PURL: purlStr, CPEs: cpeStrings, - Type: "BinaryPkg", + Type: packageTypeConstName(classifier.PackageType()), }, } diff --git a/syft/pkg/cataloger/binary/capabilities.yaml b/syft/pkg/cataloger/binary/capabilities.yaml index e8d68b254..25fb3d7b8 100644 --- a/syft/pkg/cataloger/binary/capabilities.yaml +++ b/syft/pkg/cataloger/binary/capabilities.yaml @@ -155,10 +155,10 @@ catalogers: packages: - class: perl-binary name: perl - purl: pkg:generic/perl + purl: pkg:cpan/perl cpes: - cpe:2.3:a:perl:perl:*:*:*:*:*:*:*:* - type: BinaryPkg + type: CpanPkg - method: glob criteria: - '**/composer*' diff --git a/syft/pkg/cataloger/binary/classifier_cataloger_test.go b/syft/pkg/cataloger/binary/classifier_cataloger_test.go index 4cbd2f6f3..60a02bdc3 100644 --- a/syft/pkg/cataloger/binary/classifier_cataloger_test.go +++ b/syft/pkg/cataloger/binary/classifier_cataloger_test.go @@ -436,8 +436,8 @@ func Test_Cataloger_PositiveCases(t *testing.T) { expected: pkg.Package{ Name: "perl", Version: "5.12.5", - Type: "binary", - PURL: "pkg:generic/perl@5.12.5", + Type: "cpan", + PURL: "pkg:cpan/perl@5.12.5", Locations: locations("perl"), Metadata: metadata("perl-binary"), }, @@ -449,8 +449,8 @@ func Test_Cataloger_PositiveCases(t *testing.T) { expected: pkg.Package{ Name: "perl", Version: "5.20.0", - Type: "binary", - PURL: "pkg:generic/perl@5.20.0", + Type: "cpan", + PURL: "pkg:cpan/perl@5.20.0", Locations: locations("perl"), Metadata: metadata("perl-binary"), }, @@ -462,8 +462,8 @@ func Test_Cataloger_PositiveCases(t *testing.T) { expected: pkg.Package{ Name: "perl", Version: "5.37.8", - Type: "binary", - PURL: "pkg:generic/perl@5.37.8", + Type: "cpan", + PURL: "pkg:cpan/perl@5.37.8", Locations: locations("perl"), Metadata: metadata("perl-binary"), }, @@ -3097,6 +3097,58 @@ func Test_Cataloger_DefaultClassifiers_PositiveCases_Image(t *testing.T) { } } +// Test_Cataloger_PackageTypes covers what assertPackagesAreEqual deliberately ignores: the package type and CPEs. +// The perl interpreter is reported as cpan so it lines up with the rest of the CPAN ecosystem, everything else +// keeps the default binary type. +func Test_Cataloger_PackageTypes(t *testing.T) { + tests := []struct { + logicalFixture string + wantType pkg.Type + wantPURL string + wantCPEs []string + }{ + { + logicalFixture: "perl/5.20.0/linux-amd64", + wantType: pkg.CpanPkg, + wantPURL: "pkg:cpan/perl@5.20.0", + wantCPEs: []string{"cpe:2.3:a:perl:perl:5.20.0:*:*:*:*:*:*:*"}, + }, + { + logicalFixture: "haproxy/1.5.14/linux-amd64", + wantType: pkg.BinaryPkg, + wantPURL: "pkg:generic/haproxy@1.5.14", + wantCPEs: []string{"cpe:2.3:a:haproxy:haproxy:1.5.14:*:*:*:*:*:*:*"}, + }, + } + + for _, test := range tests { + t.Run(test.logicalFixture, func(t *testing.T) { + c := NewClassifierCataloger(DefaultClassifierCatalogerConfig()) + + path := testutil.SnippetOrBinary(t, test.logicalFixture, *mustUseOriginalBinaries) + + src, err := directorysource.NewFromPath(path) + require.NoError(t, err) + + resolver, err := src.FileResolver(source.SquashedScope) + require.NoError(t, err) + + packages, _, err := c.Catalog(context.Background(), resolver) + require.NoError(t, err) + require.Len(t, packages, 1) + + var cpes []string + for _, c := range packages[0].CPEs { + cpes = append(cpes, c.Attributes.BindToFmtString()) + } + + assert.Equal(t, test.wantType, packages[0].Type) + assert.Equal(t, test.wantPURL, packages[0].PURL) + assert.Equal(t, test.wantCPEs, cpes) + }) + } +} + func TestClassifierCataloger_DefaultClassifiers_NegativeCases(t *testing.T) { c := NewClassifierCataloger(DefaultClassifierCatalogerConfig()) diff --git a/syft/pkg/cataloger/binary/classifiers.go b/syft/pkg/cataloger/binary/classifiers.go index afd4c7536..fd6090a5f 100644 --- a/syft/pkg/cataloger/binary/classifiers.go +++ b/syft/pkg/cataloger/binary/classifiers.go @@ -5,6 +5,7 @@ import ( "github.com/anchore/packageurl-go" "github.com/anchore/syft/syft/cpe" + "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg/cataloger/internal/binutils" ) @@ -248,8 +249,9 @@ func DefaultClassifiers() []binutils.Classifier { EvidenceMatcher: m.FileContentsVersionMatcher( `(?m)\/usr\/local\/lib\/perl\d\/(?P[0-9]+\.[0-9]+\.[0-9]+)`), Package: "perl", - PURL: mustPURL("pkg:generic/perl@version"), + PURL: mustPURL("pkg:cpan/perl@version"), CPEs: singleCPE("cpe:2.3:a:perl:perl:*:*:*:*:*:*:*:*", cpe.NVDDictionaryLookupSource), + Type: pkg.CpanPkg, }, { Class: "php-composer-binary", diff --git a/syft/pkg/cataloger/internal/binutils/classifier.go b/syft/pkg/cataloger/internal/binutils/classifier.go index 011feaeeb..b777d6ba7 100644 --- a/syft/pkg/cataloger/internal/binutils/classifier.go +++ b/syft/pkg/cataloger/internal/binutils/classifier.go @@ -48,6 +48,17 @@ type Classifier struct { // CPEs are the specific CPEs we want to include for this binary with updated version information CPEs []cpe.CPE `json:"cpes"` + + // Type optionally overrides the package type reported for matches; when unset pkg.BinaryPkg is used + Type pkg.Type `json:"-"` +} + +// PackageType is the package type reported for matches of this classifier, defaulting to pkg.BinaryPkg. +func (cfg Classifier) PackageType() pkg.Type { + if cfg.Type == "" { + return pkg.BinaryPkg + } + return cfg.Type } func (cfg Classifier) MarshalJSON() ([]byte, error) { diff --git a/syft/pkg/cataloger/internal/binutils/classifier_package.go b/syft/pkg/cataloger/internal/binutils/classifier_package.go index 42831ddae..e1c4393df 100644 --- a/syft/pkg/cataloger/internal/binutils/classifier_package.go +++ b/syft/pkg/cataloger/internal/binutils/classifier_package.go @@ -35,7 +35,7 @@ func NewClassifierPackage(classifier Classifier, location file.Location, matchMe Locations: file.NewLocationSet( location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation), ), - Type: pkg.BinaryPkg, + Type: classifier.PackageType(), CPEs: cpes, FoundBy: catalogerName, Metadata: pkg.BinarySignature{ diff --git a/syft/pkg/cataloger/internal/binutils/classifier_test.go b/syft/pkg/cataloger/internal/binutils/classifier_test.go index c9d1c9e31..5bae45683 100644 --- a/syft/pkg/cataloger/internal/binutils/classifier_test.go +++ b/syft/pkg/cataloger/internal/binutils/classifier_test.go @@ -12,6 +12,7 @@ import ( "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/internal/unionreader" + "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source/directorysource" ) @@ -102,6 +103,35 @@ func Test_ClassifierCPEs(t *testing.T) { } } +func Test_ClassifierPackageType(t *testing.T) { + tests := []struct { + name string + classifier Classifier + want pkg.Type + }{ + { + name: "defaults to binary when unset", + classifier: Classifier{Class: "some-binary", Package: "some-app"}, + want: pkg.BinaryPkg, + }, + { + name: "honors an explicit type", + classifier: Classifier{Class: "perl-binary", Package: "perl", Type: pkg.CpanPkg}, + want: pkg.CpanPkg, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, test.classifier.PackageType()) + + p := NewClassifierPackage(test.classifier, file.NewLocation("/usr/bin/app"), map[string]string{"version": "1.2.3"}, "cataloger-name") + require.NotNil(t, p) + assert.Equal(t, test.want, p.Type) + }) + } +} + func TestClassifier_MarshalJSON(t *testing.T) { tests := []struct {