summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--meta/lib/oe/cve_check.py166
-rw-r--r--meta/lib/oeqa/selftest/cases/cve_check.py205
2 files changed, 317 insertions, 54 deletions
diff --git a/meta/lib/oe/cve_check.py b/meta/lib/oe/cve_check.py
index 280f9f613d..85a899a880 100644
--- a/meta/lib/oe/cve_check.py
+++ b/meta/lib/oe/cve_check.py
@@ -5,9 +5,11 @@
5# 5#
6 6
7import collections 7import collections
8import re
9import itertools
10import functools 8import functools
9import itertools
10import os.path
11import re
12import oe.patch
11 13
12_Version = collections.namedtuple( 14_Version = collections.namedtuple(
13 "_Version", ["release", "patch_l", "pre_l", "pre_v"] 15 "_Version", ["release", "patch_l", "pre_l", "pre_v"]
@@ -71,76 +73,132 @@ def _cmpkey(release, patch_l, pre_l, pre_v):
71 return _release, _patch, _pre 73 return _release, _patch, _pre
72 74
73 75
74def get_patched_cves(d): 76def parse_cve_from_filename(patch_filename):
75 """ 77 """
76 Get patches that solve CVEs using the "CVE: " tag. 78 Parses CVE ID from the filename
79
80 Matches the last "CVE-YYYY-ID" in the file name, also if written
81 in lowercase. Possible to have multiple CVE IDs in a single
82 file name, but only the last one will be detected from the file name.
83
84 Returns the last CVE ID foudn in the filename. If no CVE ID is found
85 an empty string is returned.
77 """ 86 """
87 cve_file_name_match = re.compile(r".*(CVE-\d{4}-\d{4,})", re.IGNORECASE)
78 88
79 import re 89 # Check patch file name for CVE ID
80 import oe.patch 90 fname_match = cve_file_name_match.search(patch_filename)
91 return fname_match.group(1).upper() if fname_match else ""
81 92
82 cve_match = re.compile(r"CVE:( CVE-\d{4}-\d+)+")
83 93
84 # Matches the last "CVE-YYYY-ID" in the file name, also if written 94def parse_cves_from_patch_contents(patch_contents):
85 # in lowercase. Possible to have multiple CVE IDs in a single 95 """
86 # file name, but only the last one will be detected from the file name. 96 Parses CVE IDs from patch contents
87 # However, patch files contents addressing multiple CVE IDs are supported
88 # (cve_match regular expression)
89 cve_file_name_match = re.compile(r".*(CVE-\d{4}-\d+)", re.IGNORECASE)
90 97
98 Matches all CVE IDs contained on a line that starts with "CVE: ". Any
99 delimiter (',', '&', "and", etc.) can be used without any issues. Multiple
100 "CVE:" lines can also exist.
101
102 Returns a set of all CVE IDs found in the patch contents.
103 """
104 cve_ids = set()
105 cve_match = re.compile(r"CVE-\d{4}-\d{4,}")
106 # Search for one or more "CVE: " lines
107 for line in patch_contents.split("\n"):
108 if not line.startswith("CVE:"):
109 continue
110 cve_ids.update(cve_match.findall(line))
111 return cve_ids
112
113
114def parse_cves_from_patch_file(patch_file):
115 """
116 Parses CVE IDs associated with a particular patch file, using both the filename
117 and patch contents.
118
119 Returns a set of all CVE IDs found in the patch filename and contents.
120 """
121 cve_ids = set()
122 filename_cve = parse_cve_from_filename(patch_file)
123 if filename_cve:
124 bb.debug(2, "Found %s from patch file name %s" % (filename_cve, patch_file))
125 cve_ids.add(parse_cve_from_filename(patch_file))
126
127 # Remote patches won't be present and compressed patches won't be
128 # unpacked, so say we're not scanning them
129 if not os.path.isfile(patch_file):
130 bb.note("%s is remote or compressed, not scanning content" % patch_file)
131 return cve_ids
132
133 with open(patch_file, "r", encoding="utf-8") as f:
134 try:
135 patch_text = f.read()
136 except UnicodeDecodeError:
137 bb.debug(
138 1,
139 "Failed to read patch %s using UTF-8 encoding"
140 " trying with iso8859-1" % patch_file,
141 )
142 f.close()
143 with open(patch_file, "r", encoding="iso8859-1") as f:
144 patch_text = f.read()
145
146 cve_ids.update(parse_cves_from_patch_contents(patch_text))
147
148 if not cve_ids:
149 bb.debug(2, "Patch %s doesn't solve CVEs" % patch_file)
150 else:
151 bb.debug(2, "Patch %s solves %s" % (patch_file, ", ".join(sorted(cve_ids))))
152
153 return cve_ids
154
155
156def get_patched_cves(d):
157 """
158 Determines the CVE IDs that have been solved by either patches incuded within
159 SRC_URI or by setting CVE_STATUS.
160
161 Returns a dictionary with the CVE IDs as keys and an associated dictonary of
162 relevant metadata as the value.
163 """
91 patched_cves = {} 164 patched_cves = {}
92 patches = oe.patch.src_patches(d) 165 patches = oe.patch.src_patches(d)
93 bb.debug(2, "Scanning %d patches for CVEs" % len(patches)) 166 bb.debug(2, "Scanning %d patches for CVEs" % len(patches))
167
168 # Check each patch file
94 for url in patches: 169 for url in patches:
95 patch_file = bb.fetch.decodeurl(url)[2] 170 patch_file = bb.fetch.decodeurl(url)[2]
96 171 for cve_id in parse_cves_from_patch_file(patch_file):
97 # Check patch file name for CVE ID 172 if cve_id not in patched_cves:
98 fname_match = cve_file_name_match.search(patch_file) 173 {
99 if fname_match: 174 "abbrev-status": "Patched",
100 cve = fname_match.group(1).upper() 175 "status": "fix-file-included",
101 patched_cves[cve] = {"abbrev-status": "Patched", "status": "fix-file-included", "resource": patch_file} 176 "resource": [patch_file],
102 bb.debug(2, "Found %s from patch file name %s" % (cve, patch_file)) 177 }
103 178 else:
104 # Remote patches won't be present and compressed patches won't be 179 patched_cves[cve_id]["resource"].append(patch_file)
105 # unpacked, so say we're not scanning them
106 if not os.path.isfile(patch_file):
107 bb.note("%s is remote or compressed, not scanning content" % patch_file)
108 continue
109
110 with open(patch_file, "r", encoding="utf-8") as f:
111 try:
112 patch_text = f.read()
113 except UnicodeDecodeError:
114 bb.debug(1, "Failed to read patch %s using UTF-8 encoding"
115 " trying with iso8859-1" % patch_file)
116 f.close()
117 with open(patch_file, "r", encoding="iso8859-1") as f:
118 patch_text = f.read()
119
120 # Search for one or more "CVE: " lines
121 text_match = False
122 for match in cve_match.finditer(patch_text):
123 # Get only the CVEs without the "CVE: " tag
124 cves = patch_text[match.start()+5:match.end()]
125 for cve in cves.split():
126 bb.debug(2, "Patch %s solves %s" % (patch_file, cve))
127 patched_cves[cve] = {"abbrev-status": "Patched", "status": "fix-file-included", "resource": patch_file}
128 text_match = True
129
130 if not fname_match and not text_match:
131 bb.debug(2, "Patch %s doesn't solve CVEs" % patch_file)
132 180
133 # Search for additional patched CVEs 181 # Search for additional patched CVEs
134 for cve in (d.getVarFlags("CVE_STATUS") or {}): 182 for cve_id in d.getVarFlags("CVE_STATUS") or {}:
135 decoded_status = decode_cve_status(d, cve) 183 decoded_status = decode_cve_status(d, cve_id)
136 products = d.getVar("CVE_PRODUCT") 184 products = d.getVar("CVE_PRODUCT")
137 if has_cve_product_match(decoded_status, products) == True: 185 if has_cve_product_match(decoded_status, products):
138 patched_cves[cve] = { 186 if cve_id in patched_cves:
187 bb.warn(
188 'CVE_STATUS[%s] = "%s" is overwriting previous status of "%s: %s"'
189 % (
190 cve_id,
191 d.getVarFlag("CVE_STATUS", cve_id),
192 patched_cves[cve_id]["abbrev-status"],
193 patched_cves[cve_id]["status"],
194 )
195 )
196 patched_cves[cve_id] = {
139 "abbrev-status": decoded_status["mapping"], 197 "abbrev-status": decoded_status["mapping"],
140 "status": decoded_status["detail"], 198 "status": decoded_status["detail"],
141 "justification": decoded_status["description"], 199 "justification": decoded_status["description"],
142 "affected-vendor": decoded_status["vendor"], 200 "affected-vendor": decoded_status["vendor"],
143 "affected-product": decoded_status["product"] 201 "affected-product": decoded_status["product"],
144 } 202 }
145 203
146 return patched_cves 204 return patched_cves
diff --git a/meta/lib/oeqa/selftest/cases/cve_check.py b/meta/lib/oeqa/selftest/cases/cve_check.py
index 3dd3e89d3e..511e4b81b4 100644
--- a/meta/lib/oeqa/selftest/cases/cve_check.py
+++ b/meta/lib/oeqa/selftest/cases/cve_check.py
@@ -120,6 +120,211 @@ class CVECheck(OESelftestTestCase):
120 self.assertEqual(has_cve_product_match(status, "test glibca:glibc"), True) 120 self.assertEqual(has_cve_product_match(status, "test glibca:glibc"), True)
121 self.assertEqual(has_cve_product_match(status, "glibca:glibc test"), True) 121 self.assertEqual(has_cve_product_match(status, "glibca:glibc test"), True)
122 122
123 def test_parse_cve_from_patch_filename(self):
124 from oe.cve_check import parse_cve_from_filename
125
126 # Patch filename without CVE ID
127 self.assertEqual(parse_cve_from_filename("0001-test.patch"), "")
128
129 # Patch with single CVE ID
130 self.assertEqual(
131 parse_cve_from_filename("CVE-2022-12345.patch"), "CVE-2022-12345"
132 )
133
134 # Patch with multiple CVE IDs
135 self.assertEqual(
136 parse_cve_from_filename("CVE-2022-41741-CVE-2022-41742.patch"),
137 "CVE-2022-41742",
138 )
139
140 # Patches with CVE ID and appended text
141 self.assertEqual(
142 parse_cve_from_filename("CVE-2023-3019-0001.patch"), "CVE-2023-3019"
143 )
144 self.assertEqual(
145 parse_cve_from_filename("CVE-2024-21886-1.patch"), "CVE-2024-21886"
146 )
147
148 # Patch with CVE ID and prepended text
149 self.assertEqual(
150 parse_cve_from_filename("grep-CVE-2012-5667.patch"), "CVE-2012-5667"
151 )
152 self.assertEqual(
153 parse_cve_from_filename("0001-CVE-2012-5667.patch"), "CVE-2012-5667"
154 )
155
156 # Patch with CVE ID and both prepended and appended text
157 self.assertEqual(
158 parse_cve_from_filename(
159 "0001-tpm2_import-fix-fixed-AES-key-CVE-2021-3565-0001.patch"
160 ),
161 "CVE-2021-3565",
162 )
163
164 # Only grab the last CVE ID in the filename
165 self.assertEqual(
166 parse_cve_from_filename("CVE-2012-5667-CVE-2012-5668.patch"),
167 "CVE-2012-5668",
168 )
169
170 # Test invalid CVE ID with incorrect length (must be at least 4 digits)
171 self.assertEqual(
172 parse_cve_from_filename("CVE-2024-001.patch"),
173 "",
174 )
175
176 # Test valid CVE ID with very long length
177 self.assertEqual(
178 parse_cve_from_filename("CVE-2024-0000000000000000000000001.patch"),
179 "CVE-2024-0000000000000000000000001",
180 )
181
182 def test_parse_cve_from_patch_contents(self):
183 import textwrap
184 from oe.cve_check import parse_cves_from_patch_contents
185
186 # Standard patch file excerpt without any patches
187 self.assertEqual(
188 parse_cves_from_patch_contents(
189 textwrap.dedent("""\
190 remove "*" for root since we don't have a /etc/shadow so far.
191
192 Upstream-Status: Inappropriate [configuration]
193
194 Signed-off-by: Scott Garman <scott.a.garman@intel.com>
195
196 --- base-passwd/passwd.master~nobash
197 +++ base-passwd/passwd.master
198 @@ -1,4 +1,4 @@
199 -root:*:0:0:root:/root:/bin/sh
200 +root::0:0:root:/root:/bin/sh
201 daemon:*:1:1:daemon:/usr/sbin:/bin/sh
202 bin:*:2:2:bin:/bin:/bin/sh
203 sys:*:3:3:sys:/dev:/bin/sh
204 """)
205 ),
206 set(),
207 )
208
209 # Patch file with multiple CVE IDs (space-separated)
210 self.assertEqual(
211 parse_cves_from_patch_contents(
212 textwrap.dedent("""\
213 There is an assertion in function _cairo_arc_in_direction().
214
215 CVE: CVE-2019-6461 CVE-2019-6462
216 Upstream-Status: Pending
217 Signed-off-by: Ross Burton <ross.burton@intel.com>
218
219 diff --git a/src/cairo-arc.c b/src/cairo-arc.c
220 index 390397bae..1bde774a4 100644
221 --- a/src/cairo-arc.c
222 +++ b/src/cairo-arc.c
223 @@ -186,7 +186,8 @@ _cairo_arc_in_direction (cairo_t *cr,
224 if (cairo_status (cr))
225 return;
226
227 - assert (angle_max >= angle_min);
228 + if (angle_max < angle_min)
229 + return;
230
231 if (angle_max - angle_min > 2 * M_PI * MAX_FULL_CIRCLES) {
232 angle_max = fmod (angle_max - angle_min, 2 * M_PI);
233 """),
234 ),
235 {"CVE-2019-6461", "CVE-2019-6462"},
236 )
237
238 # Patch file with multiple CVE IDs (comma-separated w/ both space and no space)
239 self.assertEqual(
240 parse_cves_from_patch_contents(
241 textwrap.dedent("""\
242 There is an assertion in function _cairo_arc_in_direction().
243
244 CVE: CVE-2019-6461,CVE-2019-6462, CVE-2019-6463
245 Upstream-Status: Pending
246 Signed-off-by: Ross Burton <ross.burton@intel.com>
247
248 diff --git a/src/cairo-arc.c b/src/cairo-arc.c
249 index 390397bae..1bde774a4 100644
250 --- a/src/cairo-arc.c
251 +++ b/src/cairo-arc.c
252 @@ -186,7 +186,8 @@ _cairo_arc_in_direction (cairo_t *cr,
253 if (cairo_status (cr))
254 return;
255
256 - assert (angle_max >= angle_min);
257 + if (angle_max < angle_min)
258 + return;
259
260 if (angle_max - angle_min > 2 * M_PI * MAX_FULL_CIRCLES) {
261 angle_max = fmod (angle_max - angle_min, 2 * M_PI);
262
263 """),
264 ),
265 {"CVE-2019-6461", "CVE-2019-6462", "CVE-2019-6463"},
266 )
267
268 # Patch file with multiple CVE IDs (&-separated)
269 self.assertEqual(
270 parse_cves_from_patch_contents(
271 textwrap.dedent("""\
272 There is an assertion in function _cairo_arc_in_direction().
273
274 CVE: CVE-2019-6461 & CVE-2019-6462
275 Upstream-Status: Pending
276 Signed-off-by: Ross Burton <ross.burton@intel.com>
277
278 diff --git a/src/cairo-arc.c b/src/cairo-arc.c
279 index 390397bae..1bde774a4 100644
280 --- a/src/cairo-arc.c
281 +++ b/src/cairo-arc.c
282 @@ -186,7 +186,8 @@ _cairo_arc_in_direction (cairo_t *cr,
283 if (cairo_status (cr))
284 return;
285
286 - assert (angle_max >= angle_min);
287 + if (angle_max < angle_min)
288 + return;
289
290 if (angle_max - angle_min > 2 * M_PI * MAX_FULL_CIRCLES) {
291 angle_max = fmod (angle_max - angle_min, 2 * M_PI);
292 """),
293 ),
294 {"CVE-2019-6461", "CVE-2019-6462"},
295 )
296
297 # Patch file with multiple lines with CVE IDs
298 self.assertEqual(
299 parse_cves_from_patch_contents(
300 textwrap.dedent("""\
301 There is an assertion in function _cairo_arc_in_direction().
302
303 CVE: CVE-2019-6461 & CVE-2019-6462
304
305 CVE: CVE-2019-6463 & CVE-2019-6464
306 Upstream-Status: Pending
307 Signed-off-by: Ross Burton <ross.burton@intel.com>
308
309 diff --git a/src/cairo-arc.c b/src/cairo-arc.c
310 index 390397bae..1bde774a4 100644
311 --- a/src/cairo-arc.c
312 +++ b/src/cairo-arc.c
313 @@ -186,7 +186,8 @@ _cairo_arc_in_direction (cairo_t *cr,
314 if (cairo_status (cr))
315 return;
316
317 - assert (angle_max >= angle_min);
318 + if (angle_max < angle_min)
319 + return;
320
321 if (angle_max - angle_min > 2 * M_PI * MAX_FULL_CIRCLES) {
322 angle_max = fmod (angle_max - angle_min, 2 * M_PI);
323
324 """),
325 ),
326 {"CVE-2019-6461", "CVE-2019-6462", "CVE-2019-6463", "CVE-2019-6464"},
327 )
123 328
124 def test_recipe_report_json(self): 329 def test_recipe_report_json(self):
125 config = """ 330 config = """