[v2] cve-check: add coverage statistics on recipes without CVEs

Message ID 20211220150347.13577-1-rybczynska@gmail.com
State New
Headers show
Series [v2] cve-check: add coverage statistics on recipes without CVEs | expand

Commit Message

Marta Rybczynska Dec. 20, 2021, 3:03 p.m. UTC
Until now the CVE checker was giving information about CVEs found for
a product (or more products) contained in a recipe. However, there was
no easy way to find out which products or recipes have no CVEs. Having
no reported CVEs might mean there are simply none, but can also mean
a product name (CPE) mismatch.

This patch adds CVE_CHECK_COVERAGE option enabling a new type of
statistics. A new file (*.cves_coverage) shows if a recipe has any
CVEs found in the NVD database, and if so, for which products.

This option is expected to help with an identification of recipes with
mismatched CPEs, issues in the database and more.

An example entry:
LAYER: meta
PACKAGE NAME: libsdl2-native
PACKAGE VERSION: 2.0.14
CVES FOUND IN RECIPE: Yes
    PRODUCT: simple_directmedia_layer (Yes)
    PRODUCT: sdl (No)

Signed-off-by: Marta Rybczynska <marta.rybczynska@huawei.com>
---
 meta/classes/cve-check.bbclass | 115 ++++++++++++++++++++++++++++-----
 1 file changed, 99 insertions(+), 16 deletions(-)

Comments

Ross Burton Dec. 22, 2021, 10:04 a.m. UTC | #1
On Mon, 20 Dec 2021 at 15:04, Marta Rybczynska <rybczynska@gmail.com> wrote:
> An example entry:
> LAYER: meta
> PACKAGE NAME: libsdl2-native
> PACKAGE VERSION: 2.0.14
> CVES FOUND IN RECIPE: Yes
>     PRODUCT: simple_directmedia_layer (Yes)
>     PRODUCT: sdl (No)

Is this meant to be human-readable or machine-readable?  Currently its
hard for a human to read (uppercase field names, key/value pairs) but
would also be hard to trivially parse.

I'd actually prefer to see an actual machine-readable report (JSON,
for example) that can be extended over time and contains all of the
information available as the current reports are also that half-way
between human and machine readable which is hard for everyone to read.

Ross
Marta Rybczynska Dec. 22, 2021, 4:27 p.m. UTC | #2
On Wed, Dec 22, 2021 at 11:04 AM Ross Burton <ross@burtonini.com> wrote:

> On Mon, 20 Dec 2021 at 15:04, Marta Rybczynska <rybczynska@gmail.com>
> wrote:
> > An example entry:
> > LAYER: meta
> > PACKAGE NAME: libsdl2-native
> > PACKAGE VERSION: 2.0.14
> > CVES FOUND IN RECIPE: Yes
> >     PRODUCT: simple_directmedia_layer (Yes)
> >     PRODUCT: sdl (No)
>
> Is this meant to be human-readable or machine-readable?  Currently its
> hard for a human to read (uppercase field names, key/value pairs) but
> would also be hard to trivially parse.
>
> I'd actually prefer to see an actual machine-readable report (JSON,
> for example) that can be extended over time and contains all of the
> information available as the current reports are also that half-way
> between human and machine readable which is hard for everyone to read.
>

Hello Ross,
This version is human readable, keeping it as similar to the current
cve-check
result as possible. Our next step is to convert both logs (this format and
the
traditional cve-check) to machine readable format, and we're aiming at JSON.
I think it makes sense to submit this one first, as the format conversion
will
include important refactoring...

What do you think?

Kind regards,
Marta

Patch

diff --git a/meta/classes/cve-check.bbclass b/meta/classes/cve-check.bbclass
index 70d1988a70..c994701334 100644
--- a/meta/classes/cve-check.bbclass
+++ b/meta/classes/cve-check.bbclass
@@ -30,19 +30,26 @@  CVE_CHECK_DB_FILE ?= "${CVE_CHECK_DB_DIR}/nvdcve_1.1.db"
 CVE_CHECK_DB_FILE_LOCK ?= "${CVE_CHECK_DB_FILE}.lock"
 
 CVE_CHECK_LOG ?= "${T}/cve.log"
+CVE_CHECK_COVERAGE_FILE = "${T}/cves_coverage.log"
 CVE_CHECK_TMP_FILE ?= "${TMPDIR}/cve_check"
+CVE_CHECK_COVERAGE_TMP_FILE ?= "${TMPDIR}/cves_coverage"
 CVE_CHECK_SUMMARY_DIR ?= "${LOG_DIR}/cve"
 CVE_CHECK_SUMMARY_FILE_NAME ?= "cve-summary"
 CVE_CHECK_SUMMARY_FILE ?= "${CVE_CHECK_SUMMARY_DIR}/${CVE_CHECK_SUMMARY_FILE_NAME}"
+CVE_CHECK_COVERAGE_SUMMARY_FILE_NAME ?= "cve-coverage-summary"
 
 CVE_CHECK_DIR ??= "${DEPLOY_DIR}/cve"
 CVE_CHECK_RECIPE_FILE ?= "${CVE_CHECK_DIR}/${PN}"
 CVE_CHECK_MANIFEST ?= "${DEPLOY_DIR_IMAGE}/${IMAGE_NAME}${IMAGE_NAME_SUFFIX}.cve"
