cvert-kernel - generate CVE report for the Linux kernel. NVD entries for the Linux kernel is almost always outdated. For example, https://nvd.nist.gov/vuln/detail/CVE-2018-1065 is shown as matched for "versions up to (including) 4.15.7", however the patch 57ebd808a97d has been back ported for 4.14. cvert-kernel script checks NVD Resource entries for the patch URLs and looking for the commits in the local git tree.
cvert-foss - generate CVE report for the list of packages. It analyzes the whole image manifest to align with the complex CPE configurations. cvert-update - only update NVD feeds and store CVE blob locally. CVE blob is a pickled representation of the cve_struct dictionary. cvert.py - python module used by all cvert-* scripts. Uses NVD JSON Vulnerability Feeds https://nvd.nist.gov/vuln/data-feeds#JSON_FEED Signed-off-by: grygorii tertychnyi <gtert...@cisco.com> --- scripts/cvert-foss | 109 ++++++++++++++ scripts/cvert-kernel | 157 +++++++++++++++++++ scripts/cvert-update | 64 ++++++++ scripts/cvert.py | 418 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 748 insertions(+) create mode 100755 scripts/cvert-foss create mode 100755 scripts/cvert-kernel create mode 100755 scripts/cvert-update create mode 100644 scripts/cvert.py diff --git a/scripts/cvert-foss b/scripts/cvert-foss new file mode 100755 index 0000000..a0cc6ad --- /dev/null +++ b/scripts/cvert-foss @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# +# Generate CVE report for the list of packages. +# +# Copyright (c) 2018 Cisco Systems, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +import sys +import cvert +import textwrap +import argparse + +def report_foss(filename, cve_struct): + packagelist = {} + + with open(filename, 'r') as fil: + for lin in fil: + product, version, patched = lin.split(',', maxsplit=3) + + if product in packagelist: + packagelist[product][version] = patched.split() + else: + packagelist[product] = { + version: patched.split() + } + + return cvert.match_cve(packagelist, cve_struct) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent(''' + Generate CVE report for the list of packages. + '''), + epilog=textwrap.dedent(''' + examples: + + # Download (update) NVD feeds in "nvdfeed" directory + # and prepare the report for the "package.lst" file + %% %(prog)s --feed-dir nvdfeed --output report-foss.txt package.lst + + # Use existed NVD feeds in "nvdfeed" directory + # and prepare the report for the "package.lst" file + %% %(prog)s --offline --feed-dir nvdfeed --output report-foss.txt package.lst + + # (faster) Restore CVE dump from "cvedump" (must exist) + # and prepare the report for the "package.lst" file + %% %(prog)s --restore cvedump --output report-foss.txt package.lst + + # Restore CVE dump from "cvedump" (must exist) + # and prepare the extended report for the "package.lst" file + %% %(prog)s --restore cvedump --show-description --show-reference --output report-foss.txt package.lst + ''')) + + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--feed-dir', help='feeds directory') + group.add_argument('--restore', help='load CVE data structures from file', + metavar='FILENAME') + + parser.add_argument('--offline', help='do not update from NVD site', + action='store_true') + parser.add_argument('--output', help='save report to the file') + parser.add_argument('--show-description', help='show "Description" in the report', + action='store_true') + parser.add_argument('--show-reference', help='show "Reference" in the report', + action='store_true') + + parser.add_argument('package_list', help='file with a list of packages, ' + 'each line contains three comma separated values: name, ' + 'version and a space separated list of patched CVEs.', + metavar='package-list') + + args = parser.parse_args() + + if args.restore: + cve_struct = cvert.load_cve(args.restore) + elif args.feed_dir: + cve_struct = cvert.update_feeds(args.feed_dir, args.offline) + + if not cve_struct and args.offline: + parser.error('No CVEs found. Try to turn off offline mode or use other file to restore.') + + if args.output: + output = open(args.output, 'w') + else: + output = sys.stdout + + report = report_foss(args.package_list, cve_struct) + cvert.print_report(report, + show_description=args.show_description, + show_reference=args.show_reference, + output=output + ) + + if args.output: + output.close() diff --git a/scripts/cvert-kernel b/scripts/cvert-kernel new file mode 100755 index 0000000..446c7b2 --- /dev/null +++ b/scripts/cvert-kernel @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# +# Generate CVE report for the Linux kernel. +# Inspect Linux kernel GIT tree and find all CVE patches commits. +# +# Copyright (c) 2018 Cisco Systems, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +import re +import cvert +import argparse +import textwrap +import subprocess + + +def report_kernel(cve_struct, kernel_dir, kernel_ver=None): + oneline = subprocess.check_output(['git', 'log', + '--format=%s'], + cwd=kernel_dir, + stderr=subprocess.DEVNULL + ).splitlines() + + if not kernel_ver: + kernel_ver = subprocess.check_output(['make', 'kernelversion'], + cwd=kernel_dir, + stderr=subprocess.DEVNULL + ).decode().rstrip() + + manifest = { + 'linux_kernel': { + kernel_ver: [] + } + } + + report = cvert.match_cve(manifest, cve_struct) + + for cve in report: + for url in cve['reference']: + headline = git_headline(kernel_dir, match_commit(url)) + + if headline and headline in oneline: + cve['status'] = 'patched' + break + + return report + + +def match_commit(url): + matched = re.match(r'^http://git\.kernel\.org/cgit/linux/kernel/git/torvalds/linux\.git/commit/\?id=(.+)$', url) \ + or re.match(r'^https?://github\.com/torvalds/linux/commit/(.+)$', url) + + if matched: + return matched.group(1) + else: + return None + + +def git_headline(kernel_dir, commit_id): + if not commit_id: + return None + + try: + headline = subprocess.check_output(['git', 'show', + '--no-patch', + '--format=%s', + commit_id], + cwd=kernel_dir, + stderr=subprocess.DEVNULL + ).rstrip() + except subprocess.CalledProcessError: + # commit_id is not found + headline = None + + return headline + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent(''' + Generate CVE report for the Linux kernel. + Inspect Linux kernel GIT tree and find all CVE patches commits. + '''), + epilog=textwrap.dedent(''' + examples: + + # Download (update) NVD feeds in "nvdfeed" directory + # and prepare the report for the "kernel-sources" directory + %% %(prog)s --feed-dir nvdfeed --output report-kernel.txt kernel-sources + + # Use existed NVD feeds in "nvdfeed" directory + # and prepare the report for the "kernel-sources" directory + %% %(prog)s --offline --feed-dir nvdfeed --output report-kernel.txt kernel-sources + + # (faster) Restore CVE dump from "cvedump" (must exist) + # and prepare the report for the "kernel-sources" directory + %% %(prog)s --restore cvedump --output report-kernel.txt kernel-sources + + # Restore CVE dump from "cvedump" (must exist) + # and prepare the extended report for the "kernel-sources" directory + %% %(prog)s --restore cvedump --show-description --show-reference --output report-kernel.txt kernel-sources + ''')) + + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--feed-dir', help='feeds directory') + group.add_argument('--restore', help='load CVE data structures from file', + metavar='FILENAME') + + parser.add_argument('--offline', help='do not update from NVD site', + action='store_true') + parser.add_argument('--output', help='save report to the file') + parser.add_argument('--kernel-ver', help='overwrite kernel version, default is "make kernelversion"', + metavar='VERSION') + parser.add_argument('--show-description', help='show "Description" in the report', + action='store_true') + parser.add_argument('--show-reference', help='show "Reference" in the report', + action='store_true') + + parser.add_argument('kernel_dir', help='kernel GIT directory', + metavar='kernel-dir') + + args = parser.parse_args() + + if args.restore: + cve_struct = cvert.load_cve(args.restore) + elif args.feed_dir: + cve_struct = cvert.update_feeds(args.feed_dir, args.offline) + + if not cve_struct and args.offline: + parser.error('No CVEs found. Try to turn off offline mode or use other file to restore.') + + if args.output: + output = open(args.output, 'w') + else: + output = sys.stdout + + report = report_kernel(cve_struct, args.kernel_dir, args.kernel_ver) + cvert.print_report(report, + show_description=args.show_description, + show_reference=args.show_reference, + output=output + ) + + if args.output: + output.close() diff --git a/scripts/cvert-update b/scripts/cvert-update new file mode 100755 index 0000000..adea13d --- /dev/null +++ b/scripts/cvert-update @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# +# Update NVD feeds and store CVE blob locally. +# +# Copyright (c) 2018 Cisco Systems, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +import cvert +import textwrap +import argparse + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent(''' + Update NVD feeds and store CVE blob locally. + '''), + epilog=textwrap.dedent(''' + examples: + + # Download NVD feeds to "nvdfeed" directory. + # If there are meta files in the directory, they will be updated + # and only fresh archives will be downloaded: + %% %(prog)s nvdfeed + + # Inspect NVD feeds in "nvdfeed" directory + # and prepare a CVE dump python blob "cvedump". + # Use it later as input for cvert-* scripts (for speeding up) + %% %(prog)s --offline --store cvedump nvdfeed + + # Download (update) NVD feeds and prepare a CVE dump + %% %(prog)s --store cvedump nvdfeed + ''')) + + parser.add_argument('--store', help='save CVE data structures in file', + metavar='FILENAME') + parser.add_argument('--offline', help='do not update from NVD site', + action='store_true') + + parser.add_argument('feed_dir', help='feeds directory', + metavar='feed-dir') + + args = parser.parse_args() + + cve_struct = cvert.update_feeds(args.feed_dir, args.offline) + + if not cve_struct and args.offline: + parser.error('No CVEs found in {0}. Try to turn off offline mode.'.format(args.feed_dir)) + + if args.store: + cvert.save_cve(args.store, cve_struct) diff --git a/scripts/cvert.py b/scripts/cvert.py new file mode 100644 index 0000000..3a7ae5a --- /dev/null +++ b/scripts/cvert.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2018 by Cisco Systems, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +import os +import re +import sys +import json +import gzip +import pickle +import logging +import hashlib +import datetime +import textwrap +import urllib.request +import distutils.version + + +stream = logging.StreamHandler() +stream.setFormatter(logging.Formatter('# %(asctime)s %% %(name)s %% %(levelname)-8s %% %(message)s')) + +logger = logging.getLogger('CVERT') +logger.setLevel(logging.DEBUG) +logger.addHandler(stream) + + +def match_cve(manifest, cve_struct): + report = [] + + for cve in cve_struct: + for conf in cve_struct[cve]['nodes']: + affected = process_configuration(manifest, conf) + + for key in affected: + product, version = key.split(',') + patched = manifest[product][version] + + if cve in patched: + cve_item = {'status': 'patched'} + else: + cve_item = {'status': 'unpatched'} + + cve_item['CVSS'] = '{0:.1f}'.format(cve_struct[cve]['score']) + cve_item['CVE'] = cve + cve_item['product'] = product + cve_item['version'] = version + cve_item['description'] = cve_struct[cve]['description'] + cve_item['reference'] = [x['url'] for x in cve_struct[cve]['reference']] + + report.append(cve_item) + + return sorted(report, key=lambda x : (x['status'], x['product'], x['CVSS'], x['CVE'])) + + +def process_configuration(manifest, conf): + '''Recursive call to process all CVE configurations.''' + + operator = conf['operator'] + + if operator not in ['OR', 'AND']: + raise ValueError('operator {} is not supported'.format(operator)) + + op = True if operator == 'AND' else False + match = False + affected = set() + + if 'cpe' in conf: + match = process_cpe(manifest, conf['cpe'][0], affected) + + for cpe in conf['cpe'][1:]: + package_match = process_cpe(manifest, cpe, affected) + + # match = match <operator> package_match + match = op ^ ((op ^ match) or (op ^ package_match)) + elif 'children' in conf: + product_set = process_configuration(manifest, conf['children'][0]) + + if product_set: + match = True + affected = affected.union(product_set) + + for child in conf['children'][1:]: + product_set = process_configuration(manifest, child) + package_match = True if product_set else False + + # match = match OP package_match + match = op ^ ((op ^ match) or (op ^ package_match)) + + if package_match: + affected = affected.union(product_set) + + if match: + return affected + else: + return () + + +def process_cpe(manifest, cpe, affected): + '''Matches CPE with all packages in manifest.''' + + if not cpe['vulnerable']: + # Ignores non vulnerable part + return False + + version_range = {} + + for flag in ['versionStartIncluding', + 'versionStartExcluding', + 'versionEndIncluding', + 'versionEndExcluding']: + if flag in cpe: + version_range[flag] = cpe[flag] + + # only "product" and "version" are taken + product, version = cpe['cpe23Uri'].split(':')[4:6] + + if product not in manifest: + return False + + if not version_range: + if version == '*': + # Ignores CVEs that touches all versions of package + # Can not fix it anyway + logger.debug('ignore "*" in {}'.format(cpe)) + return False + elif version == '-': + # "-" means NA + # + # NA (i.e. "not applicable/not used"). The logical value NA + # SHOULD be assigned when there is no legal or meaningful + # value for that attribute, or when that attribute is not + # used as part of the description. + # This includes the situation in which an attribute has + # an obtainable value that is null + # + # Ignores CVEs if version is not set + logger.debug('ignore "-" in {}'.format(cpe)) + return False + else: + version_range['versionExactMatch'] = version + + result = False + + for version in manifest[product]: + try: + if match_version(version, + version_range): + logger.debug('{} {}'.format(product, version)) + affected.add('{},{}'.format(product, version)) + + result = True + except: + # version comparison is a very tricky + # sometimes provider changes product version in a strange manner + # and the above comparison just failed + # so here we try to make version string "more standard" + + if match_version(twik_version(version), + [twik_version(v) for v in version_range]): + logger.debug('{} {} (twiked)'.format(product, twik_version(version))) + affected.add('{},{}'.format(product, version)) + + result = True + + return result + + +def match_version(version, vrange): + '''Matches version with the version range.''' + + result = False + version = util_version(version) + + if 'versionExactMatch' in vrange: + if version == util_version(vrange['versionExactMatch']): + result = True + else: + result = True + + if 'versionStartIncluding' in vrange: + result = result and version >= util_version(vrange['versionStartIncluding']) + + if 'versionStartExcluding' in vrange: + result = result and version > util_version(vrange['versionStartExcluding']) + + if 'versionEndIncluding' in vrange: + result = result and version <= util_version(vrange['versionEndIncluding']) + + if 'versionEndExcluding' in vrange: + result = result and version < util_version(vrange['versionEndExcluding']) + + return result + + +def util_version(version): + return distutils.version.LooseVersion(version.split('+git')[0]) + + +def twik_version(v): + return 'v1' + re.sub(r'^[a-zA-Z]+', '', v) + + +def print_report(report, width=70, show_description=False, show_reference=False, output=sys.stdout): + for cve in report: + print('{0:>9s} | {1:>4s} | {2:18s} | {3} | {4}'.format(cve['status'], cve['CVSS'], cve['CVE'], cve['product'], cve['version']), file=output) + + if show_description: + print('{0:>9s} + {1}'.format(' ', 'Description'), file=output) + + for lin in textwrap.wrap(cve['description'], width=width): + print('{0:>9s} {1}'.format(' ', lin), file=output) + + if show_reference: + print('{0:>9s} + {1}'.format(' ', 'Reference'), file=output) + + for url in cve['reference']: + print('{0:>9s} {1}'.format(' ', url), file=output) + + +def update_feeds(feed_dir, offline=False, start=2002): + feed_dir = os.path.realpath(feed_dir) + year_now = datetime.datetime.now().year + cve_struct = {} + + for year in range(start, year_now + 1): + update_year(cve_struct, year, feed_dir, offline) + + return cve_struct + + +def update_year(cve_struct, year, feed_dir, offline): + url_prefix = 'https://static.nvd.nist.gov/feeds/json/cve/1.0' + file_prefix = 'nvdcve-1.0-{0}'.format(year) + + meta = { + 'url': '{0}/{1}.meta'.format(url_prefix, file_prefix), + 'file': os.path.join(feed_dir, '{0}.meta'.format(file_prefix)) + } + + feed = { + 'url': '{0}/{1}.json.gz'.format(url_prefix, file_prefix), + 'file': os.path.join(feed_dir, '{0}.json.gz'.format(file_prefix)) + } + + ctx = {} + + if not offline: + ctx = download_feed(meta, feed) + + if not 'meta' in ctx or not 'feed' in ctx: + return + + if not os.path.isfile(meta['file']): + return + + if not os.path.isfile(feed['file']): + return + + if not 'meta' in ctx: + ctx['meta'] = ctx_meta(meta['file']) + + if not 'sha256' in ctx['meta']: + return + + if not 'feed' in ctx: + ctx['feed'] = ctx_gzip(feed['file'], ctx['meta']['sha256']) + + if not ctx['feed']: + return + + logger.debug('parsing year {}'.format(year)) + + for cve_item in ctx['feed']['CVE_Items']: + iden, cve = parse_item(cve_item) + + if not iden: + continue + + if not cve: + logger.error('{} parse error'.format(iden)) + break + + if iden in cve_struct: + logger.error('{} duplicated'.format(iden)) + break + + cve_struct[iden] = cve + + logger.debug('cve records: {}'.format(len(cve_struct))) + + +def ctx_meta(filename): + if not os.path.isfile(filename): + return {} + + ctx = {} + + with open(filename) as fil: + for lin in fil: + f = lin.split(':', maxsplit=1) + ctx[f[0]] = f[1].rstrip() + + return ctx + + +def ctx_gzip(filename, checksum=''): + if not os.path.isfile(filename): + return {} + + with gzip.open(filename) as fil: + try: + ctx = fil.read() + except (EOFError, OSError): + return {} + + if checksum and checksum.upper() != hashlib.sha256(ctx).hexdigest().upper(): + return {} + + return json.loads(ctx.decode()) + + +def parse_item(cve_item): + cve_id = cve_item['cve']['CVE_data_meta']['ID'][:] + impact = cve_item['impact'] + + if not impact: + # REJECTed CVE + return None, None + + if 'baseMetricV3' in impact: + score = impact['baseMetricV3']['cvssV3']['baseScore'] + elif 'baseMetricV2' in impact: + score = impact['baseMetricV2']['cvssV2']['baseScore'] + else: + return cve_id, None + + return cve_id, { + 'score': score, + 'nodes': cve_item['configurations']['nodes'][:], + 'reference': cve_item['cve']['references']['reference_data'][:], + 'description': cve_item['cve']['description']['description_data'][0]['value'] + } + + +def download_feed(meta, feed): + ctx = {} + + if not retrieve_url(meta['url'], meta['file']): + return {} + + ctx['meta'] = ctx_meta(meta['file']) + + if not 'sha256' in ctx['meta']: + return {} + + ctx['feed'] = ctx_gzip(feed['file'], ctx['meta']['sha256']) + + if not ctx['feed']: + if not retrieve_url(feed['url'], feed['file']): + return {} + + ctx['feed'] = ctx_gzip(feed['file'], ctx['meta']['sha256']) + + return ctx + + +def retrieve_url(url, filename=None): + if filename: + os.makedirs(os.path.dirname(filename), exist_ok=True) + + logger.debug('downloading {}'.format(url)) + + try: + urllib.request.urlretrieve(url, filename=filename) + except urllib.error.HTTPError: + return False + + return True + + +def save_cve(filename, cve_struct): + '''Saves CVE structure in the file.''' + + filename = os.path.realpath(filename) + + logger.debug('saving {} CVE records to {}'.format(len(cve_struct), filename)) + + with open(filename, 'wb') as fil: + pickle.dump(cve_struct, fil) + + +def load_cve(filename): + '''Loads CVE structure from the file.''' + + filename = os.path.realpath(filename) + + logger.debug('loading from {}'.format(filename)) + + with open(filename, 'rb') as fil: + cve_struct = pickle.load(fil) + + logger.debug('cve records: {}'.format(len(cve_struct))) + + return cve_struct -- 2.1.4 -- _______________________________________________ Openembedded-core mailing list Openembedded-core@lists.openembedded.org http://lists.openembedded.org/mailman/listinfo/openembedded-core