diff mbox series

[v4] sbom-cve-check: Add class for post-build CVE analysis

Message ID 20260311-add-sbom-cve-check-v4-1-f4e6c4cee8ca@bootlin.com
State Under Review
Headers show
Series [v4] sbom-cve-check: Add class for post-build CVE analysis | expand

Commit Message

Benjamin Robin March 11, 2026, 10:19 a.m. UTC
By default, the sbom-cve-check class generates these export files:
 - A JSON in `cve-check` format, named `${IMAGE_NAME}.cve-check.json`
 - An SPDX 3.0 SBOM, named `${IMAGE_NAME}.cve-check.spdx.json`.

An user can add or remove export file formats by using the
`SBOM_CVE_CHECK_EXPORT_VARS` variables.

By default, the CVE databases are downloaded using the following
recipes:
 - sbom-cve-check-update-cvelist-native.bb
 - sbom-cve-check-update-nvd-native.bb

The database download logic is implemented in sbom-cve-check-update-db.inc
The CVE databases are stored in the download directory (`DL_DIR`) by
default. This can be configured by the `SBOM_CVE_CHECK_DATABASES_DIR`
variable.

Access to the database is managed using an exclusive file lock (`flock`)
on the directory. During CVE analysis, sbom-cve-check acquires a shared
lock, allowing multiple analyses to run in parallel. However, if the
database is being updated, any ongoing CVE analysis is temporarily
paused.

This design ensures that, under normal circumstances, `sbom-cve-check`
can run without requiring network access. If a user needs network
access during execution (e.g., to download annotation databases),
they can set `SBOM_CVE_CHECK_ALLOW_NETWORK` to "1".

The CVE analysis runs only if either the original SBOM changes or the
CVE databases are updated. In the two CVE database-fetching recipes,
a file in the sysroot is written, containing the Git revision of the
fetched CVE database. This leverages BitBake's checksum computation for
sysroot files to determine if dependent tasks need re-execution.

Execute `sbom-cve-check` with the generated VEX manifest only if
enabled and if `SPDX_INCLUDE_VEX` is set to a value other than "all".
When `SPDX_INCLUDE_VEX=all`, the SPDX 3.0 file already contains all
the necessary information for CVE analysis, making the VEX manifest
redundant.

Signed-off-by: Benjamin Robin <benjamin.robin@bootlin.com>
---
 meta/classes-recipe/sbom-cve-check.bbclass         | 115 +++++++++++++++++++++
 meta/conf/distro/include/maintainers.inc           |   2 +
 meta/recipes-core/meta/sbom-cve-check-config.inc   |   4 +
 .../meta/sbom-cve-check-update-cvelist-native.bb   |   8 ++
 .../recipes-core/meta/sbom-cve-check-update-db.inc |  98 ++++++++++++++++++
 .../meta/sbom-cve-check-update-nvd-native.bb       |   8 ++
 6 files changed, 235 insertions(+)
diff mbox series

Patch

