From 57bd6a5f9cfb11752acfd759a8d72ba56857e74b Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Tue, 29 Sep 2026 16:01:21 -0400 Subject: [PATCH] fix(cpe): bound CPE candidate generation for pathological package metadata (#5356) * fix(cpe): cap sub-selection prefixes to bound candidate cross-product a name with thousands of `-`/`_` separators produced one vendor and product candidate per segment, and the vendor x product cross-product made CPE generation roughly cubic in time and memory (8KB name: ~250s, 4.8GB RSS). `generateSubSelections` now stops after 10 prefixes. The full name is still a candidate, so real package names are unaffected. Signed-off-by: Alex Goodman * fix(cpe): bound candidate count and field length before the cross-product the prefix cap only covered one way to inflate the vendor x product cross-product in `FromPackageAttributes`. Java groupIDs add a vendor (and, with no artifactID, a product) per dot segment, and .NET executables add one of each per file, so a 1000-segment `Bundle-SymbolicName` still produced ~1M CPEs and several GB. - vendor and product candidates are each capped at 64 (shortest first, so truncation is deterministic) - names, versions, and candidates over 512 bytes are skipped, since every byte is paid once per CPE in key building and regex validation real packages produce around a dozen candidates with names well under 100 bytes, so output for real inputs is unchanged. Also corrects the earlier comment: prefixes only become vendor candidates and the cost was quadratic, not cubic. Signed-off-by: Alex Goodman --------- Signed-off-by: Alex Goodman --- .../internal/cpegenerate/generate.go | 48 +++++++++++++-- .../internal/cpegenerate/generate_test.go | 61 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/syft/pkg/cataloger/internal/cpegenerate/generate.go b/syft/pkg/cataloger/internal/cpegenerate/generate.go index 097cf4e32..4495e17c1 100644 --- a/syft/pkg/cataloger/internal/cpegenerate/generate.go +++ b/syft/pkg/cataloger/internal/cpegenerate/generate.go @@ -3,10 +3,12 @@ package cpegenerate import ( "bufio" "bytes" + "cmp" _ "embed" "encoding/json" "fmt" "regexp" + "slices" "sort" "strings" "sync" @@ -145,8 +147,14 @@ func FromPackageAttributes(p pkg.Package) []cpe.CPE { return nil } - vendors := candidateVendors(p) - products := candidateProducts(p) + // real CPE fields are short, and every byte of a field is paid once per generated CPE (key building and regex + // validation), so a hostile name or version is not worth generating candidates for at all. + if len(p.Name) > maxCandidateFieldLength || len(p.Version) > maxCandidateFieldLength { + return nil + } + + vendors := boundCandidates(candidateVendors(p)) + products := boundCandidates(candidateProducts(p)) targetSWs := candidateTargetSw(p) if len(products) == 0 { return nil @@ -183,6 +191,32 @@ func FromPackageAttributes(p pkg.Package) []cpe.CPE { return result } +// maxCandidateFieldLength bounds the name, version, and each vendor and product candidate that reaches the +// cross-product in FromPackageAttributes. Real values are well under this. +const maxCandidateFieldLength = 512 + +// maxCandidates bounds how many vendor and product candidates each feed the cross-product in FromPackageAttributes, +// so a single package yields at most maxCandidates² CPEs. Some sources add a candidate per input element with no bound +// of their own (e.g. one vendor per java groupID segment, one product per .NET executable), and real packages produce +// a dozen or so candidates at most. +const maxCandidates = 64 + +// boundCandidates drops candidates longer than maxCandidateFieldLength and keeps at most maxCandidates of the rest. +// Candidates are ordered shortest first (then lexically) so that truncation is deterministic and keeps the short, +// generic values that tend to be real vendors and products. +func boundCandidates(candidates []string) []string { + candidates = slices.DeleteFunc(candidates, func(c string) bool { + return len(c) > maxCandidateFieldLength + }) + if len(candidates) <= maxCandidates { + return candidates + } + slices.SortFunc(candidates, func(a, b string) int { + return cmp.Or(cmp.Compare(len(a), len(b)), strings.Compare(a, b)) + }) + return candidates[:maxCandidates] +} + func candidateTargetSw(p pkg.Package) []string { switch p.Type { case pkg.WordpressPluginPkg: @@ -359,13 +393,19 @@ func addAllSubSelections(fields fieldCandidateSet) { } } +// maxSubSelections bounds how many prefixes generateSubSelections returns. Each prefix becomes a vendor candidate, so +// without a bound a name with N separators yields O(N) vendors of O(N) length each (quadratic in time and memory). +// Real package names rarely exceed a handful of segments. +const maxSubSelections = 10 + // generateSubSelections attempts to split a field by hyphens and underscores and return a list of sensible sub-selections -// that can be used as product or vendor candidates. E.g. jenkins-ci-tools -> [jenkins-ci-tools, jenkins-ci, jenkins]. +// that can be used as product or vendor candidates. E.g. jenkins-ci-tools -> [jenkins, jenkins-ci, jenkins-ci-tools]. +// At most maxSubSelections prefixes are returned, shortest first. func generateSubSelections(field string) (results []string) { scanner := bufio.NewScanner(strings.NewReader(field)) scanner.Split(scanByHyphenOrUnderscore) var lastToken uint8 - for scanner.Scan() { + for len(results) < maxSubSelections && scanner.Scan() { rawCandidate := scanner.Text() if len(rawCandidate) == 0 { break diff --git a/syft/pkg/cataloger/internal/cpegenerate/generate_test.go b/syft/pkg/cataloger/internal/cpegenerate/generate_test.go index d0ace066c..cdf32d9b7 100644 --- a/syft/pkg/cataloger/internal/cpegenerate/generate_test.go +++ b/syft/pkg/cataloger/internal/cpegenerate/generate_test.go @@ -1121,6 +1121,11 @@ func Test_generateSubSelections(t *testing.T) { field: "_", expected: nil, }, + { + // capped at maxSubSelections, keeping the shortest prefixes (the full name is not returned) + field: "a_b_c_d_e_f_g_h_i_j_k_l", + expected: []string{"a", "a_b", "a_b_c", "a_b_c_d", "a_b_c_d_e", "a_b_c_d_e_f", "a_b_c_d_e_f_g", "a_b_c_d_e_f_g_h", "a_b_c_d_e_f_g_h_i", "a_b_c_d_e_f_g_h_i_j"}, + }, } for _, test := range tests { t.Run(test.field, func(t *testing.T) { @@ -1265,3 +1270,59 @@ func TestAddBinaryPackageDigitVariations(t *testing.T) { }) } } + +func TestFromPackageAttributes_boundsSubSelectionBlowup(t *testing.T) { + // a name with many separators used to produce a vendor candidate per segment, each O(name length), which was + // quadratic in time and memory. + name := strings.Repeat("a_", 200) + "a" + p := pkg.Package{Name: name, Version: "1.0", Type: pkg.PythonPkg, Language: pkg.Python} + + cpes := FromPackageAttributes(p) + assert.NotEmpty(t, cpes) + assert.LessOrEqual(t, len(cpes), 1000) +} + +func TestFromPackageAttributes_boundsCandidateCrossProduct(t *testing.T) { + // every groupID segment becomes a vendor, and with no artifactID every segment past the second also becomes a + // product, so the vendor x product cross-product is quadratic in segment count without a bound. + var segments []string + for i := range 1000 { + segments = append(segments, fmt.Sprintf("s%d", i)) + } + p := pkg.Package{ + Name: "thing", + Version: "1.0", + Type: pkg.JavaPkg, + Language: pkg.Java, + Metadata: pkg.JavaArchive{ + Manifest: &pkg.JavaManifest{ + Main: pkg.KeyValues{{Key: "Bundle-SymbolicName", Value: "com.x." + strings.Join(segments, ".")}}, + }, + }, + } + + cpes := FromPackageAttributes(p) + assert.NotEmpty(t, cpes) + assert.LessOrEqual(t, len(cpes), maxCandidates*maxCandidates) +} + +func TestFromPackageAttributes_skipsOversizedFields(t *testing.T) { + long := strings.Repeat("a", maxCandidateFieldLength+1) + + assert.Empty(t, FromPackageAttributes(pkg.Package{Name: long, Version: "1.0", Type: pkg.PythonPkg})) + assert.Empty(t, FromPackageAttributes(pkg.Package{Name: "thing", Version: long, Type: pkg.PythonPkg})) + assert.NotEmpty(t, FromPackageAttributes(pkg.Package{Name: "thing", Version: "1.0", Type: pkg.PythonPkg})) +} + +func Test_boundCandidates(t *testing.T) { + long := strings.Repeat("a", maxCandidateFieldLength+1) + assert.Equal(t, []string{"b", "a"}, boundCandidates([]string{"b", long, "a"}), "under the cap order is untouched") + + var many []string + for i := range maxCandidates + 10 { + many = append(many, strings.Repeat("x", maxCandidates+10-i)) + } + got := boundCandidates(many) + assert.Len(t, got, maxCandidates) + assert.Equal(t, "x", got[0], "shortest candidates are kept") +}