poky/scripts/contrib/improve_kernel_cve_report.py
Daniel Turull 5dff1c40db improve_kernel_cve_report: add script for postprocesing of kernel CVE data
Adding postprocessing script to process data from linux CNA that includes more accurate metadata and it is updated directly by the source.

Example of enhanced CVE from a report from cve-check:

{
  "id": "CVE-2024-26710",
  "status": "Ignored",
  "link": "https://nvd.nist.gov/vuln/detail/CVE-2024-26710",
  "summary": "In the Linux kernel, the following vulnerability [...]",
  "scorev2": "0.0",
  "scorev3": "5.5",
  "scorev4": "0.0",
  "modified": "2025-03-17T15:36:11.620",
  "vector": "LOCAL",
  "vectorString": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H",
  "detail": "not-applicable-config",
  "description": "Source code not compiled by config. ['arch/powerpc/include/asm/thread_info.h']"
},

And same from a report generated with vex:
{
  "id": "CVE-2024-26710",
  "status": "Ignored",
  "link": "https://nvd.nist.gov/vuln/detail/CVE-2024-26710",
  "detail": "not-applicable-config",
  "description": "Source code not compiled by config. ['arch/powerpc/include/asm/thread_info.h']"
},

For unpatched CVEs, provide more context in the description:
Tested with 6.12.22 kernel
{
  "id": "CVE-2025-39728",
  "status": "Unpatched",
  "link": "https://nvd.nist.gov/vuln/detail/CVE-2025-39728",
  "summary": "In the Linux kernel, the following vulnerability has been [...],
  "scorev2": "0.0",
  "scorev3": "0.0",
  "scorev4": "0.0",
  "modified": "2025-04-21T14:23:45.950",
  "vector": "UNKNOWN",
  "vectorString": "UNKNOWN",
  "detail": "version-in-range",
  "description": "Needs backporting (fixed from 6.12.23)"
},

CC: Peter Marko <peter.marko@siemens.com>
CC: Marta Rybczynska <rybczynska@gmail.com>
(From OE-Core rev: e60b1759c1aea5b8f5317e46608f0a3e782ecf57)

Signed-off-by: Daniel Turull <daniel.turull@ericsson.com>
Signed-off-by: Mathieu Dubois-Briand <mathieu.dubois-briand@bootlin.com>
Signed-off-by: Richard Purdie <richard.purdie@linuxfoundation.org>
2025-06-19 21:54:43 +01:00

468 lines
18 KiB
Python
Executable File