+CVE_CHECK_COVERAGE_MANIFEST ?= "${DEPLOY_DIR_IMAGE}/${IMAGE_NAME}${IMAGE_NAME_SUFFIX}.cves_coverage"
 CVE_CHECK_COPY_FILES ??= "1"
 CVE_CHECK_CREATE_MANIFEST ??= "1"
 
 CVE_CHECK_REPORT_PATCHED ??= "1"
 
+#Report packages without CVEs (no issues or wrong product name)
+CVE_CHECK_COVERAGE ??= "1"
+
 # Whitelist for packages (PN)
 CVE_CHECK_PN_WHITELIST ?= ""
 
@@ -64,7 +71,6 @@  CVE_CHECK_LAYER_INCLUDELIST ??= ""
 CVE_VERSION_SUFFIX ??= ""
 
 python cve_save_summary_handler () {
-    import shutil
     import datetime
 
     cve_tmp_file = d.getVar("CVE_CHECK_TMP_FILE")
@@ -74,17 +80,13 @@  python cve_save_summary_handler () {
     bb.utils.mkdirhier(cvelogpath)
 
     timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
-    cve_summary_file = os.path.join(cvelogpath, "%s-%s.txt" % (cve_summary_name, timestamp))
-
-    if os.path.exists(cve_tmp_file):
-        shutil.copyfile(cve_tmp_file, cve_summary_file)
 
-        if cve_summary_file and os.path.exists(cve_summary_file):
-            cvefile_link = os.path.join(cvelogpath, cve_summary_name)
+    save_status_file(d, cve_tmp_file, cvelogpath, cve_summary_name, timestamp)
 
-            if os.path.exists(os.path.realpath(cvefile_link)):
-                os.remove(cvefile_link)
-            os.symlink(os.path.basename(cve_summary_file), cvefile_link)
+    if (d.getVar("CVE_CHECK_COVERAGE") == "1"):
+        cve_tmp_file = d.getVar("CVE_CHECK_COVERAGE_TMP_FILE")
+        cve_summary_name = d.getVar("CVE_CHECK_COVERAGE_SUMMARY_FILE_NAME")
+        save_status_file(d, cve_tmp_file, cvelogpath, cve_summary_name, timestamp)
 }
 
 addhandler cve_save_summary_handler
@@ -101,10 +103,12 @@  python do_cve_check () {
             patched_cves = get_patched_cves(d)
         except FileNotFoundError:
             bb.fatal("Failure in searching patches")
-        whitelisted, patched, unpatched = check_cves(d, patched_cves)
+        whitelisted, patched, unpatched, status = check_cves(d, patched_cves)
         if patched or unpatched:
             cve_data = get_cve_info(d, patched + unpatched)
-            cve_write_data(d, patched, unpatched, whitelisted, cve_data)
+            cve_write_data(d, patched, unpatched, whitelisted, cve_data, status)
+        else:
+            cve_write_data(d, [], [], [], {}, status)
     else:
         bb.note("No CVE database found, skipping CVE check")
 
@@ -119,6 +123,7 @@  python cve_check_cleanup () {
     Delete the file used to gather all the CVE information.
     """
     bb.utils.remove(e.data.getVar("CVE_CHECK_TMP_FILE"))
+    bb.utils.remove(e.data.getVar("CVE_CHECK_COVERAGE_TMP_FILE"))
 }
 
 addhandler cve_check_cleanup
@@ -152,6 +157,23 @@  python cve_check_write_rootfs_manifest () {
                 os.remove(manifest_link)
             os.symlink(os.path.basename(manifest_name), manifest_link)
             bb.plain("Image CVE report stored in: %s" % manifest_name)
+
+    if os.path.exists(d.getVar("CVE_CHECK_COVERAGE_TMP_FILE")):
+        bb.note("Writing rootfs CVE status")
+        deploy_dir = d.getVar("DEPLOY_DIR_IMAGE")
+        link_name = d.getVar("IMAGE_LINK_NAME")
+        manifest_name = d.getVar("CVE_CHECK_COVERAGE_MANIFEST")
+        cve_tmp_file = d.getVar("CVE_CHECK_COVERAGE_TMP_FILE")
+
+        shutil.copyfile(cve_tmp_file, manifest_name)
+
+        if manifest_name and os.path.exists(manifest_name):
+            manifest_link = os.path.join(deploy_dir, "%s.cves_coverage" % link_name)
+            # If we already have another manifest, update symlinks
+            if os.path.exists(os.path.realpath(manifest_link)):
+                os.remove(manifest_link)
+            os.symlink(os.path.basename(manifest_name), manifest_link)
+            bb.plain("Image CVE status stored in: %s" % manifest_name)
 }
 
 ROOTFS_POSTPROCESS_COMMAND:prepend = "${@'cve_check_write_rootfs_manifest; ' if d.getVar('CVE_CHECK_CREATE_MANIFEST') == '1' else ''}"
@@ -168,17 +190,20 @@  def check_cves(d, patched_cves):
     suffix = d.getVar("CVE_VERSION_SUFFIX")
 
     cves_unpatched = []
+    cves_status = [False, []]
+    cves_found_recipe = False
+
     # CVE_PRODUCT can contain more than one product (eg. curl/libcurl)
     products = d.getVar("CVE_PRODUCT").split()
     # If this has been unset then we're not scanning for CVEs here (for example, image recipes)
     if not products:
-        return ([], [], [])
+        return ([], [], [], [])
     pv = d.getVar("CVE_VERSION").split("+git")[0]
 
     # If the recipe has been whitelisted we return empty lists
     if pn in d.getVar("CVE_CHECK_PN_WHITELIST").split():
         bb.note("Recipe has been whitelisted, skipping check")
-        return ([], [], [])
+        return ([], [], [], [])
 
     cve_whitelist = d.getVar("CVE_CHECK_WHITELIST").split()
 
@@ -188,6 +213,7 @@  def check_cves(d, patched_cves):
 
     # For each of the known product names (e.g. curl has CPEs using curl and libcurl)...
     for product in products:
+        cves_found_product = False
         if ":" in product:
             vendor, product = product.split(":", 1)
         else:
@@ -206,6 +232,13 @@  def check_cves(d, patched_cves):
                 bb.note("%s has been patched" % (cve))
                 continue
 
+            # Write status once only for each product
+            if not cves_found_product:
+                cves_status[0] = True
+                cves_status[1].append([product, True])
+                cves_found_product = True
+                cves_found_recipe = True
+
             vulnerable = False
             for row in conn.execute("SELECT * FROM PRODUCTS WHERE ID IS ? AND PRODUCT IS ? AND VENDOR LIKE ?", (cve, product, vendor)):
                 (_, _, _, version_start, operator_start, version_end, operator_end) = row
@@ -251,9 +284,32 @@  def check_cves(d, patched_cves):
                 # TODO: not patched but not vulnerable
                 patched_cves.add(cve)
 
+        if not cves_found_product:
+            bb.note("No CVE records found for product %s, pn %s" % (product, pn))
+            cves_status[1].append([product, False])
+
     conn.close()
 
-    return (list(cve_whitelist), list(patched_cves), cves_unpatched)
+    if not cves_found_recipe:
+        bb.note("No CVE records for products in recipe %s" % (pn))
+
+    return (list(cve_whitelist), list(patched_cves), cves_unpatched, cves_status)
+
+
+def save_status_file(d, tmp_file, logpath, summary_name, timestamp):
+    import shutil
+
+    summary_file = os.path.join(logpath, "%s-%s.txt" % (summary_name, timestamp))
+
+    if os.path.exists(tmp_file):
+        shutil.copyfile(tmp_file, summary_file)
+
+        if summary_file and os.path.exists(summary_file):
+            file_link = os.path.join(logpath, summary_name)
+
+            if os.path.exists(os.path.realpath(file_link)):
+                os.remove(file_link)
+            os.symlink(os.path.basename(summary_file), file_link)
 
 def get_cve_info(d, cves):
     """
@@ -277,7 +333,7 @@  def get_cve_info(d, cves):
     conn.close()
     return cve_data
 
-def cve_write_data(d, patched, unpatched, whitelisted, cve_data):
+def cve_write_data(d, patched, unpatched, whitelisted, cve_data, status):
     """
     Write CVE information in WORKDIR; and to CVE_CHECK_DIR, and
     CVE manifest if enabled.
@@ -343,3 +399,30 @@  def cve_write_data(d, patched, unpatched, whitelisted, cve_data):
 
             with open(d.getVar("CVE_CHECK_TMP_FILE"), "a") as f:
                 f.write("%s" % write_string)
+
+    if (d.getVar("CVE_CHECK_COVERAGE") == "1") and status:
+        cve_status_file = d.getVar("CVE_CHECK_COVERAGE_FILE")
+
+        write_string = ""
+        bb.utils.mkdirhier(os.path.dirname(cve_status_file))
+
+        write_string += "LAYER: %s\n" % layer
+        write_string += "PACKAGE NAME: %s\n" % d.getVar("PN")
+        write_string += "PACKAGE VERSION: %s%s\n" % (d.getVar("EXTENDPE"), d.getVar("PV"))
+        write_string += "CVES FOUND IN RECIPE: %s\n" % ("No" if status[0] == False else "Yes")
+
+        for st in status[1]:
+            write_string += "    PRODUCT: %s (%s)\n" % (st[0], "No" if st[1] == False else "Yes")
+
+        write_string += "\n"
+
+        with open(cve_status_file, "w") as f:
+            bb.note("Writing file %s with status" % cve_status_file)
+            f.write(write_string)
+
+        if d.getVar("CVE_CHECK_CREATE_MANIFEST") == "1":
+            cvelogpath = d.getVar("CVE_CHECK_SUMMARY_DIR")
+            bb.utils.mkdirhier(cvelogpath)
+
+            with open(d.getVar("CVE_CHECK_COVERAGE_TMP_FILE"), "a") as f:
+                f.write("%s" % write_string)