diff mbox series

[v3,7/8] cve-check, vex, spdx: use metadata from linux-vulns to enhance CVE reporting

Message ID 20250429143904.634082-8-daniel.turull@ericsson.com
State New
Headers show
Series Check compiled files to filter kernel CVEs | expand

Commit Message

Daniel Turull April 29, 2025, 2:39 p.m. UTC
From: Daniel Turull <daniel.turull@ericsson.com>

Introducing two new options to check kernel CVEs using linux-vulns.

- CVE_CHECK_KERNEL = "1"
To add linux-vulns metadata on cve-report for cve_check, vex and spdx.
This will check for kernel CVEs, and use the metadata to resolve them.
Enabled by default

- CVE_CHECK_KERNEL_CONFIG = "1"
CVE Check using kernel compiled files, disabled by default since it requires
a compiled kernel and it will increase cve-check times.
Metadata in the CVE information includes the affected files, and using the
compiled files by the kernel we can ignore some of the cves.

The above variables are defined in cve_check.bbclass, vex.bbclass and
spdx.bbclass in case not all classes are used at the same time. The ones in
cve_check has priority.

Example of output with CVE_CHECK_KERNEL_CONFIG when using 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 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']"
},

And new information for the Unpatched showing where the fix (if any) is available:
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)"
},

Tested with cve-check, create-spdx2.2, create-spdx3.0, vex

CC: Peter Marko <peter.marko@siemens.com>
Signed-off-by: Daniel Turull <daniel.turull@ericsson.com>
---
 meta/classes/cve-check.bbclass   |  23 +++-
 meta/classes/spdx-common.bbclass |   3 +
 meta/classes/vex.bbclass         |  11 ++
 meta/lib/oe/cve_check.py         | 211 ++++++++++++++++++++++++++++++-
 4 files changed, 245 insertions(+), 3 deletions(-)
diff mbox series

Patch

diff --git a/meta/classes/cve-check.bbclass b/meta/classes/cve-check.bbclass
index 81512c255d..da396747f2 100644
--- a/meta/classes/cve-check.bbclass
+++ b/meta/classes/cve-check.bbclass
@@ -34,6 +34,13 @@  CVE_VERSION ??= "${PV}"
 # Possible database sources: NVD1, NVD2, FKIE
 NVD_DB_VERSION ?= "FKIE"
 
+# CVE Check using kernel CNA and compiled files
+CVE_CHECK_KERNEL ?= "1"
+# CVE Check using kernel compiled files
+CVE_CHECK_KERNEL_CONFIG ?= "0"
+# Location of the linux-vulns data
+CVE_CHECK_KERNEL_DB_DIR ?= "${DL_DIR}/CVE_CHECK/vulns"
+
 # Use different file names for each database source, as they synchronize at different moments, so may be slightly different
 CVE_CHECK_DB_FILENAME ?= "${@'nvdcve_2-2.db' if d.getVar('NVD_DB_VERSION') == 'NVD2' else 'nvdcve_1-3.db' if d.getVar('NVD_DB_VERSION') == 'NVD1' else 'nvdfkie_1-1.db'}"
 CVE_CHECK_DB_FETCHER ?= "${@'cve-update-nvd2-native' if d.getVar('NVD_DB_VERSION') == 'NVD2' else 'cve-update-db-native'}"
@@ -111,6 +118,9 @@  python () {
     if nvd_database_type not in ("NVD1", "NVD2", "FKIE"):
         bb.erroronce("Malformed NVD_DB_VERSION, must be one of: NVD1, NVD2, FKIE. Defaulting to NVD2")
         d.setVar("NVD_DB_VERSION", "NVD2")
+
+    from oe.cve_check import extend_cve_kernel_config
+    extend_cve_kernel_config(d, "do_cve_check")
 }
 
 def generate_json_report(d, out_path, link_path):
