Improved cpe deterministic matching (#42325)

**Related issue:** Resolves #41644

There are two cases that exist in the cpe database where this generic
logic could not be applied.

django from python_packages:
gofiber:django
djangoproject:django

npm from npm_packages:
microsoft:npm
npmjs:npm

These will require individual cve overrides that is outside the scope of
this task.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Enhanced CPE (Common Platform Enumeration) matching to reduce
non-deterministic vendor selection when multiple vendors exist for the
same software product. The algorithm now incorporates software ecosystem
information to ensure more accurate and consistent vulnerability
resolution across package types.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Konstantin Sykulev 2026-03-24 22:48:02 +00:00 committed by GitHub
parent 2e6ffa747d
commit a599889152
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 157 additions and 5 deletions

View file

@ -0,0 +1 @@
* Added software source to ecosystem matching to help prevent non-deterministic CPE selection when multiple vendors exist for the same product.

View file

@ -120,8 +120,10 @@ type cpeSearchQuery struct {
args []any
}
const cpeSelectColumns = `SELECT c.rowid, c.product, c.vendor, c.deprecated FROM cpe_2 c`
const cpeOrderBy = ` ORDER BY c.vendor, c.product`
const (
cpeSelectColumns = `SELECT c.rowid, c.product, c.vendor, c.target_sw, c.deprecated FROM cpe_2 c`
cpeOrderBy = ` ORDER BY c.vendor, c.product`
)
// cpeSearchQueries returns individual search queries in priority order for finding CPE matches.
// Query 1 (vendor+product) and 2 (product-only) are cheap index lookups. Query 3 (full-text search)
@ -192,6 +194,72 @@ func cpeVendorMatchesSoftware(item *IndexedCPEItem, software *fleet.Software) bo
return matched
}
// cpeTargetSWMatchesSoftware returns a score (0-3) indicating how well the CPE's vendor
// and target_sw fields match the expected ecosystem for the software's source.
func cpeTargetSWMatchesSoftware(item *IndexedCPEItem, software *fleet.Software) int {
expectedTargetSW := targetSW(software)
if expectedTargetSW != "*" {
// Best match: CPE's target_sw matches what we expect for this software source
// Example:
// software.source="npm_packages" (expectedTargetSW="node.js")
// item.TargetSW="node.js"
if item.TargetSW != "" && strings.EqualFold(item.TargetSW, expectedTargetSW) {
return 3
}
// Good match: CPE vendor contains the ecosystem name
// Example:
// software.source="python_packages" (expectedTargetSW="python")
// item.Vendor="python"
expectedLower := strings.ToLower(expectedTargetSW)
vendorLower := strings.ToLower(item.Vendor)
// "node.js" -> "node"
ecosystemName := expectedLower
if strings.Contains(ecosystemName, ".") {
ecosystemName = strings.Split(ecosystemName, ".")[0]
}
if strings.Contains(vendorLower, ecosystemName) {
return 2
}
}
if expectedTargetSW == "*" {
// Good match: CPE vendor contains the ecosystem name
vendorLower := strings.ToLower(item.Vendor)
switch software.Source {
case "deb_packages":
// Example:
// software.source="deb_packages" (expectedTargetSW="*")
// item.Vendor="debian"
if strings.Contains(vendorLower, "debian") {
return 2
}
case "rpm_packages":
// Example:
// software.source="rpm_packages" (expectedTargetSW="*")
// item.Vendor="redhat"
if strings.Contains(vendorLower, "redhat") || strings.Contains(vendorLower, "fedora") {
return 2
}
}
}
// Partial match: CPE vendor matches software name with common _project suffix
// Example:
// software.name="duplicity", source="python_packages"
// item.Vendor="duplicity_project", item.Product="duplicity"
productLower := strings.ToLower(item.Product)
vendorLower := strings.ToLower(item.Vendor)
if vendorLower == productLower+"_project" {
return 1
}
return 0
}
// cpeItemMatchesSoftware checks whether a CPE result's vendor/product terms all appear in the
// software's name, vendor, and bundle identifier.
func cpeItemMatchesSoftware(item *IndexedCPEItem, software *fleet.Software) bool {
@ -644,6 +712,8 @@ func CPEFromSoftware(ctx context.Context, logger *slog.Logger, db *sqlx.DB, soft
// This avoids nondeterministic results when multiple CPE entries match
// (e.g. "ge:line" vs "linecorp:line" for the "Line" app).
var bestMatch *IndexedCPEItem
var bestTargetSWScore int
var bestVendorMatch bool
var deprecatedMatches []IndexedCPEItem
for i := range results {
if !cpeItemMatchesSoftware(&results[i], software) {
@ -653,8 +723,19 @@ func CPEFromSoftware(ctx context.Context, logger *slog.Logger, db *sqlx.DB, soft
deprecatedMatches = append(deprecatedMatches, results[i])
continue
}
if bestMatch == nil || (!cpeVendorMatchesSoftware(bestMatch, software) && cpeVendorMatchesSoftware(&results[i], software)) {
targetSWScore := cpeTargetSWMatchesSoftware(&results[i], software)
vendorMatch := cpeVendorMatchesSoftware(&results[i], software)
// first valid match, OR
// better target_sw score (ecosystem match), OR
// Same target_sw score but better vendor match
if bestMatch == nil ||
targetSWScore > bestTargetSWScore ||
(targetSWScore == bestTargetSWScore && !bestVendorMatch && vendorMatch) {
bestMatch = &results[i]
bestTargetSWScore = targetSWScore
bestVendorMatch = vendorMatch
}
}
if bestMatch != nil {

View file

@ -100,6 +100,42 @@ func TestCPEFromSoftware(t *testing.T) {
)
require.NoError(t, err, "software name %q should not cause FTS5 syntax error", name)
}
// Target_SW scoring: python_packages source should prefer python vendor over jenkins vendor
// when multiple CPE entries exist for the same product name.
cpe, err = CPEFromSoftware(t.Context(), slog.New(slog.DiscardHandler), db, &fleet.Software{
Name: "requests", Version: "2.31.0", Source: "python_packages",
}, nil, reCache)
require.NoError(t, err)
require.Equal(t, "cpe:2.3:a:python:requests:2.31.0:*:*:*:*:python:*:*", cpe,
"python_packages should prefer python:requests (vendor contains 'python')")
// Target_SW scoring: npm_packages source should prefer openjsf vendor over checkpoint vendor
// when the CPE has target_sw=node.js matching the expected target_sw.
cpe, err = CPEFromSoftware(t.Context(), slog.New(slog.DiscardHandler), db, &fleet.Software{
Name: "express", Version: "4.18.0", Source: "npm_packages",
}, nil, reCache)
require.NoError(t, err)
require.Equal(t, "cpe:2.3:a:openjsf:express:4.18.0:*:*:*:*:node.js:*:*", cpe,
"npm_packages should prefer openjsf:express with target_sw=node.js")
// Target_SW scoring with <product>_project fallback pattern.
// For duplicity from python_packages, neither vendor relates to Python ecosystem, but
// duplicity_project:duplicity follows the NVD "<product>_project" pattern for upstream CPEs.
cpe, err = CPEFromSoftware(t.Context(), slog.New(slog.DiscardHandler), db, &fleet.Software{
Name: "duplicity", Version: "0.8.0", Source: "python_packages",
}, nil, reCache)
require.NoError(t, err)
require.Equal(t, "cpe:2.3:a:duplicity_project:duplicity:0.8.0:*:*:*:*:python:*:*", cpe,
"should prefer duplicity_project (upstream) over debian (distro-specific) using _project pattern")
// Target_SW scoring: deb_packages source should prefer debian vendor for duplicity
cpe, err = CPEFromSoftware(t.Context(), slog.New(slog.DiscardHandler), db, &fleet.Software{
Name: "duplicity", Version: "0.8.0", Source: "deb_packages",
}, nil, reCache)
require.NoError(t, err)
require.Equal(t, "cpe:2.3:a:debian:duplicity:0.8.0:*:*:*:*:*:*:*", cpe,
"deb_packages duplicity should prefer debian:duplicity (vendor contains 'debian')")
}
func TestCPETranslations(t *testing.T) {
@ -1112,7 +1148,16 @@ func TestCPEFromSoftwareIntegration(t *testing.T) {
Version: "0.8.21",
Vendor: "",
BundleIdentifier: "",
}, cpe: "cpe:2.3:a:debian:duplicity:0.8.21:*:*:*:*:python:*:*",
}, cpe: "cpe:2.3:a:duplicity_project:duplicity:0.8.21:*:*:*:*:python:*:*",
},
{
software: fleet.Software{
Name: "duplicity",
Source: "deb_packages",
Version: "0.8.21",
Vendor: "",
BundleIdentifier: "",
}, cpe: "cpe:2.3:a:debian:duplicity:0.8.21:*:*:*:*:*:*:*",
},
{
software: fleet.Software{
@ -1346,7 +1391,7 @@ func TestCPEFromSoftwareIntegration(t *testing.T) {
Version: "2.25.1",
Vendor: "",
BundleIdentifier: "",
}, cpe: "cpe:2.3:a:jenkins:requests:2.25.1:*:*:*:*:python:*:*",
}, cpe: "cpe:2.3:a:python:requests:2.25.1:*:*:*:*:python:*:*",
},
{
software: fleet.Software{

View file

@ -14,6 +14,7 @@ type IndexedCPEItem struct {
Product string `json:"product" db:"product"`
Vendor string `json:"vendor" db:"vendor"`
SWEdition string `json:"sw_edition" db:"sw_edition"`
TargetSW string `json:"target_sw" db:"target_sw"`
Deprecated bool `json:"deprecated" db:"deprecated"`
Weight int `db:"weight"`
}

View file

@ -68,5 +68,29 @@ const XmlCPETestDict = `
<title xml:lang="en-US">Good Corp Correct Result 1.0</title>
<cpe-23:cpe23-item name="cpe:2.3:a:goodcorp:correct_result:1.0:*:*:*:*:*:*:*"/>
</cpe-item>
<cpe-item name="cpe:/a:python:requests:2.31.0">
<title xml:lang="en-US">Python Requests 2.31.0</title>
<cpe-23:cpe23-item name="cpe:2.3:a:python:requests:2.31.0:*:*:*:*:*:*:*"/>
</cpe-item>
<cpe-item name="cpe:/a:jenkins:requests:2.31.0">
<title xml:lang="en-US">Jenkins Requests 2.31.0</title>
<cpe-23:cpe23-item name="cpe:2.3:a:jenkins:requests:2.31.0:*:*:*:*:jenkins:*:*"/>
</cpe-item>
<cpe-item name="cpe:/a:duplicity_project:duplicity:0.8.0">
<title xml:lang="en-US">Duplicity Project Duplicity 0.8.0</title>
<cpe-23:cpe23-item name="cpe:2.3:a:duplicity_project:duplicity:0.8.0:*:*:*:*:*:*:*"/>
</cpe-item>
<cpe-item name="cpe:/a:debian:duplicity:0.8.0">
<title xml:lang="en-US">Debian Duplicity 0.8.0</title>
<cpe-23:cpe23-item name="cpe:2.3:a:debian:duplicity:0.8.0:*:*:*:*:*:*:*"/>
</cpe-item>
<cpe-item name="cpe:/a:openjsf:express:4.18.0">
<title xml:lang="en-US">OpenJS Foundation Express 4.18.0</title>
<cpe-23:cpe23-item name="cpe:2.3:a:openjsf:express:4.18.0:*:*:*:*:node.js:*:*"/>
</cpe-item>
<cpe-item name="cpe:/a:checkpoint:express:4.18.0">
<title xml:lang="en-US">Check Point Express 4.18.0</title>
<cpe-23:cpe23-item name="cpe:2.3:a:checkpoint:express:4.18.0:*:*:*:*:*:*:*"/>
</cpe-item>
</cpe-list>
`