#! /usr/bin/env python3
#
# Copyright OpenEmbedded Contributors
#
# The script uses another source of CVE information from linux-vulns
# to enrich the cve-summary from cve-check or vex.
# It can also use the list of compiled files from the kernel spdx to ignore CVEs
# that are not affected since the files are not compiled.
#
# It creates a new json file with updated CVE information
#
# Compiled files can be extracted adding the following in local.conf
# SPDX_INCLUDE_COMPILED_SOURCES:pn-linux-yocto = "1"
#
# Tested with the following CVE sources:
# - https://git.kernel.org/pub/scm/linux/security/vulns.git
# - https://github.com/CVEProject/cvelistV5
#
# Example:
# python3 ./openembedded-core/scripts/contrib/improve_kernel_cve_report.py --spdx tmp/deploy/spdx/3.0.1/qemux86_64/recipes/recipe-linux-yocto.spdx.json --kernel-version 6.12.27 --datadir ./vulns
# python3 ./openembedded-core/scripts/contrib/improve_kernel_cve_report.py --spdx tmp/deploy/spdx/3.0.1/qemux86_64/recipes/recipe-linux-yocto.spdx.json --datadir ./vulns --old-cve-report build/tmp/log/cve/cve-summary.json
#
# SPDX-License-Identifier: GPLv2
import argparse
import json
import sys
import logging
import glob
import os
import pathlib
from packaging.version import Version
def is_linux_cve(cve_info):
'''Return true is the CVE belongs to Linux'''
if not "affected" in cve_info["containers"]["cna"]:
return False
for affected in cve_info["containers"]["cna"]["affected"]:
if not "product" in affected:
return False
if affected["product"] == "Linux" and affected["vendor"] == "Linux":
return True
return False
def get_kernel_cves(datadir, compiled_files, version):
"""
Get CVEs for the kernel
"""
cves = {}
check_config = len(compiled_files) > 0
base_version = Version(f"{version.major}.{version.minor}")
# Check all CVES from kernel vulns
pattern = os.path.join(datadir, '**', "CVE-*.json")
cve_files = glob.glob(pattern, recursive=True)
not_applicable_config = 0
fixed_as_later_backport = 0
vulnerable = 0
not_vulnerable = 0
for cve_file in sorted(cve_files):
cve_info = {}
with open(cve_file, "r", encoding='ISO-8859-1') as f:
cve_info = json.load(f)
if len(cve_info) == 0:
logging.error("Not valid data in %s. Aborting", cve_file)
break
if not is_linux_cve(cve_info):
continue
cve_id = os.path.basename(cve_file)[:-5]
description = cve_info["containers"]["cna"]["descriptions"][0]["value"]
if cve_file.find("rejected") >= 0:
logging.debug("%s is rejected by the CNA", cve_id)
cves[cve_id] = {
"id": cve_id,
"status": "Ignored",
"detail": "rejected",
"summary": description,
"description": f"Rejected by CNA"
}
continue
if any(elem in cve_file for elem in ["review", "reverved", "testing"]):
continue
is_vulnerable, first_affected, last_affected, better_match_first, better_match_last, affected_versions = get_cpe_applicability(cve_info, version)
logging.debug("%s: %s (%s - %s) (%s - %s)", cve_id, is_vulnerable, better_match_first, better_match_last, first_affected, last_affected)
if is_vulnerable is None:
logging.warning("%s doesn't have good metadata", cve_id)
if is_vulnerable:
is_affected = True
affected_files = []
if check_config:
is_affected, affected_files = check_kernel_compiled_files(compiled_files, cve_info)
if not is_affected and len(affected_files) > 0:
logging.debug(
"%s - not applicable configuration since affected files not compiled: %s",
cve_id, affected_files)
cves[cve_id] = {
"id": cve_id,
"status": "Ignored",
"detail": "not-applicable-config",
"summary": description,
"description": f"Source code not compiled by config. {affected_files}"
}
not_applicable_config +=1
# Check if we have backport
else:
if not better_match_last:
fixed_in = last_affected
else:
fixed_in = better_match_last
logging.debug("%s needs backporting (fixed from %s)", cve_id, fixed_in)
cves[cve_id] = {
"id": cve_id,
"status": "Unpatched",
"detail": "version-in-range",
"summary": description,
"description": f"Needs backporting (fixed from {fixed_in})"
}
vulnerable += 1
if (better_match_last and
Version(f"{better_match_last.major}.{better_match_last.minor}") == base_version):
fixed_as_later_backport += 1
# Not vulnerable
else:
if not first_affected:
logging.debug("%s - not known affected %s",
cve_id,
better_match_last)
cves[cve_id] = {
"id": cve_id,
"status": "Patched",
"detail": "version-not-in-range",
"summary": description,
"description": "No CPE match"
}
not_vulnerable += 1
continue
backport_base = Version(f"{better_match_last.major}.{better_match_last.minor}")
if version < first_affected:
logging.debug('%s - fixed-version: only affects %s onwards',
cve_id,
first_affected)
cves[cve_id] = {
"id": cve_id,
"status": "Patched",
"detail": "fixed-version",
"summary": description,
"description": f"only affects {first_affected} onwards"
}
not_vulnerable += 1
elif last_affected <= version:
logging.debug("%s - fixed-version: Fixed from version %s",
cve_id,
last_affected)
cves[cve_id] = {
"id": cve_id,
"status": "Patched",
"detail": "fixed-version",
"summary": description,
"description": f"fixed-version: Fixed from version {last_affected}"
}
not_vulnerable += 1
elif backport_base == base_version:
logging.debug("%s - cpe-stable-backport: Backported in %s",
cve_id,
better_match_last)
cves[cve_id] = {
"id": cve_id,
"status": "Patched",
"detail": "cpe-stable-backport",
"summary": description,
"description": f"Backported in {better_match_last}"
}
not_vulnerable += 1
else:
logging.debug("%s - version not affected %s", cve_id, str(affected_versions))
cves[cve_id] = {
"id": cve_id,
"status": "Patched",
"detail": "version-not-in-range",
"summary": description,
"description": f"Range {affected_versions}"
}
not_vulnerable += 1
logging.info("Total CVEs ignored due to not applicable config: %d", not_applicable_config)
logging.info("Total CVEs not vulnerable due version-not-in-range: %d", not_vulnerable)
logging.info("Total vulnerable CVEs: %d", vulnerable)
logging.info("Total CVEs already backported in %s: %s", base_version,
fixed_as_later_backport)
return cves
def read_spdx(spdx_file):
'''Open SPDX file and extract compiled files'''
with open(spdx_file, 'r', encoding='ISO-8859-1') as f:
spdx = json.load(f)
if "spdxVersion" in spdx:
if spdx["spdxVersion"] == "SPDX-2.2":
return read_spdx2(spdx)
if "@graph" in spdx:
return read_spdx3(spdx)
return []
def read_spdx2(spdx):
'''
Read spdx2 compiled files from spdx
'''
cfiles = set()
if 'files' not in spdx:
return cfiles
for item in spdx['files']:
for ftype in item['fileTypes']:
if ftype == "SOURCE":
filename = item["fileName"][item["fileName"].find("/")+1:]
cfiles.add(filename)
return cfiles
def read_spdx3(spdx):
'''
Read spdx3 compiled files from spdx
'''
cfiles = set()
for item in spdx["@graph"]:
if "software_primaryPurpose" not in item:
continue
if item["software_primaryPurpose"] == "source":
filename = item['name'][item['name'].find("/")+1:]
cfiles.add(filename)
return cfiles
def check_kernel_compiled_files(compiled_files, cve_info):
"""
Return if a CVE affected us depending on compiled files
"""
files_affected = set()
is_affected = False
for item in cve_info['containers']['cna']['affected']:
if "programFiles" in item:
for f in item['programFiles']:
if f not in files_affected:
files_affected.add(f)
if len(files_affected) > 0:
for f in files_affected:
if f in compiled_files:
logging.debug("File match: %s", f)
is_affected = True
return is_affected, files_affected
def get_cpe_applicability(cve_info, v):
'''
Check if version is affected and return affected versions
'''
base_branch = Version(f"{v.major}.{v.minor}")
affected = []
if not 'cpeApplicability' in cve_info["containers"]["cna"]:
return None, None, None, None, None, None
for nodes in cve_info["containers"]["cna"]["cpeApplicability"]:
for node in nodes.values():
vulnerable = False
matched_branch = False
first_affected = Version("5000")
last_affected = Version("0")
better_match_first = Version("0")
better_match_last = Version("5000")
if len(node[0]['cpeMatch']) == 0:
first_affected = None
last_affected = None
better_match_first = None
better_match_last = None
for cpe_match in node[0]['cpeMatch']:
version_start_including = Version("0")
version_end_excluding = Version("0")
if 'versionStartIncluding' in cpe_match:
version_start_including = Version(cpe_match['versionStartIncluding'])
else:
version_start_including = Version("0")
# if versionEndExcluding is missing we are in a branch, which is not fixed.
if "versionEndExcluding" in cpe_match:
version_end_excluding = Version(cpe_match["versionEndExcluding"])
else:
# if versionEndExcluding is missing we are in a branch, which is not fixed.
version_end_excluding = Version(
f"{version_start_including.major}.{version_start_including.minor}.5000"
)
affected.append(f" {version_start_including}-{version_end_excluding}")
# Detect if versionEnd is in fixed in base branch. It has precedence over the rest
branch_end = Version(f"{version_end_excluding.major}.{version_end_excluding.minor}")
if branch_end == base_branch:
if version_start_including <= v < version_end_excluding:
vulnerable = cpe_match['vulnerable']
# If we don't match in our branch, we are not vulnerable,
# since we have a backport
matched_branch = True
better_match_first = version_start_including
better_match_last = version_end_excluding
if version_start_including <= v < version_end_excluding and not matched_branch:
if version_end_excluding < better_match_last:
better_match_first = max(version_start_including, better_match_first)
better_match_last = min(better_match_last, version_end_excluding)
vulnerable = cpe_match['vulnerable']
matched_branch = True
first_affected = min(version_start_including, first_affected)
last_affected = max(version_end_excluding, last_affected)
# Not a better match, we use the first and last affected instead of the fake .5000
if vulnerable and better_match_last == Version(f"{base_branch}.5000"):
better_match_last = last_affected
better_match_first = first_affected
return vulnerable, first_affected, last_affected, better_match_first, better_match_last, affected
def copy_data(old, new):
'''Update dictionary with new entries, while keeping the old ones'''
for k in new.keys():
old[k] = new[k]
return old
# Function taken from cve_check.bbclass. Adapted to cve fields
def cve_update(cve_data, cve, entry):
# If no entry, just add it
if cve not in cve_data:
cve_data[cve] = entry
return
# If we are updating, there might be change in the status
if cve_data[cve]['status'] == "Unknown":
cve_data[cve] = copy_data(cve_data[cve], entry)
return
if cve_data[cve]['status'] == entry['status']:
return
if entry['status'] == "Unpatched" and cve_data[cve]['status'] == "Patched":
logging.warning("CVE entry %s update from Patched to Unpatched from the scan result", cve)
cve_data[cve] = copy_data(cve_data[cve], entry)
return
if entry['status'] == "Patched" and cve_data[cve]['status'] == "Unpatched":
logging.warning("CVE entry %s update from Unpatched to Patched from the scan result", cve)
cve_data[cve] = copy_data(cve_data[cve], entry)
return
# If we have an "Ignored", it has a priority
if cve_data[cve]['status'] == "Ignored":
logging.debug("CVE %s not updating because Ignored", cve)
return
# If we have an "Ignored", it has a priority
if entry['status'] == "Ignored":
cve_data[cve] = copy_data(cve_data[cve], entry)
logging.debug("CVE entry %s updated from Unpatched to Ignored", cve)
return
logging.warning("Unhandled CVE entry update for %s %s from %s %s to %s",
cve, cve_data[cve]['status'], cve_data[cve]['detail'], entry['status'], entry['detail'])
def main():
parser = argparse.ArgumentParser(
description="Update cve-summary with kernel compiled files and kernel CVE information"
)
parser.add_argument(
"-s",
"--spdx",
help="SPDX2/3 for the kernel. Needs to include compiled sources",
)
parser.add_argument(
"--datadir",
type=pathlib.Path,
help="Directory where CVE data is",
required=True
)
parser.add_argument(
"--old-cve-report",
help="CVE report to update. (Optional)",
)
parser.add_argument(
"--kernel-version",
help="Kernel version. Needed if old cve_report is not provided (Optional)",
type=Version
)
parser.add_argument(
"--new-cve-report",
help="Output file",
default="cve-summary-enhance.json"
)
parser.add_argument(
"-D",
"--debug",
help='Enable debug ',
action="store_true")
args = parser.parse_args()
if args.debug:
log_level=logging.DEBUG
else:
log_level=logging.INFO
logging.basicConfig(format='[%(filename)s:%(lineno)d] %(message)s', level=log_level)
if not args.kernel_version and not args.old_cve_report:
parser.error("either --kernel-version or --old-cve-report are needed")
return -1
# by default we don't check the compiled files, unless provided
compiled_files = []
if args.spdx:
compiled_files = read_spdx(args.spdx)
logging.info("Total compiled files %d", len(compiled_files))
if args.old_cve_report:
with open(args.old_cve_report, encoding='ISO-8859-1') as f:
cve_report = json.load(f)
else:
#If summary not provided, we create one
cve_report = {
"version": "1",
"package": [
{
"name": "linux-yocto",
"version": str(args.kernel_version),
"products": [
{
"product": "linux_kernel",
"cvesInRecord": "Yes"
}
],
"issue": []
}
]
}
for pkg in cve_report['package']:
is_kernel = False
for product in pkg['products']:
if product['product'] == "linux_kernel":
is_kernel=True
if not is_kernel:
continue
kernel_cves = get_kernel_cves(args.datadir,
compiled_files,
Version(pkg["version"]))
logging.info("Total kernel cves from kernel CNA: %s", len(kernel_cves))
cves = {issue["id"]: issue for issue in pkg["issue"]}
logging.info("Total kernel before processing cves: %s", len(cves))
for cve in kernel_cves:
cve_update(cves, cve, kernel_cves[cve])
pkg["issue"] = []
for cve in sorted(cves):
pkg["issue"].extend([cves[cve]])
logging.info("Total kernel cves after processing: %s", len(pkg['issue']))
with open(args.new_cve_report, "w", encoding='ISO-8859-1') as f:
json.dump(cve_report, f, indent=2)
return 0
if __name__ == "__main__":
sys.exit(main())