@@ -161,15 +171,24 @@  python do_cve_check () {
     """
     Check recipe for patched and unpatched CVEs
     """
-    from oe.cve_check import get_patched_cves
+    from oe.cve_check import get_patched_cves, get_kernel_cves
 
     with bb.utils.fileslocked([d.getVar("CVE_CHECK_DB_FILE_LOCK")], shared=True):
+        cve_data = {}
+        # Add all reported CVES from linux-vulns
+        cve_check_kernel = d.getVar("CVE_CHECK_KERNEL")
+        if "linux_kernel" in d.getVar("CVE_PRODUCT") and cve_check_kernel == "1":
+            kernel_unpatched_cves, kernel_patched_cves = get_kernel_cves(d)
+            cve_data.update(kernel_patched_cves)
+            cve_data.update(kernel_unpatched_cves)
         if os.path.exists(d.getVar("CVE_CHECK_DB_FILE")):
             try:
                 patched_cves = get_patched_cves(d)
+                # Update cve_data, this will cover the manually reported with CVE_STATUS
+                cve_data.update(patched_cves)
             except FileNotFoundError:
                 bb.fatal("Failure in searching patches")
-            cve_data, status = check_cves(d, patched_cves)
+            cve_data, status = check_cves(d, cve_data)
             if len(cve_data) or (d.getVar("CVE_CHECK_COVERAGE") == "1" and status):
                 get_cve_info(d, cve_data)
                 cve_write_data(d, cve_data, status)
diff --git a/meta/classes/spdx-common.bbclass b/meta/classes/spdx-common.bbclass
index 1e3249cbd3..83d35d0e3f 100644
--- a/meta/classes/spdx-common.bbclass
+++ b/meta/classes/spdx-common.bbclass
@@ -41,6 +41,9 @@  SPDX_MULTILIB_SSTATE_ARCHS ??= "${SSTATE_ARCHS}"
 python () {
     from oe.cve_check import extend_cve_status
     extend_cve_status(d)
+
+    from oe.cve_check import extend_cve_kernel_config
+    extend_cve_kernel_config(d, "do_create_spdx")
 }
 
 def create_spdx_source_deps(d):
diff --git a/meta/classes/vex.bbclass b/meta/classes/vex.bbclass
index 905d67b47d..3d49b1ad0e 100644
--- a/meta/classes/vex.bbclass
+++ b/meta/classes/vex.bbclass
@@ -26,6 +26,14 @@ 
 CVE_PRODUCT ??= "${BPN}"
 CVE_VERSION ??= "${PV}"
 
+CVE_CHECK_KERNEL_DB_DIR ?= "${DL_DIR}/CVE_CHECK/vulns"
+# CVE Check using kernel CNA and compiled files
+CVE_CHECK_KERNEL ?= "1"
+# CVE Check using kernel compiled files
+CVE_CHECK_KERNEL_CONFIG ?= "0"
+# Location of the linux-vulns data
+CVE_CHECK_KERNEL_DB_DIR ?= "${DL_DIR}/CVE_CHECK/vulns"
+
 CVE_CHECK_SUMMARY_DIR ?= "${LOG_DIR}/cve"
 
 CVE_CHECK_SUMMARY_FILE_NAME_JSON = "cve-summary.json"
@@ -78,6 +86,9 @@  python () {
 
     from oe.cve_check import extend_cve_status
     extend_cve_status(d)
+
+    from oe.cve_check import extend_cve_kernel_config
+    extend_cve_kernel_config(d, "do_generate_vex")
 }
 
 def generate_json_report(d, out_path, link_path):
diff --git a/meta/lib/oe/cve_check.py b/meta/lib/oe/cve_check.py
index ae194f27cf..b548402928 100644
--- a/meta/lib/oe/cve_check.py
+++ b/meta/lib/oe/cve_check.py
@@ -47,6 +47,7 @@  class Version():
             self._version.pre_l,
             self._version.pre_v
         )
+        self.version = version
 
     def __eq__(self, other):
         if not isinstance(other, Version):
@@ -58,6 +59,9 @@  class Version():
             return NotImplemented
         return self._key > other._key
 
+    def __str__(self) -> str:
+        return self.version
+
 def _cmpkey(release, patch_l, pre_l, pre_v):
     # remove leading 0
     _release = tuple(
@@ -202,8 +206,14 @@  def get_patched_cves(d):
                 "affected-product": decoded_status["product"],
             }
 
-    return patched_cves
+    # If we are parsing the kernel, check compiled files
+    cve_check_kernel = d.getVar("CVE_CHECK_KERNEL")
+    if "linux_kernel" in d.getVar("CVE_PRODUCT") and cve_check_kernel == "1":
+         bb.debug(1, "Checking kernel CVEs")
+         _kernel_unpatched_cves, kernel_patched_cves = get_kernel_cves(d)
+         patched_cves.update(kernel_patched_cves)
 
