support/scripts/cve.py: fix CPE matching

Given the following criteria: `cpe:2.3🅰️oneidentitty:syslog-ng:*:*:*:*:-:*:*:*`.
The former `cpe_matches` implementation would match with the following
CPE: `cpe:2.3🅰️oneidentitty:syslog-ng:4.71:*:*:*:premium:*:*:*`.

The 'hyphen' ('-') meaning is "Not Attributed" (NA) a criteria with no
attributed software edition shouldn't match with a CPE with an attributed
software edition:

https://csrc.nist.gov/pubs/ir/7695/final

This patch also create a distinct 'CPE' object that aggregate the
function specifics to CPEs like it's done for 'CVE'.

Signed-off-by: Thomas Perale <thomas.perale@mind.be>
Signed-off-by: Peter Korsgaard <peter@korsgaard.com>
This commit is contained in:
Thomas Perale
2025-11-03 22:11:42 +01:00
committed by Peter Korsgaard
parent 4b318dea17
commit 35f376d88e
2 changed files with 103 additions and 27 deletions

View File

@@ -39,22 +39,98 @@ ops = {
} }
# Check if two CPE IDs match each other class CPE:
def cpe_matches(cpe1, cpe2): DISJOINT = 0
cpe1_elems = cpe1.split(":") SUBSET = 1
cpe2_elems = cpe2.split(":") SUPERSET = 2
EQUAL = 3
remains = filter(lambda x: x[0] not in ["*", "-"] and x[1] not in ["*", "-"] and x[0] != x[1], ANY = '*'
zip(cpe1_elems, cpe2_elems)) NA = '-'
return len(list(remains)) == 0
@staticmethod
def compareAttribute(left, right):
"""
This static method compare two single attributes part of two CPE.
def cpe_product(cpe): This is an implementation of table 6-2 of [1].
return cpe.split(':')[4]
Attribute that are empty will be matched to the '*' (ANY) attribute.
According to [2] section 6.1.2.1.1 the empty attribute is inherited
from CPE22 and now bind to ANY.
def cpe_version(cpe): The hyphen '-' bind to the NA attribute (see [2]).
return cpe.split(':')[5]
[1] https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7696.pdf
[2] https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7695.pdf
"""
if left == '':
left = CPE.ANY
if right == '':
right = CPE.ANY
if left == right:
# 1 6 9 - equals
return CPE.EQUAL
elif left == CPE.ANY:
# 2 3 4 - superset
return CPE.SUPERSET
elif left == CPE.NA and right == CPE.ANY:
# 5 - subset
return CPE.SUBSET
elif left == CPE.NA:
# 12 16 - disjoint
return CPE.DISJOINT
elif right == CPE.ANY:
# 13 15 - subset
return CPE.SUBSET
return CPE.DISJOINT
def matches(self, target) -> bool:
"""
As an example let's take the example of CVE-2023-... for syslog-ng.
One of the node as the following CPE criteria matched with the Buildroot CPE:
cpe:2.3:a:oneidentitty:syslog-ng:*:*:*:*:-:*:*:*
cpe:2.3:a:oneidentitty:syslog-ng:4.71:*:*:*:*:*:*:*
vendor: EQUAL (3)
product: EQUAL (3)
version: SUPERSET (2)
update: EQUAL (3)
edition: EQUAL (3)
language: EQUAL (3)
sw_edition: SUBSET (1)
...
This operation results in the two CPE matching.
"""
if not isinstance(target, CPE):
target = CPE(target)
for selfAttribute, targetAttribute in zip(self.parts, target.parts):
if CPE.compareAttribute(selfAttribute, targetAttribute) == CPE.DISJOINT:
return False
return True
def __str__(self):
return self.cpe
def __init__(self, cpe):
self.cpe = cpe
self.parts = cpe.split(':')
self.vendor = self.parts[3]
self.product = self.parts[4]
self.version = self.parts[5]
self.update = self.parts[6]
self.edition = self.parts[7]
self.language = self.parts[8]
self.sw_edition = self.parts[9]
self.target_sw = self.parts[10]
self.target_hw = self.parts[11]
self.other = self.parts[12]
class CVE: class CVE:
@@ -127,8 +203,9 @@ class CVE:
for cpe in node.get('cpeMatch', ()): for cpe in node.get('cpeMatch', ()):
if not cpe['vulnerable']: if not cpe['vulnerable']:
return return
product = cpe_product(cpe['criteria']) cpeId = CPE(cpe['criteria'])
version = cpe_version(cpe['criteria']) product = cpeId.product
version = cpeId.version
# ignore when product is '-', which means N/A # ignore when product is '-', which means N/A
if product == '-': if product == '-':
return return
@@ -160,7 +237,7 @@ class CVE:
v_end = cpe['versionEndExcluding'] v_end = cpe['versionEndExcluding']
yield { yield {
'id': cpe['criteria'], 'id': cpeId,
'v_start': v_start, 'v_start': v_start,
'op_start': op_start, 'op_start': op_start,
'v_end': v_end, 'v_end': v_end,
@@ -181,30 +258,29 @@ class CVE:
@property @property
def affected_products(self): def affected_products(self):
"""The set of CPE products referred by this CVE definition""" """The set of CPE products referred by this CVE definition"""
return set(cpe_product(p['id']) for p in self.each_cpe()) return set(p['id'].product for p in self.each_cpe())
def affects(self, name, version, cpeid=None): def affects(self, name, version, cpeid=None):
""" """
True if the Buildroot Package object passed as argument is affected True if the Buildroot Package object passed as argument is affected
by this CVE. by this CVE.
""" """
if cpeid is None:
# if we don't have a cpeid, build one based on name and version
cpeid = CPE("cpe:2.3:*:*:%s:%s:*:*:*:*:*:*:*" % (name, version))
elif not isinstance(cpeid, CPE):
cpeid = CPE(cpeid)
pkg_version = distutils.version.LooseVersion(version) # Always prefer the package version of the CPE ID.
pkg_version = distutils.version.LooseVersion(cpeid.version)
if not hasattr(pkg_version, "version"): if not hasattr(pkg_version, "version"):
print("Cannot parse package '%s' version '%s'" % (name, version), file=sys.stderr) print("Cannot parse package '%s' version '%s'" % (name, version), file=sys.stderr)
pkg_version = None pkg_version = None
# if we don't have a cpeid, build one based on name and version
if not cpeid:
cpeid = "cpe:2.3:*:*:%s:%s:*:*:*:*:*:*:*" % (name, version)
# if we have a cpeid, use its version instead of the package
# version, as they might be different due to
# <pkg>_CPE_ID_VERSION
else:
pkg_version = distutils.version.LooseVersion(cpe_version(cpeid))
for cpe in self.each_cpe(): for cpe in self.each_cpe():
if not cpe_matches(cpe['id'], cpeid): if not cpe['id'].matches(cpeid):
# If the node CPE id is not a subset of the target package we
# don't check for affect
continue continue
if not cpe['v_start'] and not cpe['v_end']: if not cpe['v_start'] and not cpe['v_end']:
return self.CVE_AFFECTS return self.CVE_AFFECTS

View File

@@ -670,7 +670,7 @@ def check_package_cves(nvd_path, packages):
pkg.status['cve'] = ("na", "no version information available") pkg.status['cve'] = ("na", "no version information available")
continue continue
if pkg.cpeid: if pkg.cpeid:
cpe_product = cvecheck.cpe_product(pkg.cpeid) cpe_product = cvecheck.CPE(pkg.cpeid).product
cpe_product_pkgs[cpe_product].append(pkg) cpe_product_pkgs[cpe_product].append(pkg)
else: else:
cpe_product_pkgs[pkg.name].append(pkg) cpe_product_pkgs[pkg.name].append(pkg)