From 3e4e82f03e941edf1789efded50b887dbff173d9 Mon Sep 17 00:00:00 2001 From: kdt523 Date: Mon, 3 Nov 2025 20:24:48 +0530 Subject: [PATCH] Canonicalize Ghostscript CPE/PURL for ghostscript packages from PE Binaries (#4308) * binary(pe): canonicalize Ghostscript CPE to artifex:ghostscript and add generic purl for PE (#4275)\n\n- Detect Ghostscript via PE version resources and set purl pkg:generic/ghostscript@\n- Add PE-specific CPE candidates: vendor 'artifex', product 'ghostscript'\n- Add focused unit tests for purl and CPE generation Signed-off-by: kdt523 * fix: gofmt formatting for static analysis pass (pe-ghostscript-cpe-purl-4275) Signed-off-by: kdt523 --------- Signed-off-by: kdt523 --- syft/pkg/cataloger/binary/pe_package.go | 14 +++++ syft/pkg/cataloger/binary/pe_package_test.go | 24 +++++++ .../internal/cpegenerate/candidate_for_pe.go | 63 +++++++++++++++++++ .../internal/cpegenerate/generate.go | 6 ++ .../cataloger/internal/cpegenerate/pe_test.go | 39 ++++++++++++ 5 files changed, 146 insertions(+) create mode 100644 syft/pkg/cataloger/binary/pe_package_test.go create mode 100644 syft/pkg/cataloger/internal/cpegenerate/candidate_for_pe.go create mode 100644 syft/pkg/cataloger/internal/cpegenerate/pe_test.go diff --git a/syft/pkg/cataloger/binary/pe_package.go b/syft/pkg/cataloger/binary/pe_package.go index 89e785f50..a7cab30e2 100644 --- a/syft/pkg/cataloger/binary/pe_package.go +++ b/syft/pkg/cataloger/binary/pe_package.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + packageurl "github.com/anchore/packageurl-go" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" ) @@ -32,6 +33,19 @@ func newPEPackage(versionResources map[string]string, f file.Location) pkg.Packa Metadata: newPEBinaryVersionResourcesFromMap(versionResources), } + // If this appears to be Ghostscript, emit a canonical generic purl + // Example expected: pkg:generic/ghostscript@ + prod := strings.ToLower(spaceNormalize(versionResources["ProductName"])) + if prod == "" { + // fall back to FileDescription if ProductName is missing + prod = strings.ToLower(spaceNormalize(versionResources["FileDescription"])) + } + if p.Version != "" && strings.Contains(prod, "ghostscript") { + // build a generic PURL for ghostscript + purl := packageurl.NewPackageURL(packageurl.TypeGeneric, "", "ghostscript", p.Version, nil, "").ToString() + p.PURL = purl + } + p.SetID() return p diff --git a/syft/pkg/cataloger/binary/pe_package_test.go b/syft/pkg/cataloger/binary/pe_package_test.go new file mode 100644 index 000000000..cf5746317 --- /dev/null +++ b/syft/pkg/cataloger/binary/pe_package_test.go @@ -0,0 +1,24 @@ +package binary + +import ( + "testing" + + "github.com/anchore/syft/syft/file" +) + +func TestGhostscriptPEGeneratesGenericPURL(t *testing.T) { + vr := map[string]string{ + "CompanyName": "Artifex Software, Inc.", + "ProductName": "GPL Ghostscript", + "FileDescription": "Ghostscript Interpreter", + "ProductVersion": "9.54.0", + } + + loc := file.NewLocation("/usr/bin/gswin64c.exe") + p := newPEPackage(vr, loc) + + expected := "pkg:generic/ghostscript@9.54.0" + if p.PURL != expected { + t.Fatalf("expected purl %q, got %q", expected, p.PURL) + } +} diff --git a/syft/pkg/cataloger/internal/cpegenerate/candidate_for_pe.go b/syft/pkg/cataloger/internal/cpegenerate/candidate_for_pe.go new file mode 100644 index 000000000..00a032c5a --- /dev/null +++ b/syft/pkg/cataloger/internal/cpegenerate/candidate_for_pe.go @@ -0,0 +1,63 @@ +package cpegenerate + +import ( + "strings" + + "github.com/anchore/syft/syft/pkg" +) + +// candidateVendorsForPE returns vendor candidates for PE (BinaryPkg) packages based on common metadata hints. +// Specifically, normalize Ghostscript binaries to vendor "artifex" when detected. +func candidateVendorsForPE(p pkg.Package) fieldCandidateSet { + candidates := newFieldCandidateSet() + + meta, ok := p.Metadata.(pkg.PEBinary) + if !ok { + return candidates + } + + var company, product, fileDesc string + for _, kv := range meta.VersionResources { + switch strings.ToLower(kv.Key) { + case "companyname": + company = strings.ToLower(kv.Value) + case "productname": + product = strings.ToLower(kv.Value) + case "filedescription": + fileDesc = strings.ToLower(kv.Value) + } + } + + if strings.Contains(product, "ghostscript") || strings.Contains(fileDesc, "ghostscript") || strings.Contains(company, "artifex") { + candidates.addValue("artifex") + } + + return candidates +} + +// candidateProductsForPE returns product candidates for PE (BinaryPkg) packages based on common metadata hints. +// Specifically, normalize Ghostscript binaries to product "ghostscript" when detected. +func candidateProductsForPE(p pkg.Package) fieldCandidateSet { + candidates := newFieldCandidateSet() + + meta, ok := p.Metadata.(pkg.PEBinary) + if !ok { + return candidates + } + + var product, fileDesc string + for _, kv := range meta.VersionResources { + switch strings.ToLower(kv.Key) { + case "productname": + product = strings.ToLower(kv.Value) + case "filedescription": + fileDesc = strings.ToLower(kv.Value) + } + } + + if strings.Contains(product, "ghostscript") || strings.Contains(fileDesc, "ghostscript") { + candidates.addValue("ghostscript") + } + + return candidates +} diff --git a/syft/pkg/cataloger/internal/cpegenerate/generate.go b/syft/pkg/cataloger/internal/cpegenerate/generate.go index 1ccbf5e25..8fd40d7cd 100644 --- a/syft/pkg/cataloger/internal/cpegenerate/generate.go +++ b/syft/pkg/cataloger/internal/cpegenerate/generate.go @@ -225,6 +225,9 @@ func candidateVendors(p pkg.Package) []string { vendors.union(candidateVendorsForAPK(p)) case pkg.NpmPackage: vendors.union(candidateVendorsForJavascript(p)) + case pkg.PEBinary: + // Add PE-specific vendor hints (e.g. ghostscript -> artifex) + vendors.union(candidateVendorsForPE(p)) case pkg.WordpressPluginEntry: vendors.clear() vendors.union(candidateVendorsForWordpressPlugin(p)) @@ -301,6 +304,9 @@ func candidateProductSet(p pkg.Package) fieldCandidateSet { switch p.Metadata.(type) { case pkg.ApkDBEntry: products.union(candidateProductsForAPK(p)) + case pkg.PEBinary: + // Add PE-specific product hints (e.g. ghostscript) + products.union(candidateProductsForPE(p)) case pkg.WordpressPluginEntry: products.clear() products.union(candidateProductsForWordpressPlugin(p)) diff --git a/syft/pkg/cataloger/internal/cpegenerate/pe_test.go b/syft/pkg/cataloger/internal/cpegenerate/pe_test.go new file mode 100644 index 000000000..b8bd78ea7 --- /dev/null +++ b/syft/pkg/cataloger/internal/cpegenerate/pe_test.go @@ -0,0 +1,39 @@ +package cpegenerate + +import ( + "testing" + + "github.com/anchore/syft/syft/pkg" +) + +func TestGhostscriptPEGeneratesArtifexCPE(t *testing.T) { + // construct a BinaryPkg with PE metadata resembling Ghostscript + p := pkg.Package{ + Name: "GPL Ghostscript", + Version: "9.54.0", + Type: pkg.BinaryPkg, + Metadata: pkg.PEBinary{ + VersionResources: pkg.KeyValues{ + {Key: "CompanyName", Value: "Artifex Software, Inc."}, + {Key: "ProductName", Value: "GPL Ghostscript"}, + {Key: "FileDescription", Value: "Ghostscript Interpreter"}, + }, + }, + } + + cpes := FromPackageAttributes(p) + if len(cpes) == 0 { + t.Fatalf("expected at least one CPE, got none") + } + + found := false + for _, c := range cpes { + if c.Attributes.Vendor == "artifex" && c.Attributes.Product == "ghostscript" && c.Attributes.Version == p.Version { + found = true + break + } + } + if !found { + t.Fatalf("expected to find CPE with vendor 'artifex' and product 'ghostscript' for Ghostscript PE binary; got: %+v", cpes) + } +}