+    return patched_cves
 
 def get_cpe_ids(cve_product, version):
     """
@@ -376,3 +386,202 @@  def extend_cve_status(d):
                 d.setVarFlag("CVE_STATUS", cve, d.getVarFlag(cve_status_group, "status"))
         else:
             bb.warn("CVE_STATUS_GROUPS contains undefined variable %s" % cve_status_group)
+
+def extend_cve_kernel_config(d, task):
+    pn = d.getVar('PN')
+    # For kernel CVEs, add required dependencies
+    if "linux_kernel" in d.getVar("CVE_PRODUCT"):
+        if d.getVar("CVE_CHECK_KERNEL_CONFIG") == "1":
+            bb.debug(1, "Checking kernel CVEs using kernel config")
+            depends = f" {pn}:do_save_compiled_files "
+            d.appendVarFlag(task, "depends", depends)
+            d.setVar('CVE_CHECK_KERNEL','1')
+            d.setVar('SPDX_INCLUDE_COMPILED_SOURCE','1')
+        if d.getVar("CVE_CHECK_KERNEL") == "1":
+            bb.debug(1, "Checking kernel CVEs using linux-vulns")
+            d.appendVarFlag(task, "depends", " linux-vulns:do_fetch ")
+
+def get_kernel_cves(d):
+    """
+    Get CVEs for the kernel
+    """
+    import glob
+    import json
+    patched_cves = {}
+    unpatched_cves = {}
+    datadir = f"{d.getVar('CVE_CHECK_KERNEL_DB_DIR')}/cve/published/"
+    version_str = d.getVar("LINUX_VERSION")
+    check_config = d.getVar("CVE_CHECK_KERNEL_CONFIG")
+    version = Version(version_str)
+    base_version = Version(".".join(version_str.split(".")[0:2]))
+
+    # Check all CVES from kernel vulns
+    pattern = os.path.join(datadir, '**', f"CVE-*.json")
+    cve_files = glob.glob(pattern, recursive=True)
+    not_applicable_config = 0
+    fixed_as_later_backport = 0
+    for cve_file in cve_files:
+        cve_info = {}
+        with open(cve_file, "r") as f:
+            cve_info = json.load(f)
+
+        if len(cve_info) == 0:
+            bb.error(f"Not valid data in {cve_file}. Aborting")
+            break
+        cve_id = cve_info["cveMetadata"]["cveID"]
+
+        first_affected, fixed, backport_ver = get_kernel_fixed_versions(cve_info, base_version)
+        if not fixed:
+            if check_config == "1":
+                is_affected, affected_files = check_kernel_compiled_files(d, cve_info)
+            else:
+                is_affected = True
+            if not is_affected and len(affected_files) > 0:
+                bb.debug(1, f"{cve_id} - not applicable configuration since affected files not compiled: {affected_files}")
+                patched_cves[cve_id] = {
+                    "abbrev-status": "Ignored",
+                    "status": "not-applicable-config",
+                    "justification": f"Source code not compiled by config. {affected_files}"
+                }
+            else:
+                bb.debug(1, f"{cve_id} not fixed usptream")
+                description = cve_info["containers"]["cna"]["descriptions"][0]["value"]
+                unpatched_cves[cve_id] = {
+                    "abbrev-status": "Unpatched",
+                    "status": "version-in-range",
+                    "summary": description,
+                    "justification": f"No fix available upstream"
+                }
+        elif first_affected and version < first_affected:
+            bb.debug(1, f'{cve_id} - "fixed-version: only affects {first_affected} onwards"')
+            patched_cves[cve_id] = {
+                "abbrev-status": "Patched",
+                "status": "fixed-version",
+                "justification": f"only affects {first_affected} onwards"
+            }
+        elif fixed <= version:
+            bb.debug(1, f'{cve_id} - "fixed-version: Fixed from version {fixed}"')
+            patched_cves[cve_id] = {
+                "abbrev-status": "Patched",
+                "status": "fixed-version",
+                "justification": f"fixed-version: Fixed from version {fixed}"
+            }
+        else:
+            if backport_ver:
+                if backport_ver <= version:
+                    bb.debug(1, f'{cve_id} - "cpe-stable-backport: Backported in {backport_ver}"')
+                    patched_cves[cve_id] = {
+                        "abbrev-status": "Patched",
+                        "status": "cpe-stable-backport",
+                        "justification": f"Backported in {backport_ver}"
+                    }
+                else:
+                    bb.debug(1, f"{cve_id}: needs backporting (fixed from {backport_ver})")
+                    description = cve_info["containers"]["cna"]["descriptions"][0]["value"]
+                    unpatched_cves[cve_id] = {
+                        "abbrev-status": "Unpatched",
+                        "status": "version-in-range",
+                        "summary": description,
+                        "justification": f"Needs backporting (fixed from {backport_ver})"
+                    }
+                    fixed_as_later_backport += 1
+            else:
+                # Check if file affected
+                if check_config == "1":
+                    is_affected, affected_files = check_kernel_compiled_files(d, cve_info)
+                else:
+                    is_affected = True
+                if not is_affected and len(affected_files) > 0:
+                    bb.debug(1, f"{cve_id} - not applicable configuration since affected files not compiled: {affected_files}")
+                    patched_cves[cve_id] = {
+                         "abbrev-status": "Ignored",
+                         "status": "not-applicable-config",
+                         "justification": f"Source code not compiled by config. {affected_files}"
+                    }
+                    not_applicable_config +=1
+                else:
+                    bb.debug(1, f"{cve_id}: needs backporting (fixed from {fixed})")
+                    description = cve_info["containers"]["cna"]["descriptions"][0]["value"]
+                    unpatched_cves[cve_id] = {
+                        "abbrev-status": "Unpatched",
+                        "status": "version-in-range",
+                        "summary": description,
+                        "justification": f"Needs backporting (fixed from {fixed})"
+                    }
+    if len(cve_files) > 0:
+        bb.debug(1, f"Total CVEs ignored due to not applicable config {not_applicable_config}")
+        bb.debug(1, f"Total vulnerable CVEs: {len(unpatched_cves)}")
+        bb.debug(1, f"Total CVEs already backported in {base_version}: {fixed_as_later_backport}")
+    return unpatched_cves, patched_cves
+
+def check_kernel_compiled_files(d, cve_info):
+    """
+    Return if a CVE affected us depending on compiled files
+    """
+    import json
+    files_affected = []
+    kfiles = []
+    is_affected = False
+    with open(d.getVar('KERNEL_SRC_FILES'), 'r') as file:
+        for item in json.load(file):
+            kfiles.append(item['file'].replace(f"{d.getVar('S')}/",""))
+
+    for item in cve_info['containers']['cna']['affected']:
+        if item["defaultStatus"] == "affected":
+            if "programFiles" in item:
+                files = item['programFiles']
+                files_affected.extend(files)
+
+    if len(files_affected) > 0:
+        for f in files_affected:
+            if f in kfiles:
+                bb.debug(1, f"File match: {f}")
+                is_affected = True
+    return is_affected, files_affected
+
+def get_kernel_fixed_versions(cve_info, base_version):
+    '''
+    Get fixed versions for a given CVE
+    '''
+    first_affected = None
+    fixed = None
+    fixed_backport = None
+    next_version = Version(str(base_version) + ".5000")
+    for affected in cve_info["containers"]["cna"]["affected"]:
+        # In case the CVE info is not complete, it might not have default status and therefore
+        # we don't know the status of this CVE.
+        if not "defaultStatus" in affected:
+            return first_affected, fixed, fixed_backport
+        if affected["defaultStatus"] == "affected":
+            for version in affected["versions"]:
+                v = Version(version["version"])
+                if v == 0:
+                    # Skiping non-affected
+                    continue
+                if version["status"] == "affected" and not first_affected:
+                    first_affected = v
+                elif (version["status"] == "unaffected" and
+                    version['versionType'] == "original_commit_for_fix"):
+                    fixed = v
+                elif base_version < v and v < next_version:
+                    fixed_backport = v
+        elif affected["defaultStatus"] == "unaffected":
+            # Only specific versions are affected. We care only about our base version
+            if "versions" not in affected:
+                continue
+            for version in affected["versions"]:
+                if "versionType" not in version:
+                    continue
+                if version["versionType"] == "git":
+                    continue
+                v = Version(version["version"])
+                # in case it is not in our base version
+                less_than = Version(version["lessThan"])
+
+                if not first_affected:
+                    first_affected = v
+                fixed = less_than
+                if base_version < v and v < next_version:
+                    fixed_backport = less_than
+
+    return first_affected, fixed, fixed_backport