From db007cdc9f44f5769ad1baea8a6b10c1c0aafa94 Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:20:29 -0700 Subject: [PATCH] fix(cpe): generate the correct CPE for Microsoft Edge PE binaries Motivation: Microsoft Edge PE binaries (msedge.exe) on Windows produced a CPE generated verbatim from the raised package name ("Microsoft Edge"), yielding cpe:2.3:a:Microsoft_Edge:Microsoft_Edge::... instead of the NVD CPE dictionary's actual entry, cpe:2.3:a:microsoft:edge_chromium::.... Since syft/format/internal/cyclonedxutil/helpers/cpe.go and syft/format/internal/backfill.go use p.CPEs[0] as the package's sole CycloneDX CPE, and syft/cpe/by_specificity.go's tie-break sorts by combined field character count, the longer, incorrect "Microsoft_Edge:Microsoft_Edge" candidate always outranked the correct, shorter "microsoft:edge_chromium" one and was the value actually emitted. This causes downstream vulnerability scanners (e.g. Grype, Dependency-Track) consuming syft's output to false-negative on Edge CVEs, since the emitted CPE never matches NVD's dictionary entry. Approach: Add a Microsoft Edge hint to syft/pkg/cataloger/internal/cpegenerate/candidate_for_pe.go, following the existing Ghostscript/Git-for-Windows pattern already in that file. Because the incorrect default candidate was winning on specificity, simply adding a better candidate alongside it (the prior union-only behavior) was not enough - the incorrect default also needs to be cleared. candidateVendorsForPE/candidateProductsForPE now mutate the caller's candidate set in place (documented inline as a deliberate, minor deviation from sibling helpers in the same package, which return a fresh set to union in) so the Edge case can clear() the caller's existing candidates before adding the correct microsoft/edge_chromium pair. The added candidates disallow delimiter variations so a derived "edge-chromium" hyphenated form can't out-rank the underscore form NVD actually uses. The match is an exact, case-insensitive comparison against "microsoft edge" (not a substring match) so it does not misfire on related-but-distinct binaries such as Edge Beta/Dev or WebView2, which have different real CPEs. Validation: go build ./... passed. go test ./syft/pkg/cataloger/internal/cpegenerate/... passed, including a new targeted test (TestMicrosoftEdgePEGeneratesCorrectCPE) built on synthetic PE VersionResources metadata mirroring the existing Ghostscript test's approach; it asserts the primary CPE (index 0, the one actually emitted to CycloneDX) has vendor "microsoft" and product "edge_chromium", and that no CPE in the full candidate list is derived verbatim from the raised package name. golangci-lint run (repo's own .golangci.yaml) against the changed package reported zero issues in the changed files. gofmt -l on the changed files was clean. I could not run go test ./syft/pkg/cataloger/binary/... in this sandbox (Docker is unavailable for its image-fixture-based subtests unrelated to this change; confirmed pre-existing by reproducing the identical failure against an unmodified checkout). I did not reproduce this against a live msedge.exe binary; the fix is validated via a targeted synthetic unit test built from realistic PE VersionResources metadata (Company: "Microsoft Corporation", ProductName/FileDescription: "Microsoft Edge"), matching this repo's existing testing convention for this file. Report: https://github.com/anchore/syft/issues/4429 Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Assisted-by: claude-sonnet-5 (via Claude Code) --- .../internal/cpegenerate/candidate_for_pe.go | 56 ++++++++++++------- .../internal/cpegenerate/generate.go | 4 +- .../cataloger/internal/cpegenerate/pe_test.go | 34 +++++++++++ 3 files changed, 73 insertions(+), 21 deletions(-) diff --git a/syft/pkg/cataloger/internal/cpegenerate/candidate_for_pe.go b/syft/pkg/cataloger/internal/cpegenerate/candidate_for_pe.go index c5e29a89761..0f91871dc72 100644 --- a/syft/pkg/cataloger/internal/cpegenerate/candidate_for_pe.go +++ b/syft/pkg/cataloger/internal/cpegenerate/candidate_for_pe.go @@ -6,14 +6,20 @@ import ( "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() +// isMicrosoftEdgePE returns true if the PE metadata resembles a Microsoft Edge binary (e.g. msedge.exe). +func isMicrosoftEdgePE(product, fileDesc string) bool { + return product == "microsoft edge" || fileDesc == "microsoft edge" +} +// candidateVendorsForPE adds vendor candidates for PE (BinaryPkg) packages based on common metadata hints, +// mutating the given set in place. Specifically, normalize Ghostscript binaries to vendor "artifex" when detected. +// Unlike sibling candidateXForY helpers in this package, this mutates the caller's set directly (rather than +// returning a new one to union in) because the Microsoft Edge case below needs to clear() the caller's existing +// (incorrect) candidates, not just add to them. +func candidateVendorsForPE(p pkg.Package, vendors fieldCandidateSet) { meta, ok := p.Metadata.(pkg.PEBinary) if !ok { - return candidates + return } var company, product, fileDesc string @@ -29,25 +35,30 @@ func candidateVendorsForPE(p pkg.Package) fieldCandidateSet { } if strings.Contains(product, "ghostscript") || strings.Contains(fileDesc, "ghostscript") || strings.Contains(company, "artifex") { - candidates.addValue("artifex") + vendors.addValue("artifex") } if product == "git" || fileDesc == "git setup" || company == "the git development community" { - candidates.addValue("git_for_windows_project") - candidates.addValue("gitforwindows") + vendors.addValue("git_for_windows_project") + vendors.addValue("gitforwindows") } - return candidates + if isMicrosoftEdgePE(product, fileDesc) { + // the raised package name ("Microsoft Edge") is otherwise used verbatim as the vendor guess, which + // does not match the NVD CPE dictionary entry (vendor: microsoft); replace it outright so the + // mismatched candidate doesn't win on specificity and get selected as the primary CPE. Delimiter + // variations are disallowed so this exact, known-correct value isn't out-ranked by a hyphenated form. + vendors.clear() + vendors.add(fieldCandidate{value: "microsoft", disallowSubSelections: true, disallowDelimiterVariations: true}) + } } -// 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() - +// candidateProductsForPE adds product candidates for PE (BinaryPkg) packages based on common metadata hints, +// mutating the given set in place. Specifically, normalize Ghostscript binaries to product "ghostscript" when detected. +func candidateProductsForPE(p pkg.Package, products fieldCandidateSet) { meta, ok := p.Metadata.(pkg.PEBinary) if !ok { - return candidates + return } var product, fileDesc string @@ -61,13 +72,20 @@ func candidateProductsForPE(p pkg.Package) fieldCandidateSet { } if strings.Contains(product, "ghostscript") || strings.Contains(fileDesc, "ghostscript") { - candidates.addValue("ghostscript") + products.addValue("ghostscript") } if product == "git" || fileDesc == "git setup" { - candidates.addValue("git_for_windows") - candidates.addValue("git") + products.addValue("git_for_windows") + products.addValue("git") } - return candidates + if isMicrosoftEdgePE(product, fileDesc) { + // the raised package name ("Microsoft Edge") is otherwise used verbatim as the product guess, which + // does not match the NVD CPE dictionary entry (product: edge_chromium); replace it outright so the + // mismatched candidate doesn't win on specificity and get selected as the primary CPE. Delimiter + // variations are disallowed so this exact, known-correct value isn't out-ranked by a hyphenated form. + products.clear() + products.add(fieldCandidate{value: "edge_chromium", disallowSubSelections: true, disallowDelimiterVariations: true}) + } } diff --git a/syft/pkg/cataloger/internal/cpegenerate/generate.go b/syft/pkg/cataloger/internal/cpegenerate/generate.go index 097cf4e329a..1b37074cd05 100644 --- a/syft/pkg/cataloger/internal/cpegenerate/generate.go +++ b/syft/pkg/cataloger/internal/cpegenerate/generate.go @@ -283,7 +283,7 @@ func candidateVendorsByType(p pkg.Package, vendors fieldCandidateSet) fieldCandi vendors.union(candidateVendorsForJavascript(p)) case pkg.PEBinary: // Add PE-specific vendor hints (e.g. ghostscript -> artifex) - vendors.union(candidateVendorsForPE(p)) + candidateVendorsForPE(p, vendors) case pkg.WordpressPluginEntry: vendors.clear() vendors.union(candidateVendorsForWordpressPlugin(p)) @@ -328,7 +328,7 @@ func candidateProductSet(p pkg.Package) fieldCandidateSet { products.union(candidateProductsForAPK(p)) case pkg.PEBinary: // Add PE-specific product hints (e.g. ghostscript) - products.union(candidateProductsForPE(p)) + candidateProductsForPE(p, products) 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 index b8bd78ea7e7..d09e87db831 100644 --- a/syft/pkg/cataloger/internal/cpegenerate/pe_test.go +++ b/syft/pkg/cataloger/internal/cpegenerate/pe_test.go @@ -37,3 +37,37 @@ func TestGhostscriptPEGeneratesArtifexCPE(t *testing.T) { t.Fatalf("expected to find CPE with vendor 'artifex' and product 'ghostscript' for Ghostscript PE binary; got: %+v", cpes) } } + +func TestMicrosoftEdgePEGeneratesCorrectCPE(t *testing.T) { + // construct a BinaryPkg with PE metadata resembling Microsoft Edge (e.g. msedge.exe) + p := pkg.Package{ + Name: "Microsoft Edge", + Version: "122.0.2365.106", + Type: pkg.BinaryPkg, + Metadata: pkg.PEBinary{ + VersionResources: pkg.KeyValues{ + {Key: "CompanyName", Value: "Microsoft Corporation"}, + {Key: "ProductName", Value: "Microsoft Edge"}, + {Key: "FileDescription", Value: "Microsoft Edge"}, + }, + }, + } + + cpes := FromPackageAttributes(p) + if len(cpes) == 0 { + t.Fatalf("expected at least one CPE, got none") + } + + // the first CPE is the one selected as the package's primary CPE in output formats (e.g. CycloneDX), + // so it must be the correct vendor/product pairing and not the raw "Microsoft_Edge:Microsoft_Edge" guess. + got := cpes[0].Attributes + if got.Vendor != "microsoft" || got.Product != "edge_chromium" || got.Version != p.Version { + t.Fatalf("expected primary CPE vendor 'microsoft' and product 'edge_chromium' for Microsoft Edge PE binary; got: %+v", cpes) + } + + for _, c := range cpes { + if c.Attributes.Vendor == "microsoft_edge" || c.Attributes.Product == "microsoft_edge" { + t.Fatalf("did not expect a CPE derived verbatim from the raised package name; got: %+v", cpes) + } + } +}