diff options
| -rw-r--r-- | meta/classes/cve-check.bbclass | 267 |
1 files changed, 267 insertions, 0 deletions
diff --git a/meta/classes/cve-check.bbclass b/meta/classes/cve-check.bbclass new file mode 100644 index 0000000000..8251ca7c97 --- /dev/null +++ b/meta/classes/cve-check.bbclass | |||
| @@ -0,0 +1,267 @@ | |||
| 1 | # This class is used to check recipes against public CVEs. | ||
| 2 | # | ||
| 3 | # In order to use this class just inherit the class in the | ||
| 4 | # local.conf file and it will add the cve_check task for | ||
| 5 | # every recipe. The task can be used per recipe, per image, | ||
| 6 | # or using the special cases "world" and "universe". The | ||
| 7 | # cve_check task will print a warning for every unpatched | ||
| 8 | # CVE found and generate a file in the recipe WORKDIR/cve | ||
| 9 | # directory. If an image is build it will generate a report | ||
| 10 | # in DEPLOY_DIR_IMAGE for all the packages used. | ||
| 11 | # | ||
| 12 | # Example: | ||
| 13 | # bitbake -c cve_check openssl | ||
| 14 | # bitbake core-image-sato | ||
| 15 | # bitbake -k -c cve_check universe | ||
| 16 | # | ||
| 17 | # DISCLAIMER | ||
| 18 | # | ||
| 19 | # This class/tool is meant to be used as support and not | ||
| 20 | # the only method to check against CVEs. Running this tool | ||
| 21 | # doesn't guarantee your packages are free of CVEs. | ||
| 22 | |||
| 23 | CVE_CHECK_DB_DIR ?= "${DL_DIR}/CVE_CHECK" | ||
| 24 | CVE_CHECK_DB_FILE ?= "${CVE_CHECK_DB_DIR}/nvd.db" | ||
| 25 | |||
| 26 | CVE_CHECK_LOCAL_DIR ?= "${WORKDIR}/cve" | ||
| 27 | CVE_CHECK_LOCAL_FILE ?= "${CVE_CHECK_LOCAL_DIR}/cve.log" | ||
| 28 | CVE_CHECK_TMP_FILE ?= "${TMPDIR}/cve_check" | ||
| 29 | |||
| 30 | CVE_CHECK_DIR ??= "${DEPLOY_DIR}/cve" | ||
| 31 | CVE_CHECK_MANIFEST ?= "${DEPLOY_DIR_IMAGE}/${IMAGE_NAME}${IMAGE_NAME_SUFFIX}.cve" | ||
| 32 | CVE_CHECK_COPY_FILES ??= "1" | ||
| 33 | CVE_CHECK_CREATE_MANIFEST ??= "1" | ||
| 34 | |||
| 35 | # Whitelist for packages (PN) | ||
| 36 | CVE_CHECK_PN_WHITELIST = "\ | ||
| 37 | glibc-locale \ | ||
| 38 | " | ||
| 39 | |||
| 40 | # Whitelist for CVE and version of package | ||
| 41 | CVE_CHECK_CVE_WHITELIST = "{\ | ||
| 42 | 'CVE-2014-2524': ('6.3',), \ | ||
| 43 | }" | ||
| 44 | |||
| 45 | python do_cve_check () { | ||
| 46 | """ | ||
| 47 | Check recipe for patched and unpatched CVEs | ||
| 48 | """ | ||
| 49 | |||
| 50 | if os.path.exists(d.getVar("CVE_CHECK_TMP_FILE", True)): | ||
| 51 | patched_cves = get_patches_cves(d) | ||
| 52 | patched, unpatched = check_cves(d, patched_cves) | ||
| 53 | if patched or unpatched: | ||
| 54 | cve_data = get_cve_info(d, patched + unpatched) | ||
| 55 | cve_write_data(d, patched, unpatched, cve_data) | ||
| 56 | else: | ||
| 57 | bb.note("Failed to update CVE database, skipping CVE check") | ||
| 58 | } | ||
| 59 | |||
| 60 | addtask cve_check after do_unpack before do_build | ||
| 61 | do_cve_check[depends] = "cve-check-tool-native:do_populate_cve_db" | ||
| 62 | do_cve_check[nostamp] = "1" | ||
| 63 | |||
| 64 | python cve_check_cleanup () { | ||
| 65 | """ | ||
| 66 | Delete the file used to gather all the CVE information. | ||
| 67 | """ | ||
| 68 | |||
| 69 | bb.utils.remove(e.data.getVar("CVE_CHECK_TMP_FILE", True)) | ||
| 70 | } | ||
| 71 | |||
| 72 | addhandler cve_check_cleanup | ||
| 73 | cve_check_cleanup[eventmask] = "bb.cooker.CookerExit" | ||
| 74 | |||
| 75 | python cve_check_write_rootfs_manifest () { | ||
| 76 | """ | ||
| 77 | Create CVE manifest when building an image | ||
| 78 | """ | ||
| 79 | |||
| 80 | import shutil | ||
| 81 | |||
| 82 | if os.path.exists(d.getVar("CVE_CHECK_TMP_FILE", True)): | ||
| 83 | bb.note("Writing rootfs CVE manifest") | ||
| 84 | deploy_dir = d.getVar("DEPLOY_DIR_IMAGE", True) | ||
| 85 | link_name = d.getVar("IMAGE_LINK_NAME", True) | ||
| 86 | manifest_name = d.getVar("CVE_CHECK_MANIFEST", True) | ||
| 87 | cve_tmp_file = d.getVar("CVE_CHECK_TMP_FILE", True) | ||
| 88 | |||
| 89 | shutil.copyfile(cve_tmp_file, manifest_name) | ||
| 90 | |||
| 91 | if manifest_name and os.path.exists(manifest_name): | ||
| 92 | manifest_link = os.path.join(deploy_dir, "%s.cve" % link_name) | ||
| 93 | # If we already have another manifest, update symlinks | ||
| 94 | if os.path.exists(os.path.realpath(manifest_link)): | ||
| 95 | if d.getVar('RM_OLD_IMAGE', True) == "1": | ||
| 96 | os.remove(os.path.realpath(manifest_link)) | ||
| 97 | os.remove(manifest_link) | ||
| 98 | os.symlink(os.path.basename(manifest_name), manifest_link) | ||
| 99 | bb.plain("Image CVE report stored in: %s" % manifest_name) | ||
| 100 | } | ||
| 101 | |||
| 102 | ROOTFS_POSTPROCESS_COMMAND_prepend = "${@'cve_check_write_rootfs_manifest; ' if d.getVar('CVE_CHECK_CREATE_MANIFEST', True) == '1' else ''}" | ||
| 103 | |||
| 104 | def get_patches_cves(d): | ||
| 105 | """ | ||
| 106 | Get patches that solve CVEs using the "CVE: " tag. | ||
| 107 | """ | ||
| 108 | |||
| 109 | import re | ||
| 110 | |||
| 111 | pn = d.getVar("PN", True) | ||
| 112 | cve_match = re.compile("CVE:( CVE\-\d{4}\-\d+)+") | ||
| 113 | patched_cves = set() | ||
| 114 | bb.debug(2, "Looking for patches that solves CVEs for %s" % pn) | ||
| 115 | for url in src_patches(d): | ||
| 116 | patch_file = bb.fetch.decodeurl(url)[2] | ||
| 117 | with open(patch_file, "r", encoding="utf-8") as f: | ||
| 118 | try: | ||
| 119 | patch_text = f.read() | ||
| 120 | except UnicodeDecodeError: | ||
| 121 | bb.debug(1, "Failed to read patch %s using UTF-8 encoding" | ||
| 122 | " trying with iso8859-1" % patch_file) | ||
| 123 | f.close() | ||
| 124 | with open(patch_file, "r", encoding="iso8859-1") as f: | ||
| 125 | patch_text = f.read() | ||
| 126 | |||
| 127 | # Search for the "CVE: " line | ||
| 128 | match = cve_match.search(patch_text) | ||
| 129 | if match: | ||
| 130 | # Get only the CVEs without the "CVE: " tag | ||
| 131 | cves = patch_text[match.start()+5:match.end()] | ||
| 132 | for cve in cves.split(): | ||
| 133 | bb.debug(2, "Patch %s solves %s" % (patch_file, cve)) | ||
| 134 | patched_cves.add(cve) | ||
| 135 | else: | ||
| 136 | bb.debug(2, "Patch %s doesn't solve CVEs" % patch_file) | ||
| 137 | |||
| 138 | return patched_cves | ||
| 139 | |||
| 140 | def check_cves(d, patched_cves): | ||
| 141 | """ | ||
| 142 | Run cve-check-tool looking for patched and unpatched CVEs. | ||
| 143 | """ | ||
| 144 | |||
| 145 | import ast, csv, tempfile, subprocess, io | ||
| 146 | |||
| 147 | cves_patched = [] | ||
| 148 | cves_unpatched = [] | ||
| 149 | bpn = d.getVar("BPN", True) | ||
| 150 | pv = d.getVar("PV", True).split("git+")[0] | ||
| 151 | cves = " ".join(patched_cves) | ||
| 152 | cve_db_dir = d.getVar("CVE_CHECK_DB_DIR", True) | ||
| 153 | cve_whitelist = ast.literal_eval(d.getVar("CVE_CHECK_CVE_WHITELIST", True)) | ||
| 154 | cve_cmd = "cve-check-tool" | ||
| 155 | cmd = [cve_cmd, "--no-html", "--csv", "--not-affected", "-t", "faux", "-d", cve_db_dir] | ||
| 156 | |||
| 157 | # If the recipe has been whitlisted we return empty lists | ||
| 158 | if d.getVar("PN", True) in d.getVar("CVE_CHECK_PN_WHITELIST", True).split(): | ||
| 159 | bb.note("Recipe has been whitelisted, skipping check") | ||
| 160 | return ([], []) | ||
| 161 | |||
| 162 | # It is needed to export the proxies to download the database using HTTP | ||
| 163 | bb.utils.export_proxies(d) | ||
| 164 | |||
| 165 | try: | ||
| 166 | # Write the faux CSV file to be used with cve-check-tool | ||
| 167 | fd, faux = tempfile.mkstemp(prefix="cve-faux-") | ||
| 168 | with os.fdopen(fd, "w") as f: | ||
| 169 | f.write("%s,%s,%s," % (bpn, pv, cves)) | ||
| 170 | cmd.append(faux) | ||
| 171 | |||
| 172 | output = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8") | ||
| 173 | bb.debug(2, "Output of command %s:\n%s" % ("\n".join(cmd), output)) | ||
| 174 | except subprocess.CalledProcessError as e: | ||
| 175 | bb.warn("Couldn't check for CVEs: %s (output %s)" % (e, e.output)) | ||
| 176 | finally: | ||
| 177 | os.remove(faux) | ||
| 178 | |||
| 179 | for row in csv.reader(io.StringIO(output)): | ||
| 180 | # Third row has the unpatched CVEs | ||
| 181 | if row[2]: | ||
| 182 | for cve in row[2].split(): | ||
| 183 | # Skip if the CVE has been whitlisted for the current version | ||
| 184 | if pv in cve_whitelist.get(cve,[]): | ||
| 185 | bb.note("%s-%s has been whitelisted for %s" % (bpn, pv, cve)) | ||
| 186 | else: | ||
| 187 | cves_unpatched.append(cve) | ||
| 188 | bb.debug(2, "%s-%s is not patched for %s" % (bpn, pv, cve)) | ||
| 189 | # Fourth row has patched CVEs | ||
| 190 | if row[3]: | ||
| 191 | for cve in row[3].split(): | ||
| 192 | cves_patched.append(cve) | ||
| 193 | bb.debug(2, "%s-%s is patched for %s" % (bpn, pv, cve)) | ||
| 194 | |||
| 195 | return (cves_patched, cves_unpatched) | ||
| 196 | |||
| 197 | def get_cve_info(d, cves): | ||
| 198 | """ | ||
| 199 | Get CVE information from the database used by cve-check-tool. | ||
| 200 | |||
| 201 | Unfortunately the only way to get CVE info is set the output to | ||
| 202 | html (hard to parse) or query directly the database. | ||
| 203 | """ | ||
| 204 | |||
| 205 | try: | ||
| 206 | import sqlite3 | ||
| 207 | except ImportError: | ||
| 208 | from pysqlite2 import dbapi2 as sqlite3 | ||
| 209 | |||
| 210 | cve_data = {} | ||
| 211 | db_file = d.getVar("CVE_CHECK_DB_FILE", True) | ||
| 212 | placeholder = ",".join("?" * len(cves)) | ||
| 213 | query = "SELECT * FROM NVD WHERE id IN (%s)" % placeholder | ||
| 214 | conn = sqlite3.connect(db_file) | ||
| 215 | cur = conn.cursor() | ||
| 216 | for row in cur.execute(query, tuple(cves)): | ||
| 217 | cve_data[row[0]] = {} | ||
| 218 | cve_data[row[0]]["summary"] = row[1] | ||
| 219 | cve_data[row[0]]["score"] = row[2] | ||
| 220 | cve_data[row[0]]["modified"] = row[3] | ||
| 221 | cve_data[row[0]]["vector"] = row[4] | ||
| 222 | conn.close() | ||
| 223 | |||
| 224 | return cve_data | ||
| 225 | |||
| 226 | def cve_write_data(d, patched, unpatched, cve_data): | ||
| 227 | """ | ||
| 228 | Write CVE information in WORKDIR; and to CVE_CHECK_DIR, and | ||
| 229 | CVE manifest if enabled. | ||
| 230 | """ | ||
| 231 | |||
| 232 | cve_file = d.getVar("CVE_CHECK_LOCAL_FILE", True) | ||
| 233 | nvd_link = "https://web.nvd.nist.gov/view/vuln/detail?vulnId=" | ||
| 234 | write_string = "" | ||
| 235 | first_alert = True | ||
| 236 | bb.utils.mkdirhier(d.getVar("CVE_CHECK_LOCAL_DIR", True)) | ||
| 237 | |||
| 238 | for cve in sorted(cve_data): | ||
| 239 | write_string += "PACKAGE NAME: %s\n" % d.getVar("PN", True) | ||
| 240 | write_string += "PACKAGE VERSION: %s\n" % d.getVar("PV", True) | ||
| 241 | write_string += "CVE: %s\n" % cve | ||
| 242 | if cve in patched: | ||
| 243 | write_string += "CVE STATUS: Patched\n" | ||
| 244 | else: | ||
| 245 | write_string += "CVE STATUS: Unpatched\n" | ||
| 246 | if first_alert: | ||
| 247 | bb.warn("Found unpatched CVE, for more information check %s" % cve_file) | ||
| 248 | first_alert = False | ||
| 249 | write_string += "CVE SUMMARY: %s\n" % cve_data[cve]["summary"] | ||
| 250 | write_string += "CVSS v2 BASE SCORE: %s\n" % cve_data[cve]["score"] | ||
| 251 | write_string += "VECTOR: %s\n" % cve_data[cve]["vector"] | ||
| 252 | write_string += "MORE INFORMATION: %s%s\n\n" % (nvd_link, cve) | ||
| 253 | |||
| 254 | with open(cve_file, "w") as f: | ||
| 255 | bb.note("Writing file %s with CVE information" % cve_file) | ||
| 256 | f.write(write_string) | ||
| 257 | |||
| 258 | if d.getVar("CVE_CHECK_COPY_FILES", True) == "1": | ||
| 259 | cve_dir = d.getVar("CVE_CHECK_DIR", True) | ||
| 260 | bb.utils.mkdirhier(cve_dir) | ||
| 261 | deploy_file = os.path.join(cve_dir, d.getVar("PN", True)) | ||
| 262 | with open(deploy_file, "w") as f: | ||
| 263 | f.write(write_string) | ||
| 264 | |||
| 265 | if d.getVar("CVE_CHECK_CREATE_MANIFEST", True) == "1": | ||
| 266 | with open(d.getVar("CVE_CHECK_TMP_FILE", True), "a") as f: | ||
| 267 | f.write("%s" % write_string) | ||
