summaryrefslogtreecommitdiffstats
path: root/meta/recipes-core/meta/cve-update-nvd2-native.bb
blob: 1901641965a5f37f0264c1defd60e1cffb575711 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
SUMMARY = "Updates the NVD CVE database"
LICENSE = "MIT"

# Important note:
# This product uses the NVD API but is not endorsed or certified by the NVD.

INHIBIT_DEFAULT_DEPS = "1"

inherit native

deltask do_unpack
deltask do_patch
deltask do_configure
deltask do_compile
deltask do_install
deltask do_populate_sysroot

NVDCVE_URL ?= "https://services.nvd.nist.gov/rest/json/cves/2.0"

# If you have a NVD API key (https://nvd.nist.gov/developers/request-an-api-key)
# then setting this to get higher rate limits.
NVDCVE_API_KEY ?= ""

# CVE database update interval, in seconds. By default: once a day (24*60*60).
# Use 0 to force the update
# Use a negative value to skip the update
CVE_DB_UPDATE_INTERVAL ?= "86400"

# CVE database incremental update age threshold, in seconds. If the database is
# older than this threshold, do a full re-download, else, do an incremental
# update. By default: the maximum allowed value from NVD: 120 days (120*24*60*60)
# Use 0 to force a full download.
CVE_DB_INCR_UPDATE_AGE_THRES ?= "10368000"

# Number of attempts for each http query to nvd server before giving up
CVE_DB_UPDATE_ATTEMPTS ?= "5"

CVE_DB_TEMP_FILE ?= "${CVE_CHECK_DB_DIR}/temp_nvdcve_2.db"

python () {
    if not bb.data.inherits_class("cve-check", d):
        raise bb.parse.SkipRecipe("Skip recipe when cve-check class is not loaded.")
}

python do_fetch() {
    """
    Update NVD database with API 2.0
    """
    import bb.utils
    import bb.progress
    import shutil

    bb.utils.export_proxies(d)

    db_file = d.getVar("CVE_CHECK_DB_FILE")
    db_dir = os.path.dirname(db_file)
    db_tmp_file = d.getVar("CVE_DB_TEMP_FILE")

    cleanup_db_download(db_file, db_tmp_file)
    # By default let's update the whole database (since time 0)
    database_time = 0

    # The NVD database changes once a day, so no need to update more frequently
    # Allow the user to force-update
    try:
        import time
        update_interval = int(d.getVar("CVE_DB_UPDATE_INTERVAL"))
        if update_interval < 0:
            bb.note("CVE database update skipped")
            return
        if time.time() - os.path.getmtime(db_file) < update_interval:
            bb.note("CVE database recently updated, skipping")
            return
        database_time = os.path.getmtime(db_file)

    except OSError:
        pass

    bb.utils.mkdirhier(db_dir)
    if os.path.exists(db_file):
        shutil.copy2(db_file, db_tmp_file)

    if update_db_file(db_tmp_file, d, database_time) == True:
        # Update downloaded correctly, can swap files
        shutil.move(db_tmp_file, db_file)
    else:
        # Update failed, do not modify the database
        bb.warn("CVE database update failed")
        os.remove(db_tmp_file)
}

do_fetch[lockfiles] += "${CVE_CHECK_DB_FILE_LOCK}"
do_fetch[file-checksums] = ""
do_fetch[vardeps] = ""

def cleanup_db_download(db_file, db_tmp_file):
    """
    Cleanup the download space from possible failed downloads
    """

    # Clean up the updates done on the main file
    # Remove it only if a journal file exists - it means a complete re-download
    if os.path.exists("{0}-journal".format(db_file)):
        # If a journal is present the last update might have been interrupted. In that case,
        # just wipe any leftovers and force the DB to be recreated.
        os.remove("{0}-journal".format(db_file))

        if os.path.exists(db_file):
            os.remove(db_file)

    # Clean-up the temporary file downloads, we can remove both journal
    # and the temporary database
    if os.path.exists("{0}-journal".format(db_tmp_file)):
        # If a journal is present the last update might have been interrupted. In that case,
        # just wipe any leftovers and force the DB to be recreated.
        os.remove("{0}-journal".format(db_tmp_file))

    if os.path.exists(db_tmp_file):
        os.remove(db_tmp_file)

def nvd_request_wait(attempt, min_wait):
    return min ( ( (2 * attempt) + min_wait ) , 30)

