diff options
| -rw-r--r-- | meta/lib/oe/cve_check.py | 166 | ||||
| -rw-r--r-- | meta/lib/oeqa/selftest/cases/cve_check.py | 205 |
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 | ||
| 7 | import collections | 7 | import collections |
| 8 | import re | ||
| 9 | import itertools | ||
| 10 | import functools | 8 | import functools |
| 9 | import itertools | ||
| 10 | import os.path | ||
| 11 | import re | ||
| 12 | import 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 | ||
| 74 | def get_patched_cves(d): | 76 | def 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 | 94 | def 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 | |||
| 114 | def 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 | |||
| 156 | def 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 = """ |
