cve-check: Rework patch parsing

The cve_check functionality to parse CVE IDs from the patch filename and
patch contents have been reworked to improve parsing and also utilize
tests. This ensures that the parsing works as intended.

Additionally, the new patched_cves dict has a few issues I tried to fix
as well. If multiple patch files exist for a single CVE ID, only the
last one will show up with the "resource" key. The value for the
"resource" key has been updated to hold a list and return all patch
files associated with a given CVE ID. Also, at the end of
get_patch_cves, CVE_STATUS can overwrite an existing entry in the dict.
This could cause an issue, for example, if a CVE has been addressed via
a patch, but a CVE_STATUS line also exists that ignores the given CVE
ID. A warning has been added if this ever happens.

(From OE-Core rev: 87c6da681609b4f8e048eca2a27ae8e068c724e1)

Signed-off-by: Colin McAllister <colinmca242@gmail.com>
Signed-off-by: Richard Purdie <richard.purdie@linuxfoundation.org>
This commit is contained in:
Colin McAllister
2024-12-30 19:22:24 +00:00
committed by Richard Purdie
parent db2146dbe6
commit 35f4253a10
2 changed files with 320 additions and 57 deletions

View File

@@ -5,9 +5,11 @@
#
import collections
import re
import itertools
import functools
import itertools
import os.path
import re
import oe.patch
_Version = collections.namedtuple(
"_Version", ["release", "patch_l", "pre_l", "pre_v"]
@@ -71,76 +73,132 @@ def _cmpkey(release, patch_l, pre_l, pre_v):
return _release, _patch, _pre
def parse_cve_from_filename(patch_filename):
"""
Parses CVE ID from the filename
Matches the last "CVE-YYYY-ID" in the file name, also if written
in lowercase. Possible to have multiple CVE IDs in a single
file name, but only the last one will be detected from the file name.
Returns the last CVE ID foudn in the filename. If no CVE ID is found
an empty string is returned.
"""
cve_file_name_match = re.compile(r".*(CVE-\d{4}-\d{4,})", re.IGNORECASE)
# Check patch file name for CVE ID
fname_match = cve_file_name_match.search(patch_filename)
return fname_match.group(1).upper() if fname_match else ""
def parse_cves_from_patch_contents(patch_contents):
"""
Parses CVE IDs from patch contents
Matches all CVE IDs contained on a line that starts with "CVE: ". Any
delimiter (',', '&', "and", etc.) can be used without any issues. Multiple
"CVE:" lines can also exist.
Returns a set of all CVE IDs found in the patch contents.
"""
cve_ids = set()
cve_match = re.compile(r"CVE-\d{4}-\d{4,}")
# Search for one or more "CVE: " lines
for line in patch_contents.split("\n"):
if not line.startswith("CVE:"):
continue
cve_ids.update(cve_match.findall(line))
return cve_ids
def parse_cves_from_patch_file(patch_file):
"""
Parses CVE IDs associated with a particular patch file, using both the filename
and patch contents.
Returns a set of all CVE IDs found in the patch filename and contents.
"""
cve_ids = set()
filename_cve = parse_cve_from_filename(patch_file)
if filename_cve:
bb.debug(2, "Found %s from patch file name %s" % (filename_cve, patch_file))
cve_ids.add(parse_cve_from_filename(patch_file))
# Remote patches won't be present and compressed patches won't be
# unpacked, so say we're not scanning them
if not os.path.isfile(patch_file):
bb.note("%s is remote or compressed, not scanning content" % patch_file)
return cve_ids
with open(patch_file, "r", encoding="utf-8") as f:
try:
patch_text = f.read()
except UnicodeDecodeError:
bb.debug(
1,
"Failed to read patch %s using UTF-8 encoding"
" trying with iso8859-1" % patch_file,
)
f.close()
with open(patch_file, "r", encoding="iso8859-1") as f:
patch_text = f.read()
cve_ids.update(parse_cves_from_patch_contents(patch_text))
if not cve_ids:
bb.debug(2, "Patch %s doesn't solve CVEs" % patch_file)
else:
bb.debug(2, "Patch %s solves %s" % (patch_file, ", ".join(sorted(cve_ids))))
return cve_ids
def get_patched_cves(d):
"""
Get patches that solve CVEs using the "CVE: " tag.
Determines the CVE IDs that have been solved by either patches incuded within
SRC_URI or by setting CVE_STATUS.
Returns a dictionary with the CVE IDs as keys and an associated dictonary of
relevant metadata as the value.
"""
import re
import oe.patch
cve_match = re.compile(r"CVE:( CVE-\d{4}-\d+)+")
# Matches the last "CVE-YYYY-ID" in the file name, also if written
# in lowercase. Possible to have multiple CVE IDs in a single
# file name, but only the last one will be detected from the file name.
# However, patch files contents addressing multiple CVE IDs are supported
# (cve_match regular expression)
cve_file_name_match = re.compile(r".*(CVE-\d{4}-\d+)", re.IGNORECASE)
patched_cves = {}
patches = oe.patch.src_patches(d)
bb.debug(2, "Scanning %d patches for CVEs" % len(patches))
# Check each patch file
for url in patches:
patch_file = bb.fetch.decodeurl(url)[2]
# Check patch file name for CVE ID
fname_match = cve_file_name_match.search(patch_file)
if fname_match:
cve = fname_match.group(1).upper()
patched_cves[cve] = {"abbrev-status": "Patched", "status": "fix-file-included", "resource": patch_file}
bb.debug(2, "Found %s from patch file name %s" % (cve, patch_file))
# Remote patches won't be present and compressed patches won't be
# unpacked, so say we're not scanning them
if not os.path.isfile(patch_file):
bb.note("%s is remote or compressed, not scanning content" % patch_file)
continue
with open(patch_file, "r", encoding="utf-8") as f:
try:
patch_text = f.read()
except UnicodeDecodeError:
bb.debug(1, "Failed to read patch %s using UTF-8 encoding"
" trying with iso8859-1" % patch_file)
f.close()
with open(patch_file, "r", encoding="iso8859-1") as f:
patch_text = f.read()
# Search for one or more "CVE: " lines
text_match = False
for match in cve_match.finditer(patch_text):
# Get only the CVEs without the "CVE: " tag
cves = patch_text[match.start()+5:match.end()]
for cve in cves.split():
bb.debug(2, "Patch %s solves %s" % (patch_file, cve))
patched_cves[cve] = {"abbrev-status": "Patched", "status": "fix-file-included", "resource": patch_file}
text_match = True
if not fname_match and not text_match:
bb.debug(2, "Patch %s doesn't solve CVEs" % patch_file)
for cve_id in parse_cves_from_patch_file(patch_file):
if cve_id not in patched_cves:
{
"abbrev-status": "Patched",
"status": "fix-file-included",
"resource": [patch_file],
}
else:
patched_cves[cve_id]["resource"].append(patch_file)
# Search for additional patched CVEs
for cve in (d.getVarFlags("CVE_STATUS") or {}):
decoded_status = decode_cve_status(d, cve)
for cve_id in d.getVarFlags("CVE_STATUS") or {}:
decoded_status = decode_cve_status(d, cve_id)
products = d.getVar("CVE_PRODUCT")
if has_cve_product_match(decoded_status, products) == True:
patched_cves[cve] = {
if has_cve_product_match(decoded_status, products):
if cve_id in patched_cves:
bb.warn(
'CVE_STATUS[%s] = "%s" is overwriting previous status of "%s: %s"'
% (
cve_id,
d.getVarFlag("CVE_STATUS", cve_id),
patched_cves[cve_id]["abbrev-status"],
patched_cves[cve_id]["status"],
)
)
patched_cves[cve_id] = {
"abbrev-status": decoded_status["mapping"],
"status": decoded_status["detail"],
"justification": decoded_status["description"],
"affected-vendor": decoded_status["vendor"],
"affected-product": decoded_status["product"]
"affected-product": decoded_status["product"],
}
return patched_cves

View File

@@ -120,6 +120,211 @@ class CVECheck(OESelftestTestCase):
self.assertEqual(has_cve_product_match(status, "test glibca:glibc"), True)
self.assertEqual(has_cve_product_match(status, "glibca:glibc test"), True)
def test_parse_cve_from_patch_filename(self):
from oe.cve_check import parse_cve_from_filename
# Patch filename without CVE ID
self.assertEqual(parse_cve_from_filename("0001-test.patch"), "")
# Patch with single CVE ID
self.assertEqual(
parse_cve_from_filename("CVE-2022-12345.patch"), "CVE-2022-12345"
)
# Patch with multiple CVE IDs
self.assertEqual(
parse_cve_from_filename("CVE-2022-41741-CVE-2022-41742.patch"),
"CVE-2022-41742",
)
# Patches with CVE ID and appended text
self.assertEqual(
parse_cve_from_filename("CVE-2023-3019-0001.patch"), "CVE-2023-3019"
)
self.assertEqual(
parse_cve_from_filename("CVE-2024-21886-1.patch"), "CVE-2024-21886"
)
# Patch with CVE ID and prepended text
self.assertEqual(
parse_cve_from_filename("grep-CVE-2012-5667.patch"), "CVE-2012-5667"
)
self.assertEqual(
parse_cve_from_filename("0001-CVE-2012-5667.patch"), "CVE-2012-5667"
)
# Patch with CVE ID and both prepended and appended text
self.assertEqual(
parse_cve_from_filename(
"0001-tpm2_import-fix-fixed-AES-key-CVE-2021-3565-0001.patch"
),
"CVE-2021-3565",
)
# Only grab the last CVE ID in the filename
self.assertEqual(
parse_cve_from_filename("CVE-2012-5667-CVE-2012-5668.patch"),
"CVE-2012-5668",
)
# Test invalid CVE ID with incorrect length (must be at least 4 digits)
self.assertEqual(
parse_cve_from_filename("CVE-2024-001.patch"),
"",
)
# Test valid CVE ID with very long length
self.assertEqual(
parse_cve_from_filename("CVE-2024-0000000000000000000000001.patch"),
"CVE-2024-0000000000000000000000001",
)
def test_parse_cve_from_patch_contents(self):
import textwrap
from oe.cve_check import parse_cves_from_patch_contents
# Standard patch file excerpt without any patches
self.assertEqual(
parse_cves_from_patch_contents(
textwrap.dedent("""\
remove "*" for root since we don't have a /etc/shadow so far.
Upstream-Status: Inappropriate [configuration]
Signed-off-by: Scott Garman <scott.a.garman@intel.com>
--- base-passwd/passwd.master~nobash
+++ base-passwd/passwd.master
@@ -1,4 +1,4 @@
-root:*:0:0:root:/root:/bin/sh
+root::0:0:root:/root:/bin/sh
daemon:*:1:1:daemon:/usr/sbin:/bin/sh
bin:*:2:2:bin:/bin:/bin/sh
sys:*:3:3:sys:/dev:/bin/sh
""")
),
set(),
)
# Patch file with multiple CVE IDs (space-separated)
self.assertEqual(
parse_cves_from_patch_contents(
textwrap.dedent("""\
There is an assertion in function _cairo_arc_in_direction().
CVE: CVE-2019-6461 CVE-2019-6462
Upstream-Status: Pending
Signed-off-by: Ross Burton <ross.burton@intel.com>
diff --git a/src/cairo-arc.c b/src/cairo-arc.c
index 390397bae..1bde774a4 100644
--- a/src/cairo-arc.c
+++ b/src/cairo-arc.c
@@ -186,7 +186,8 @@ _cairo_arc_in_direction (cairo_t *cr,
if (cairo_status (cr))
return;
- assert (angle_max >= angle_min);
+ if (angle_max < angle_min)
+ return;
if (angle_max - angle_min > 2 * M_PI * MAX_FULL_CIRCLES) {
angle_max = fmod (angle_max - angle_min, 2 * M_PI);
"""),
),
{"CVE-2019-6461", "CVE-2019-6462"},
)
# Patch file with multiple CVE IDs (comma-separated w/ both space and no space)
self.assertEqual(
parse_cves_from_patch_contents(
textwrap.dedent("""\
There is an assertion in function _cairo_arc_in_direction().
CVE: CVE-2019-6461,CVE-2019-6462, CVE-2019-6463
Upstream-Status: Pending
Signed-off-by: Ross Burton <ross.burton@intel.com>
diff --git a/src/cairo-arc.c b/src/cairo-arc.c
index 390397bae..1bde774a4 100644
--- a/src/cairo-arc.c
+++ b/src/cairo-arc.c
@@ -186,7 +186,8 @@ _cairo_arc_in_direction (cairo_t *cr,
if (cairo_status (cr))
return;
- assert (angle_max >= angle_min);
+ if (angle_max < angle_min)
+ return;
if (angle_max - angle_min > 2 * M_PI * MAX_FULL_CIRCLES) {
angle_max = fmod (angle_max - angle_min, 2 * M_PI);
"""),
),
{"CVE-2019-6461", "CVE-2019-6462", "CVE-2019-6463"},
)
# Patch file with multiple CVE IDs (&-separated)
self.assertEqual(
parse_cves_from_patch_contents(
textwrap.dedent("""\
There is an assertion in function _cairo_arc_in_direction().
CVE: CVE-2019-6461 & CVE-2019-6462
Upstream-Status: Pending
Signed-off-by: Ross Burton <ross.burton@intel.com>
diff --git a/src/cairo-arc.c b/src/cairo-arc.c
index 390397bae..1bde774a4 100644
--- a/src/cairo-arc.c
+++ b/src/cairo-arc.c
@@ -186,7 +186,8 @@ _cairo_arc_in_direction (cairo_t *cr,
if (cairo_status (cr))
return;
- assert (angle_max >= angle_min);
+ if (angle_max < angle_min)
+ return;
if (angle_max - angle_min > 2 * M_PI * MAX_FULL_CIRCLES) {
angle_max = fmod (angle_max - angle_min, 2 * M_PI);
"""),
),
{"CVE-2019-6461", "CVE-2019-6462"},
)
# Patch file with multiple lines with CVE IDs
self.assertEqual(
parse_cves_from_patch_contents(
textwrap.dedent("""\
There is an assertion in function _cairo_arc_in_direction().
CVE: CVE-2019-6461 & CVE-2019-6462
CVE: CVE-2019-6463 & CVE-2019-6464
Upstream-Status: Pending
Signed-off-by: Ross Burton <ross.burton@intel.com>
diff --git a/src/cairo-arc.c b/src/cairo-arc.c
index 390397bae..1bde774a4 100644
--- a/src/cairo-arc.c
+++ b/src/cairo-arc.c
@@ -186,7 +186,8 @@ _cairo_arc_in_direction (cairo_t *cr,
if (cairo_status (cr))
return;
- assert (angle_max >= angle_min);
+ if (angle_max < angle_min)
+ return;
if (angle_max - angle_min > 2 * M_PI * MAX_FULL_CIRCLES) {
angle_max = fmod (angle_max - angle_min, 2 * M_PI);
"""),
),
{"CVE-2019-6461", "CVE-2019-6462", "CVE-2019-6463", "CVE-2019-6464"},
)
def test_recipe_report_json(self):
config = """