diff --git a/meta/classes-recipe/sbom-cve-check.bbclass b/meta/classes-recipe/sbom-cve-check.bbclass
new file mode 100644
index 000000000000..3709fa98d97e
--- /dev/null
+++ b/meta/classes-recipe/sbom-cve-check.bbclass
@@ -0,0 +1,115 @@ 
+# SPDX-License-Identifier: MIT
+
+require recipes-core/meta/sbom-cve-check-config.inc
+
+SBOM_CVE_CHECK_DEPLOYDIR = "${WORKDIR}/sbom_cve_check/image-deploy"
+
+SBOM_CVE_CHECK_EXTRA_ARGS[doc] = "Allow to specify extra arguments to sbom-cve-check. \
+    For example to add export flags for filtering (e.g., only export vulnerable CVEs). \
+"
+SBOM_CVE_CHECK_EXTRA_ARGS ??= ""
+
+SBOM_CVE_CHECK_EXPORT_VARS[doc] = "List of variables that declare export files to generate. \
+    Each variable must have a 'type' and an 'ext' flag set. \
+    The 'type' flag contains the value that is passed to the --export-type command flags. \
+    The 'ext' flag contains the filename extension (suffix). The output filename is going \
+    to be ${IMAGE_NAME}${ext} \
+"
+SBOM_CVE_CHECK_EXPORT_VARS ?= "SBOM_CVE_CHECK_EXPORT_SPDX3 SBOM_CVE_CHECK_EXPORT_CVECHECK"
+
+SBOM_CVE_CHECK_EXPORT_SPDX3[doc] = "Export configuration to generate an SPDX3 SBOM file, \
+    with the following name: ${IMAGE_NAME}.cve-check.spdx.json \
+"
+SBOM_CVE_CHECK_EXPORT_SPDX3[type] ?= "spdx3"
+SBOM_CVE_CHECK_EXPORT_SPDX3[ext] ?= ".cve-check.spdx.json"
+
+SBOM_CVE_CHECK_EXPORT_CVECHECK[doc] = "Export configuration to generate a JSON manifest \
+    in the same format as the cve-check class, with the following name: \
+    ${IMAGE_NAME}.cve-check.json \
+"
+SBOM_CVE_CHECK_EXPORT_CVECHECK[type] ?= "yocto-cve-check-manifest"
+SBOM_CVE_CHECK_EXPORT_CVECHECK[ext] ?= ".cve-check.json"
+
+SBOM_CVE_CHECK_ALLOW_NETWORK[doc] = "Set to 1 to enable network usage."
+SBOM_CVE_CHECK_ALLOW_NETWORK ?= "0"
+
+python do_sbom_cve_check() {
+    """
+    Task: Run sbom-cve-check analysis on SBOM.
+    """
+    import os
+    import bb
+    from oe.cve_check import update_symlinks
+
+    if not bb.data.inherits_class("create-spdx-3.0", d):
+        bb.fatal("Cannot execute sbom-cve-check missing create-spdx-3.0 inherit.")
+
+    sbom_path = d.expand("${DEPLOY_DIR_IMAGE}/${IMAGE_LINK_NAME}.spdx.json")
+    vex_manifest_path = d.expand("${DEPLOY_DIR_IMAGE}/${IMAGE_LINK_NAME}.json")
+    dl_db_dir = d.getVar("SBOM_CVE_CHECK_DATABASES_DIR")
+    deploy_dir = d.getVar("SBOM_CVE_CHECK_DEPLOYDIR")
+    img_link_name = d.getVar("IMAGE_LINK_NAME")
+    img_name = d.getVar("IMAGE_NAME")
+
+    export_files = []
+    for export_var in d.getVar("SBOM_CVE_CHECK_EXPORT_VARS").split():
+        export_ext = d.getVarFlag(export_var, "ext")
+        export_path = f"{deploy_dir}/{img_name}{export_ext}"
+        export_link = f"{deploy_dir}/{img_link_name}{export_ext}"
+        export_type = d.getVarFlag(export_var, "type")
+        export_files.append((export_type, export_path, export_link))
+
+    cmd_env = os.environ.copy()
+    cmd_env["SBOM_CVE_CHECK_DATABASES_DIR"] = dl_db_dir
+
+    cmd_args = [
+        d.expand("${STAGING_BINDIR_NATIVE}/sbom-cve-check"),
+        "--sbom-path",
+        sbom_path,
+    ]
+
+    # Assume that SPDX_INCLUDE_VEX is set globally to "all", and not only for the
+    # image recipe, which is very unlikely. This is not an issue to include the
+    # VEX manifest even if not needed.
+    if bb.data.inherits_class("vex", d) and d.getVar("SPDX_INCLUDE_VEX") != "all":
+        cmd_args.extend(["--yocto-vex-manifest", vex_manifest_path])
+
+    for export_file in export_files:
+        cmd_args.extend(
+            ["--export-type", export_file[0], "--export-path", export_file[1]]
+        )
+
+    cmd_args.extend(d.getVar("SBOM_CVE_CHECK_EXTRA_ARGS").split())
+
+    try:
+        bb.note("Running: {}".format(" ".join(cmd_args)))
+        bb.process.run(cmd_args, env=cmd_env)
+    except bb.process.ExecutionError as e:
+        bb.error(f"sbom-cve-check failed: {e}")
+        return
+
+    for export_file in export_files:
+        bb.note(f"sbom-cve-check exported: {export_file[1]}")
+        update_symlinks(export_file[1], export_file[2])
+}
+
+addtask do_sbom_cve_check after do_create_image_sbom_spdx before do_build
+
+SSTATETASKS += "do_sbom_cve_check"
+do_sbom_cve_check[cleandirs] = "${SBOM_CVE_CHECK_DEPLOYDIR}"
+do_sbom_cve_check[sstate-inputdirs] = "${SBOM_CVE_CHECK_DEPLOYDIR}"
+do_sbom_cve_check[sstate-outputdirs] = "${DEPLOY_DIR_IMAGE}"
+do_sbom_cve_check[depends] += " \
+    python3-sbom-cve-check-native:do_populate_sysroot \
+    ${@oe.utils.conditional('SBOM_CVE_CHECK_ALLOW_NETWORK','0',' \
+        sbom-cve-check-update-cvelist-native:do_populate_sysroot \
+        sbom-cve-check-update-nvd-native:do_populate_sysroot \
+    ','',d)} \
+"
+
+do_sbom_cve_check[network] = "${SBOM_CVE_CHECK_ALLOW_NETWORK}"
+
+python do_sbom_cve_check_setscene() {
+    sstate_setscene(d)
+}
+addtask do_sbom_cve_check_setscene
diff --git a/meta/conf/distro/include/maintainers.inc b/meta/conf/distro/include/maintainers.inc
index 3c7fc4974de3..71e5d77cb7d6 100644
--- a/meta/conf/distro/include/maintainers.inc
+++ b/meta/conf/distro/include/maintainers.inc
@@ -790,6 +790,8 @@  RECIPE_MAINTAINER:pn-sassc = "Simone Weiß <simone.p.weiss@posteo.com>"
 RECIPE_MAINTAINER:pn-sato-icon-theme = "Richard Purdie <richard.purdie@linuxfoundation.org>"
 RECIPE_MAINTAINER:pn-sato-screenshot = "Ross Burton <ross.burton@arm.com>"
 RECIPE_MAINTAINER:pn-sbc = "Unassigned <unassigned@yoctoproject.org>"