def nvd_request_next(url, attempts, api_key, args, min_wait):
    """
    Request next part of the NVD database
    NVD API documentation: https://nvd.nist.gov/developers/vulnerabilities
    """

    import urllib.request
    import urllib.parse
    import gzip
    import http
    import time

    request = urllib.request.Request(url + "?" + urllib.parse.urlencode(args))
    if api_key:
        request.add_header("apiKey", api_key)
    bb.note("Requesting %s" % request.full_url)

    for attempt in range(attempts):
        try:
            r = urllib.request.urlopen(request)

            if (r.headers['content-encoding'] == 'gzip'):
                buf = r.read()
                raw_data = gzip.decompress(buf)
            else:
                raw_data = r.read().decode("utf-8")

            r.close()

        except Exception as e:
            wait_time = nvd_request_wait(attempt, min_wait)
            bb.note("CVE database: received error (%s)" % (e))
            bb.note("CVE database: retrying download after %d seconds. attempted (%d/%d)" % (wait_time, attempt+1, attempts))
            time.sleep(wait_time)
            pass
        else:
            return raw_data
    else:
        # We failed at all attempts
        return None

def update_db_file(db_tmp_file, d, database_time):
    """
    Update the given database file
    """
    import bb.utils, bb.progress
    import datetime
    import sqlite3
    import json

    # Connect to database
    conn = sqlite3.connect(db_tmp_file)
    initialize_db(conn)

    req_args = {'startIndex' : 0}

    incr_update_threshold = int(d.getVar("CVE_DB_INCR_UPDATE_AGE_THRES"))
    if database_time != 0:
        database_date = datetime.datetime.fromtimestamp(database_time, tz=datetime.timezone.utc)
        today_date = datetime.datetime.now(tz=datetime.timezone.utc)
        delta = today_date - database_date
        if incr_update_threshold == 0:
            bb.note("CVE database: forced full update")
        elif delta < datetime.timedelta(seconds=incr_update_threshold):
            bb.note("CVE database: performing partial update")
            # The maximum range for time is 120 days
            if delta > datetime.timedelta(days=120):
                bb.error("CVE database: Trying to do an incremental update on a larger than supported range")
            req_args['lastModStartDate'] = database_date.isoformat()
            req_args['lastModEndDate'] = today_date.isoformat()
        else:
            bb.note("CVE database: file too old, forcing a full update")
    else:
        bb.note("CVE database: no preexisting database, do a full download")

    with bb.progress.ProgressHandler(d) as ph, open(os.path.join(d.getVar("TMPDIR"), 'cve_check'), 'a') as cve_f:

        bb.note("Updating entries")
        index = 0
        url = d.getVar("NVDCVE_URL")
        api_key = d.getVar("NVDCVE_API_KEY") or None
        attempts = int(d.getVar("CVE_DB_UPDATE_ATTEMPTS"))

        # Recommended by NVD
        wait_time = 6
        if api_key:
            wait_time = 2

        while True:
            req_args['startIndex'] = index
            raw_data = nvd_request_next(url, attempts, api_key, req_args, wait_time)
            if raw_data is None:
                # We haven't managed to download data
                return False

            data = json.loads(raw_data)

            index = data["startIndex"]
            total = data["totalResults"]
            per_page = data["resultsPerPage"]
            bb.note("Got %d entries" % per_page)
            for cve in data["vulnerabilities"]:
               update_db(conn, cve)

            index += per_page
            ph.update((float(index) / (total+1)) * 100)
            if index >= total:
               break

            # Recommended by NVD
            time.sleep(wait_time)

        # Update success, set the date to cve_check file.
        cve_f.write('CVE database update : %s\n\n' % datetime.date.today())

    conn.commit()
    conn.close()
    return True

def initialize_db(conn):
    with conn:
        c = conn.cursor()

        c.execute("CREATE TABLE IF NOT EXISTS META (YEAR INTEGER UNIQUE, DATE TEXT)")

        c.execute("CREATE TABLE IF NOT EXISTS NVD (ID TEXT UNIQUE, SUMMARY TEXT, \
            SCOREV2 TEXT, SCOREV3 TEXT, MODIFIED INTEGER, VECTOR TEXT, VECTORSTRING TEXT)")

        c.execute("CREATE TABLE IF NOT EXISTS PRODUCTS (ID TEXT, \
            VENDOR TEXT, PRODUCT TEXT, VERSION_START TEXT, OPERATOR_START TEXT, \
            VERSION_END TEXT, OPERATOR_END TEXT)")
        c.execute("CREATE INDEX IF NOT EXISTS PRODUCT_ID_IDX on PRODUCTS(ID);")

        c.close()

