Patrick Williams | 8e7b46e | 2023-05-01 14:19:06 -0500 | [diff] [blame^] | 1 | SUMMARY = "Updates the NVD CVE database" |
| 2 | LICENSE = "MIT" |
| 3 | |
| 4 | # Important note: |
| 5 | # This product uses the NVD API but is not endorsed or certified by the NVD. |
| 6 | |
| 7 | INHIBIT_DEFAULT_DEPS = "1" |
| 8 | |
| 9 | inherit native |
| 10 | |
| 11 | deltask do_unpack |
| 12 | deltask do_patch |
| 13 | deltask do_configure |
| 14 | deltask do_compile |
| 15 | deltask do_install |
| 16 | deltask do_populate_sysroot |
| 17 | |
| 18 | NVDCVE_URL ?= "https://services.nvd.nist.gov/rest/json/cves/2.0" |
| 19 | |
| 20 | # CVE database update interval, in seconds. By default: once a day (24*60*60). |
| 21 | # Use 0 to force the update |
| 22 | # Use a negative value to skip the update |
| 23 | CVE_DB_UPDATE_INTERVAL ?= "86400" |
| 24 | |
| 25 | # Timeout for blocking socket operations, such as the connection attempt. |
| 26 | CVE_SOCKET_TIMEOUT ?= "60" |
| 27 | |
| 28 | CVE_DB_TEMP_FILE ?= "${CVE_CHECK_DB_DIR}/temp_nvdcve_2.db" |
| 29 | |
| 30 | CVE_CHECK_DB_FILE ?= "${CVE_CHECK_DB_DIR}/nvdcve_2.db" |
| 31 | |
| 32 | python () { |
| 33 | if not bb.data.inherits_class("cve-check", d): |
| 34 | raise bb.parse.SkipRecipe("Skip recipe when cve-check class is not loaded.") |
| 35 | } |
| 36 | |
| 37 | python do_fetch() { |
| 38 | """ |
| 39 | Update NVD database with API 2.0 |
| 40 | """ |
| 41 | import bb.utils |
| 42 | import bb.progress |
| 43 | import shutil |
| 44 | |
| 45 | bb.utils.export_proxies(d) |
| 46 | |
| 47 | db_file = d.getVar("CVE_CHECK_DB_FILE") |
| 48 | db_dir = os.path.dirname(db_file) |
| 49 | db_tmp_file = d.getVar("CVE_DB_TEMP_FILE") |
| 50 | |
| 51 | cleanup_db_download(db_file, db_tmp_file) |
| 52 | # By default let's update the whole database (since time 0) |
| 53 | database_time = 0 |
| 54 | |
| 55 | # The NVD database changes once a day, so no need to update more frequently |
| 56 | # Allow the user to force-update |
| 57 | try: |
| 58 | import time |
| 59 | update_interval = int(d.getVar("CVE_DB_UPDATE_INTERVAL")) |
| 60 | if update_interval < 0: |
| 61 | bb.note("CVE database update skipped") |
| 62 | return |
| 63 | if time.time() - os.path.getmtime(db_file) < update_interval: |
| 64 | bb.note("CVE database recently updated, skipping") |
| 65 | return |
| 66 | database_time = os.path.getmtime(db_file) |
| 67 | |
| 68 | except OSError: |
| 69 | pass |
| 70 | |
| 71 | bb.utils.mkdirhier(db_dir) |
| 72 | if os.path.exists(db_file): |
| 73 | shutil.copy2(db_file, db_tmp_file) |
| 74 | |
| 75 | if update_db_file(db_tmp_file, d, database_time) == True: |
| 76 | # Update downloaded correctly, can swap files |
| 77 | shutil.move(db_tmp_file, db_file) |
| 78 | else: |
| 79 | # Update failed, do not modify the database |
| 80 | bb.warn("CVE database update failed") |
| 81 | os.remove(db_tmp_file) |
| 82 | } |
| 83 | |
| 84 | do_fetch[lockfiles] += "${CVE_CHECK_DB_FILE_LOCK}" |
| 85 | do_fetch[file-checksums] = "" |
| 86 | do_fetch[vardeps] = "" |
| 87 | |
| 88 | def cleanup_db_download(db_file, db_tmp_file): |
| 89 | """ |
| 90 | Cleanup the download space from possible failed downloads |
| 91 | """ |
| 92 | |
| 93 | # Clean up the updates done on the main file |
| 94 | # Remove it only if a journal file exists - it means a complete re-download |
| 95 | if os.path.exists("{0}-journal".format(db_file)): |
| 96 | # If a journal is present the last update might have been interrupted. In that case, |
| 97 | # just wipe any leftovers and force the DB to be recreated. |
| 98 | os.remove("{0}-journal".format(db_file)) |
| 99 | |
| 100 | if os.path.exists(db_file): |
| 101 | os.remove(db_file) |
| 102 | |
| 103 | # Clean-up the temporary file downloads, we can remove both journal |
| 104 | # and the temporary database |
| 105 | if os.path.exists("{0}-journal".format(db_tmp_file)): |
| 106 | # If a journal is present the last update might have been interrupted. In that case, |
| 107 | # just wipe any leftovers and force the DB to be recreated. |
| 108 | os.remove("{0}-journal".format(db_tmp_file)) |
| 109 | |
| 110 | if os.path.exists(db_tmp_file): |
| 111 | os.remove(db_tmp_file) |
| 112 | |
| 113 | def nvd_request_next(url, api_key, args): |
| 114 | """ |
| 115 | Request next part of the NVD dabase |
| 116 | """ |
| 117 | |
| 118 | import urllib.request |
| 119 | import urllib.parse |
| 120 | import gzip |
| 121 | import http |
| 122 | |
| 123 | headers = {} |
| 124 | if api_key: |
| 125 | headers['apiKey'] = api_key |
| 126 | |
| 127 | data = urllib.parse.urlencode(args) |
| 128 | |
| 129 | full_request = url + '?' + data |
| 130 | |
| 131 | for attempt in range(3): |
| 132 | try: |
| 133 | r = urllib.request.urlopen(full_request) |
| 134 | |
| 135 | if (r.headers['content-encoding'] == 'gzip'): |
| 136 | buf = r.read() |
| 137 | raw_data = gzip.decompress(buf) |
| 138 | else: |
| 139 | raw_data = r.read().decode("utf-8") |
| 140 | |
| 141 | r.close() |
| 142 | |
| 143 | except UnicodeDecodeError: |
| 144 | # Received garbage, retry |
| 145 | bb.debug(2, "CVE database: received malformed data, retrying (request: %s)" %(full_request)) |
| 146 | pass |
| 147 | except http.client.IncompleteRead: |
| 148 | # Read incomplete, let's try again |
| 149 | bb.debug(2, "CVE database: received incomplete data, retrying (request: %s)" %(full_request)) |
| 150 | pass |
| 151 | else: |
| 152 | return raw_data |
| 153 | else: |
| 154 | # We failed at all attempts |
| 155 | return None |
| 156 | |
| 157 | def update_db_file(db_tmp_file, d, database_time): |
| 158 | """ |
| 159 | Update the given database file |
| 160 | """ |
| 161 | import bb.utils, bb.progress |
| 162 | import datetime |
| 163 | import sqlite3 |
| 164 | import json |
| 165 | |
| 166 | # Connect to database |
| 167 | conn = sqlite3.connect(db_tmp_file) |
| 168 | initialize_db(conn) |
| 169 | |
| 170 | req_args = {'startIndex' : 0} |
| 171 | |
| 172 | # The maximum range for time is 120 days |
| 173 | # Force a complete update if our range is longer |
| 174 | if (database_time != 0): |
| 175 | database_date = datetime.datetime.combine(datetime.date.fromtimestamp(database_time), datetime.time()) |
| 176 | today_date = datetime.datetime.combine(datetime.date.today(), datetime.time()) |
| 177 | delta = today_date - database_date |
| 178 | if delta.days < 120: |
| 179 | bb.debug(2, "CVE database: performing partial update") |
| 180 | req_args['lastModStartDate'] = database_date.isoformat() |
| 181 | req_args['lastModEndDate'] = today_date.isoformat() |
| 182 | else: |
| 183 | bb.note("CVE database: file too old, forcing a full update") |
| 184 | |
| 185 | with bb.progress.ProgressHandler(d) as ph, open(os.path.join(d.getVar("TMPDIR"), 'cve_check'), 'a') as cve_f: |
| 186 | |
| 187 | bb.debug(2, "Updating entries") |
| 188 | index = 0 |
| 189 | url = d.getVar("NVDCVE_URL") |
| 190 | while True: |
| 191 | req_args['startIndex'] = index |
| 192 | raw_data = nvd_request_next(url, None, req_args) |
| 193 | if raw_data is None: |
| 194 | # We haven't managed to download data |
| 195 | return False |
| 196 | |
| 197 | data = json.loads(raw_data) |
| 198 | |
| 199 | index = data["startIndex"] |
| 200 | total = data["totalResults"] |
| 201 | per_page = data["resultsPerPage"] |
| 202 | |
| 203 | for cve in data["vulnerabilities"]: |
| 204 | update_db(conn, cve) |
| 205 | |
| 206 | index += per_page |
| 207 | ph.update((float(index) / (total+1)) * 100) |
| 208 | if index >= total: |
| 209 | break |
| 210 | |
| 211 | # Recommended by NVD |
| 212 | time.sleep(6) |
| 213 | |
| 214 | # Update success, set the date to cve_check file. |
| 215 | cve_f.write('CVE database update : %s\n\n' % datetime.date.today()) |
| 216 | |
| 217 | conn.commit() |
| 218 | conn.close() |
| 219 | return True |
| 220 | |
| 221 | def initialize_db(conn): |
| 222 | with conn: |
| 223 | c = conn.cursor() |
| 224 | |
| 225 | c.execute("CREATE TABLE IF NOT EXISTS META (YEAR INTEGER UNIQUE, DATE TEXT)") |
| 226 | |
| 227 | c.execute("CREATE TABLE IF NOT EXISTS NVD (ID TEXT UNIQUE, SUMMARY TEXT, \ |
| 228 | SCOREV2 TEXT, SCOREV3 TEXT, MODIFIED INTEGER, VECTOR TEXT)") |
| 229 | |
| 230 | c.execute("CREATE TABLE IF NOT EXISTS PRODUCTS (ID TEXT, \ |
| 231 | VENDOR TEXT, PRODUCT TEXT, VERSION_START TEXT, OPERATOR_START TEXT, \ |
| 232 | VERSION_END TEXT, OPERATOR_END TEXT)") |
| 233 | c.execute("CREATE INDEX IF NOT EXISTS PRODUCT_ID_IDX on PRODUCTS(ID);") |
| 234 | |
| 235 | c.close() |
| 236 | |
| 237 | def parse_node_and_insert(conn, node, cveId): |
| 238 | |
| 239 | def cpe_generator(): |
| 240 | for cpe in node.get('cpeMatch', ()): |
| 241 | if not cpe['vulnerable']: |
| 242 | return |
| 243 | cpe23 = cpe.get('criteria') |
| 244 | if not cpe23: |
| 245 | return |
| 246 | cpe23 = cpe23.split(':') |
| 247 | if len(cpe23) < 6: |
| 248 | return |
| 249 | vendor = cpe23[3] |
| 250 | product = cpe23[4] |
| 251 | version = cpe23[5] |
| 252 | |
| 253 | if cpe23[6] == '*' or cpe23[6] == '-': |
| 254 | version_suffix = "" |
| 255 | else: |
| 256 | version_suffix = "_" + cpe23[6] |
| 257 | |
| 258 | if version != '*' and version != '-': |
| 259 | # Version is defined, this is a '=' match |
| 260 | yield [cveId, vendor, product, version + version_suffix, '=', '', ''] |
| 261 | elif version == '-': |
| 262 | # no version information is available |
| 263 | yield [cveId, vendor, product, version, '', '', ''] |
| 264 | else: |
| 265 | # Parse start version, end version and operators |
| 266 | op_start = '' |
| 267 | op_end = '' |
| 268 | v_start = '' |
| 269 | v_end = '' |
| 270 | |
| 271 | if 'versionStartIncluding' in cpe: |
| 272 | op_start = '>=' |
| 273 | v_start = cpe['versionStartIncluding'] |
| 274 | |
| 275 | if 'versionStartExcluding' in cpe: |
| 276 | op_start = '>' |
| 277 | v_start = cpe['versionStartExcluding'] |
| 278 | |
| 279 | if 'versionEndIncluding' in cpe: |
| 280 | op_end = '<=' |
| 281 | v_end = cpe['versionEndIncluding'] |
| 282 | |
| 283 | if 'versionEndExcluding' in cpe: |
| 284 | op_end = '<' |
| 285 | v_end = cpe['versionEndExcluding'] |
| 286 | |
| 287 | if op_start or op_end or v_start or v_end: |
| 288 | yield [cveId, vendor, product, v_start, op_start, v_end, op_end] |
| 289 | else: |
| 290 | # This is no version information, expressed differently. |
| 291 | # Save processing by representing as -. |
| 292 | yield [cveId, vendor, product, '-', '', '', ''] |
| 293 | |
| 294 | conn.executemany("insert into PRODUCTS values (?, ?, ?, ?, ?, ?, ?)", cpe_generator()).close() |
| 295 | |
| 296 | def update_db(conn, elt): |
| 297 | """ |
| 298 | Update a single entry in the on-disk database |
| 299 | """ |
| 300 | |
| 301 | accessVector = None |
| 302 | cveId = elt['cve']['id'] |
| 303 | if elt['cve']['vulnStatus'] == "Rejected": |
| 304 | return |
| 305 | cveDesc = "" |
| 306 | for desc in elt['cve']['descriptions']: |
| 307 | if desc['lang'] == 'en': |
| 308 | cveDesc = desc['value'] |
| 309 | date = elt['cve']['lastModified'] |
| 310 | try: |
| 311 | accessVector = elt['cve']['metrics']['cvssMetricV2'][0]['cvssData']['accessVector'] |
| 312 | cvssv2 = elt['cve']['metrics']['cvssMetricV2'][0]['cvssData']['baseScore'] |
| 313 | except KeyError: |
| 314 | cvssv2 = 0.0 |
| 315 | try: |
| 316 | accessVector = accessVector or elt['impact']['baseMetricV3']['cvssV3']['attackVector'] |
| 317 | cvssv3 = elt['impact']['baseMetricV3']['cvssV3']['baseScore'] |
| 318 | except KeyError: |
| 319 | accessVector = accessVector or "UNKNOWN" |
| 320 | cvssv3 = 0.0 |
| 321 | |
| 322 | conn.execute("insert or replace into NVD values (?, ?, ?, ?, ?, ?)", |
| 323 | [cveId, cveDesc, cvssv2, cvssv3, date, accessVector]).close() |
| 324 | |
| 325 | try: |
| 326 | configurations = elt['cve']['configurations'][0]['nodes'] |
| 327 | for config in configurations: |
| 328 | parse_node_and_insert(conn, config, cveId) |
| 329 | except KeyError: |
| 330 | bb.debug(2, "Entry without a configuration") |
| 331 | |
| 332 | do_fetch[nostamp] = "1" |
| 333 | |
| 334 | EXCLUDE_FROM_WORLD = "1" |