+RECIPE_MAINTAINER:pn-sbom-cve-check-update-cvelist-native = "Benjamin Robin <benjamin.robin@bootlin.com>"
+RECIPE_MAINTAINER:pn-sbom-cve-check-update-nvd-native = "Benjamin Robin <benjamin.robin@bootlin.com>"
 RECIPE_MAINTAINER:pn-scdoc = "Alex Kiernan <alex.kiernan@gmail.com>"
 RECIPE_MAINTAINER:pn-screen = "Unassigned <unassigned@yoctoproject.org>"
 RECIPE_MAINTAINER:pn-seatd = "Unassigned <unassigned@yoctoproject.org>"
diff --git a/meta/recipes-core/meta/sbom-cve-check-config.inc b/meta/recipes-core/meta/sbom-cve-check-config.inc
new file mode 100644
index 000000000000..a2a645825878
--- /dev/null
+++ b/meta/recipes-core/meta/sbom-cve-check-config.inc
@@ -0,0 +1,4 @@ 
+# SPDX-License-Identifier: MIT
+
+SBOM_CVE_CHECK_DATABASES_DIR ??= "${DL_DIR}/sbom_cve_check/databases"
+SBOM_CVE_CHECK_DATABASES_DIR[doc] = "Download directory path where to store the CVE databases"
diff --git a/meta/recipes-core/meta/sbom-cve-check-update-cvelist-native.bb b/meta/recipes-core/meta/sbom-cve-check-update-cvelist-native.bb
new file mode 100644
index 000000000000..75f7370839e6
--- /dev/null
+++ b/meta/recipes-core/meta/sbom-cve-check-update-cvelist-native.bb
@@ -0,0 +1,8 @@ 
+SUMMARY = "Updates the CVE List database"
+LICENSE = "MIT"
+
+HOMEPAGE = "https://github.com/CVEProject/cvelistV5"
+SBOM_CVE_CHECK_DB_NAME = "cvelist"
+SBOM_CVE_CHECK_FETCH_URL = "https://github.com/CVEProject/cvelistV5.git"
+
+require sbom-cve-check-update-db.inc
diff --git a/meta/recipes-core/meta/sbom-cve-check-update-db.inc b/meta/recipes-core/meta/sbom-cve-check-update-db.inc
new file mode 100644
index 000000000000..8f384fa80d47
--- /dev/null
+++ b/meta/recipes-core/meta/sbom-cve-check-update-db.inc
@@ -0,0 +1,98 @@ 
+# SPDX-License-Identifier: MIT
+
+INHIBIT_DEFAULT_DEPS = "1"
+EXCLUDE_FROM_WORLD = "1"
+
+inherit native
+require sbom-cve-check-config.inc
+
+SBOM_CVE_CHECK_DB_NAME[doc] = "Database name, which is the Git repository directory name. \
+    The git repository will be stored in ${SBOM_CVE_CHECK_DATABASES_DIR)/"
+
+SBOM_CVE_CHECK_FETCH_URL[doc] = "Git clone URL of the CVE database"
+
+SBOM_CVE_CHECK_FETCH_INTERVAL ??= "57600"
+SBOM_CVE_CHECK_FETCH_INTERVAL[doc] = "\
+    CVE database update interval, in seconds. By default every 16 hours. \
+    Use 0 to force the update. Use a negative value to skip the update. \
+"
+
+python do_fetch() {
+    from datetime import datetime, timezone, timedelta
+    import fcntl
+    import os
+    import pathlib
+    import subprocess
+
+    bb.utils.export_proxies(d)
+
+    fetch_interval = int(d.get("SBOM_CVE_CHECK_FETCH_INTERVAL"))
+    git_url = d.getVar("SBOM_CVE_CHECK_FETCH_URL")
+    git_name = d.getVar("SBOM_CVE_CHECK_DB_NAME")
+    git_dir = pathlib.Path(d.getVar("SBOM_CVE_CHECK_DATABASES_DIR")).joinpath(git_name)
+    git_dir.mkdir(parents=True, exist_ok=True)
+
+    def _exec_git_cmd(args):
+        cmd = ["git"]
+        cmd.extend(args)
+        return subprocess.run(
+            cmd,
+            input="",
+            capture_output=True,
+            check=True,
+            cwd=git_dir,
+            encoding="utf-8",
+        )
+
+    # Lock the git directory: take an exclusive lock
+    lock_fd = os.open(git_dir, os.O_RDONLY | os.O_NOCTTY)
+    try:
+        fcntl.flock(lock_fd, fcntl.LOCK_EX)
+
+        # Clone the git repository if it does not exist
+        if not git_dir.joinpath(".git", "HEAD").is_file():
+            _exec_git_cmd(["clone", "--depth", "1", "--single-branch", git_url, "."])
+            return
+
+        # Check if an update is necessary
+        if fetch_interval < 0:
+            return
+
+        if fetch_interval > 0:
+            # Get date of last commit
+            r = _exec_git_cmd(["show", "-s", "--format=%ct", "HEAD"])
+            commit_date = datetime.fromtimestamp(int(r.stdout.strip()), tz=timezone.utc)
+            delta_last_commit = datetime.now(timezone.utc) - commit_date
+            if delta_last_commit < timedelta(seconds=fetch_interval):
+                return
+
+        _exec_git_cmd(["pull"])
+    except subprocess.SubprocessError as e:
+        bb.error(f"{e.cmd} failed:\n{e.stdout}\n---\n{e.stderr}\n")
+    finally:
+        # Release the exclusive lock
+        os.close(lock_fd)
+}
+
+do_fetch[file-checksums] = ""
+do_fetch[vardeps] += " \
+    SBOM_CVE_CHECK_DATABASES_DIR \
+    SBOM_CVE_CHECK_DB_NAME \
+    SBOM_CVE_CHECK_FETCH_URL \
+    SBOM_CVE_CHECK_FETCH_INTERVAL \
+"
+
+do_fetch[nostamp] = "1"
+
+# Leverage BitBake's checksum computation for populated sysroot files to determine
+# whether other recipe tasks dependent on this output need to be re-executed.
+# This serves as a workaround to avoid unnecessary runs of `sbom-cve-check` when
+# the database remains unchanged, given that `do_fetch[nostamp] = "1"` is set.
+do_compile() {
+    git_dir="${SBOM_CVE_CHECK_DATABASES_DIR}/${SBOM_CVE_CHECK_DB_NAME}"
+    git -C "${git_dir}" rev-parse --verify "HEAD^{object}" > "${S}/${SBOM_CVE_CHECK_DB_NAME}.rev"
+}
+
+do_install() {
+    install -m 644 -D -t "${D}${datadir}/sbom_cve_check/databases/" "${S}/${SBOM_CVE_CHECK_DB_NAME}.rev"
+}
diff --git a/meta/recipes-core/meta/sbom-cve-check-update-nvd-native.bb b/meta/recipes-core/meta/sbom-cve-check-update-nvd-native.bb
new file mode 100644
index 000000000000..fb7b304ac7e7
--- /dev/null
+++ b/meta/recipes-core/meta/sbom-cve-check-update-nvd-native.bb
@@ -0,0 +1,8 @@ 
+SUMMARY = "Updates the NVD CVE database"
+LICENSE = "MIT"
+
+HOMEPAGE = "https://github.com/fkie-cad/nvd-json-data-feeds"
+SBOM_CVE_CHECK_DB_NAME = "nvd-fkie"
+SBOM_CVE_CHECK_FETCH_URL = "https://github.com/fkie-cad/nvd-json-data-feeds.git"
+
+require sbom-cve-check-update-db.inc