def parse_node_and_insert(conn, node, cveId):

    def cpe_generator():
        for cpe in node.get('cpeMatch', ()):
            if not cpe['vulnerable']:
                return
            cpe23 = cpe.get('criteria')
            if not cpe23:
                return
            cpe23 = cpe23.split(':')
            if len(cpe23) < 6:
                return
            vendor = cpe23[3]
            product = cpe23[4]
            version = cpe23[5]

            if cpe23[6] == '*' or cpe23[6] == '-':
                version_suffix = ""
            else:
                version_suffix = "_" + cpe23[6]

            if version != '*' and version != '-':
                # Version is defined, this is a '=' match
                yield [cveId, vendor, product, version + version_suffix, '=', '', '']
            elif version == '-':
                # no version information is available
                yield [cveId, vendor, product, version, '', '', '']
            else:
                # Parse start version, end version and operators
                op_start = ''
                op_end = ''
                v_start = ''
                v_end = ''

                if 'versionStartIncluding' in cpe:
                    op_start = '>='
                    v_start = cpe['versionStartIncluding']

                if 'versionStartExcluding' in cpe:
                    op_start = '>'
                    v_start = cpe['versionStartExcluding']

                if 'versionEndIncluding' in cpe:
                    op_end = '<='
                    v_end = cpe['versionEndIncluding']

                if 'versionEndExcluding' in cpe:
                    op_end = '<'
                    v_end = cpe['versionEndExcluding']

                if op_start or op_end or v_start or v_end:
                    yield [cveId, vendor, product, v_start, op_start, v_end, op_end]
                else:
                    # This is no version information, expressed differently.
                    # Save processing by representing as -.
                    yield [cveId, vendor, product, '-', '', '', '']

    conn.executemany("insert into PRODUCTS values (?, ?, ?, ?, ?, ?, ?)", cpe_generator()).close()

def update_db(conn, elt):
    """
    Update a single entry in the on-disk database
    """

    accessVector = None
    vectorString = None
    cveId = elt['cve']['id']
    if elt['cve']['vulnStatus'] ==  "Rejected":
        c = conn.cursor()
        c.execute("delete from PRODUCTS where ID = ?;", [cveId])
        c.execute("delete from NVD where ID = ?;", [cveId])
        c.close()
        return
    cveDesc = ""
    for desc in elt['cve']['descriptions']:
        if desc['lang'] == 'en':
            cveDesc = desc['value']
    date = elt['cve']['lastModified']
    try:
        accessVector = elt['cve']['metrics']['cvssMetricV2'][0]['cvssData']['accessVector']
        vectorString = elt['cve']['metrics']['cvssMetricV2'][0]['cvssData']['vectorString']
        cvssv2 = elt['cve']['metrics']['cvssMetricV2'][0]['cvssData']['baseScore']
    except KeyError:
        cvssv2 = 0.0
    cvssv3 = None
    try:
        accessVector = accessVector or elt['cve']['metrics']['cvssMetricV30'][0]['cvssData']['attackVector']
        vectorString = vectorString or elt['cve']['metrics']['cvssMetricV30'][0]['cvssData']['vectorString']
        cvssv3 = elt['cve']['metrics']['cvssMetricV30'][0]['cvssData']['baseScore']
    except KeyError:
        pass
    try:
        accessVector = accessVector or elt['cve']['metrics']['cvssMetricV31'][0]['cvssData']['attackVector']
        vectorString = vectorString or elt['cve']['metrics']['cvssMetricV31'][0]['cvssData']['vectorString']
        cvssv3 = cvssv3 or elt['cve']['metrics']['cvssMetricV31'][0]['cvssData']['baseScore']
    except KeyError:
        pass
    accessVector = accessVector or "UNKNOWN"
    vectorString = vectorString or "UNKNOWN"
    cvssv3 = cvssv3 or 0.0

    conn.execute("insert or replace into NVD values (?, ?, ?, ?, ?, ?, ?)",
                [cveId, cveDesc, cvssv2, cvssv3, date, accessVector, vectorString]).close()

    try:
        # Remove any pre-existing CVE configuration. Even for partial database
        # update, those will be repopulated. This ensures that old
        # configuration is not kept for an updated CVE.
        conn.execute("delete from PRODUCTS where ID = ?", [cveId]).close()
        for config in elt['cve']['configurations']:
            # This is suboptimal as it doesn't handle AND/OR and negate, but is better than nothing
            for node in config["nodes"]:
                parse_node_and_insert(conn, node, cveId)
    except KeyError:
        bb.note("CVE %s has no configurations" % cveId)

do_fetch[nostamp] = "1"

EXCLUDE_FROM_WORLD = "1"