diff --git a/ci/do_ci.sh b/ci/do_ci.sh index 4334c2304b34c..d7a0c90e79348 100755 --- a/ci/do_ci.sh +++ b/ci/do_ci.sh @@ -462,7 +462,6 @@ elif [[ "$CI_TARGET" == "deps" ]]; then exit 0 elif [[ "$CI_TARGET" == "cve_scan" ]]; then echo "scanning for CVEs in dependencies..." - bazel run "${BAZEL_BUILD_OPTIONS[@]}" //tools/dependency:cve_scan_test bazel run "${BAZEL_BUILD_OPTIONS[@]}" //tools/dependency:cve_scan exit 0 elif [[ "$CI_TARGET" == "tooling" ]]; then @@ -482,11 +481,6 @@ elif [[ "$CI_TARGET" == "tooling" ]]; then echo "dependency validate_test..." "${ENVOY_SRCDIR}"/tools/dependency/validate_test.py - # Validate the CVE scanner works. We do it here as well as in cve_scan, since this blocks - # presubmits, but cve_scan only runs async. - echo "cve_scan_test..." - bazel run "${BAZEL_BUILD_OPTIONS[@]}" //tools/dependency:cve_scan_test - exit 0 elif [[ "$CI_TARGET" == "verify_examples" ]]; then run_ci_verify "*" "wasm-cc|win32-front-proxy" diff --git a/tools/base/requirements.in b/tools/base/requirements.in index 8261cdecf553a..8041aa7fb3d78 100644 --- a/tools/base/requirements.in +++ b/tools/base/requirements.in @@ -8,6 +8,7 @@ envoy.base.checker envoy.base.runner envoy.base.utils>=0.0.10 envoy.code_format.python_check>=0.0.4 +envoy.dependency.cve_scan envoy.dependency.pip_check>=0.0.4 envoy.distribution.release envoy.distribution.verify diff --git a/tools/base/requirements.txt b/tools/base/requirements.txt index c26dedabb09fd..cd876c9c5a1ce 100644 --- a/tools/base/requirements.txt +++ b/tools/base/requirements.txt @@ -11,6 +11,7 @@ abstracts==0.0.12 \ # envoy.abstract.command # envoy.base.utils # envoy.code-format.python-check + # envoy.dependency.cve-scan # envoy.dependency.pip-check # envoy.github.abstract # envoy.github.release @@ -19,6 +20,7 @@ aio.functional==0.0.9 \ # via # -r requirements.in # aio.tasks + # envoy.dependency.cve-scan # envoy.github.abstract # envoy.github.release aio.stream==0.0.2 \ @@ -34,6 +36,7 @@ aio.tasks==0.0.4 \ # via # -r requirements.in # envoy.code-format.python-check + # envoy.dependency.cve-scan # envoy.github.abstract # envoy.github.release aiodocker==0.21.0 \ @@ -87,6 +90,7 @@ aiohttp==3.7.4.post0 \ # via # aio.stream # aiodocker + # envoy.dependency.cve-scan # envoy.github.abstract # envoy.github.release # slackclient @@ -261,6 +265,7 @@ envoy.base.checker==0.0.2 \ # via # -r requirements.in # envoy.code-format.python-check + # envoy.dependency.cve-scan # envoy.dependency.pip-check # envoy.distribution.distrotest # envoy.distribution.verify @@ -279,6 +284,7 @@ envoy.base.utils==0.0.10 \ # via # -r requirements.in # envoy.code-format.python-check + # envoy.dependency.cve-scan # envoy.dependency.pip-check # envoy.distribution.distrotest # envoy.docs.sphinx-runner @@ -287,6 +293,10 @@ envoy.base.utils==0.0.10 \ envoy.code-format.python-check==0.0.4 \ --hash=sha256:5e166102d1f873f0c14640bcef87b46147cbad1cb68888c977acfde7fce96e04 # via -r requirements.in +envoy.dependency.cve-scan==0.0.1 \ + --hash=sha256:438973e6258deb271d60a9ad688c13ebf9c5360ccb9b6b0d4af3b3228235b153 \ + --hash=sha256:733fa5c6bdbe91da4afe1d46bca75279f717e410693866825d92208fa0d3418f + # via -r requirements.in envoy.dependency.pip-check==0.0.4 \ --hash=sha256:3213d77959f65c3c97e9b5d74cb14c02bc02dae64bac2e7c3cb829a2f4e5e40e # via -r requirements.in @@ -375,6 +385,7 @@ jinja2==3.0.2 \ --hash=sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c # via # -r requirements.in + # envoy.dependency.cve-scan # sphinx markupsafe==2.0.1 \ --hash=sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298 \ @@ -481,6 +492,7 @@ packaging==21.0 \ --hash=sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7 \ --hash=sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14 # via + # envoy.dependency.cve-scan # envoy.github.release # pytest # sphinx @@ -732,6 +744,7 @@ typing-extensions==3.10.0.2 \ # via # aiodocker # aiohttp + # gitpython uritemplate==3.0.1 \ --hash=sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f \ --hash=sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae diff --git a/tools/dependency/BUILD b/tools/dependency/BUILD index c2cfa4e8e17e4..394ad20c0a340 100644 --- a/tools/dependency/BUILD +++ b/tools/dependency/BUILD @@ -24,23 +24,12 @@ py_library( py_binary( name = "cve_scan", - srcs = [ - "cve_scan.py", - "utils.py", - ], + srcs = ["cve_scan.py"], args = ["$(location :cve.yaml)"], - data = [ - ":cve.yaml", - ":exports", - requirement("envoy.base.utils"), - ], -) - -py_binary( - name = "cve_scan_test", - srcs = ["cve_scan_test.py"], - data = [ - ":cve_scan", + data = [":cve.yaml"], + deps = [ + ":utils", + requirement("envoy.dependency.cve_scan"), ], ) diff --git a/tools/dependency/cve.yaml b/tools/dependency/cve.yaml index 25640f1decd94..45fc35106e3ea 100644 --- a/tools/dependency/cve.yaml +++ b/tools/dependency/cve.yaml @@ -2,11 +2,9 @@ # We only look back a few years, since we shouldn't have any ancient deps. start_year: 2018 -ndist_url: https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-{year}.json.gz - # These CVEs are false positives for the match heuristics. An explanation is # required when adding a new entry to this list as a comment. -ignore: +ignored_cves: # Node.js issue unrelated to http-parser (napi_ API implementation). - CVE-2020-8174 # Node.js HTTP desync attack. Request smuggling due to CR and hyphen @@ -22,24 +20,12 @@ ignore: - CVE-2020-8251 # Node.js issue unrelated to http-parser (libuv). - CVE-2020-8252 -# Fixed via the nghttp2 1.41.0 bump in Envoy 8b6ea4. -- CVE-2020-11080 # Node.js issue rooted in a c-ares bug. Does not appear to affect # http-parser or our use of c-ares, c-ares has been bumped regardless. - CVE-2020-8277 -# gRPC issue that only affects Javascript bindings. -- CVE-2020-7768 # Node.js issue unrelated to http-parser, see # https://github.com/mhart/StringStream/issues/7. - CVE-2018-21270 -# These should not affect Curl 7.74.0, but we see false positives due to the -# relative release date and CPE wildcard. -- CVE-2020-8169 -- CVE-2020-8177 -- CVE-2020-8284 -# Low severity Curl issue with incorrect re-use of connections due to case -# in/sensitivity -- CVE-2021-22924 # Node.js issue unrelated to http-parser (Node TLS). - CVE-2020-8265 # Node.js request smuggling. @@ -51,9 +37,6 @@ ignore: # Node.js issue unrelated to http-parser (*). - CVE-2021-22883 - CVE-2021-22884 -# False positive on the match heuristic, fixed in Curl 7.76.0. -- CVE-2021-22876 -- CVE-2021-22890 # Node.js issues unrelated to http-parser. # See https://nvd.nist.gov/vuln/detail/CVE-2021-22918 # See https://nvd.nist.gov/vuln/detail/CVE-2021-22921 @@ -66,18 +49,3 @@ ignore: - CVE-2021-22931 - CVE-2021-22939 - CVE-2021-22940 -# -# Currently, cvescan does not respect/understand versions (see #18354). -# -# The following CVEs target versions that are not currently used in the Envoy repo. -# -# libcurl -- CVE-2021-22945 -# -# kafka -- CVE-2021-38153 -# -# wasmtime -- CVE-2021-39216 -- CVE-2021-39218 -- CVE-2021-39219 diff --git a/tools/dependency/cve_scan.py b/tools/dependency/cve_scan.py index 315780ae33010..fdd0d42b4c8bd 100755 --- a/tools/dependency/cve_scan.py +++ b/tools/dependency/cve_scan.py @@ -1,275 +1,88 @@ #!/usr/bin/env python3 -# Scan for any external dependencies that were last updated before known CVEs -# (and near relatives). We also try a fuzzy match on version information. +# usage +# +# with bazel: +# +# $ bazel run //tools/dependency:cve_scan -- -h +# +# $ bazel run //tools/dependency:cve_scan +# +# +# The upstream lib is maintained here: +# +# https://github.com/envoyproxy/pytooling/tree/main/envoy.dependency.cve_scan +# +# Please submit issues/PRs to the pytooling repo: +# +# https://github.com/envoyproxy/pytooling +# -from collections import defaultdict, namedtuple -import datetime as dt -import gzip -import json -import re import sys -import textwrap -import urllib.request +from functools import cached_property +from typing import Type -from envoy.base import utils +import abstracts -import utils as dep_utils +from envoy.dependency import cve_scan -# Subset of CVE fields that are useful below. -Cve = namedtuple( - 'Cve', - ['id', 'description', 'cpes', 'score', 'severity', 'published_date', 'last_modified_date']) +import tools.dependency.utils as dep_utils -class Cpe(namedtuple('CPE', ['part', 'vendor', 'product', 'version'])): - """Model a subset of CPE fields that are used in CPE matching.""" +@abstracts.implementer(cve_scan.ACVE) +class EnvoyCVE: - @classmethod - def from_string(cls, cpe_str): - assert (cpe_str.startswith('cpe:2.3:')) - components = cpe_str.split(':') - assert (len(components) >= 6) - return cls(*components[2:6]) + @property + def cpe_class(self): + return EnvoyCPE - def __str__(self): - return f'cpe:2.3:{self.part}:{self.vendor}:{self.product}:{self.version}' + @property + def version_matcher_class(self) -> Type[cve_scan.ACVEVersionMatcher]: + return EnvoyCVEVersionMatcher - def vendor_normalized(self): - """Return a normalized CPE where only part and vendor are significant.""" - return Cpe(self.part, self.vendor, '*', '*') +@abstracts.implementer(cve_scan.ACPE) +class EnvoyCPE: + pass -def parse_cve_json(cve_json, cves, cpe_revmap): - """Parse CVE JSON dictionary. - Args: - cve_json: a NIST CVE JSON dictionary. - cves: dictionary mapping CVE ID string to Cve object (output). - cpe_revmap: a reverse map from vendor normalized CPE to CVE ID string. - """ +@abstracts.implementer(cve_scan.ADependency) +class EnvoyDependency: + pass - # This provides an over-approximation of possible CPEs affected by CVE nodes - # metadata; it traverses the entire AND-OR tree and just gathers every CPE - # observed. Generally we expect that most of Envoy's CVE-CPE matches to be - # simple, plus it's interesting to consumers of this data to understand when a - # CPE pops up, even in a conditional setting. - def gather_cpes(nodes, cpe_set): - for node in nodes: - for cpe_match in node.get('cpe_match', []): - cpe_set.add(Cpe.from_string(cpe_match['cpe23Uri'])) - gather_cpes(node.get('children', []), cpe_set) - for cve in cve_json['CVE_Items']: - cve_id = cve['cve']['CVE_data_meta']['ID'] - description = cve['cve']['description']['description_data'][0]['value'] - cpe_set = set() - gather_cpes(cve['configurations']['nodes'], cpe_set) - if len(cpe_set) == 0: - continue +@abstracts.implementer(cve_scan.ACVEChecker) +class EnvoyCVEChecker: - if not "baseMetricV3" in cve['impact']: - print(f"WARNING: ignoring v2 metric for {cve['cve']['CVE_data_meta']['ID']}") - continue + @property + def cpe_class(self): + return EnvoyCPE - cvss_v3_score = cve['impact']['baseMetricV3']['cvssV3']['baseScore'] - cvss_v3_severity = cve['impact']['baseMetricV3']['cvssV3']['baseSeverity'] + @property + def cve_class(self): + return EnvoyCVE - def parse_cve_date(date_str): - assert (date_str.endswith('Z')) - return dt.date.fromisoformat(date_str.split('T')[0]) + @property + def dependency_class(self): + return EnvoyDependency - published_date = parse_cve_date(cve['publishedDate']) - last_modified_date = parse_cve_date(cve['lastModifiedDate']) - cves[cve_id] = Cve( - cve_id, description, cpe_set, cvss_v3_score, cvss_v3_severity, published_date, - last_modified_date) - for cpe in cpe_set: - cpe_revmap[str(cpe.vendor_normalized())].add(cve_id) - return cves, cpe_revmap + @cached_property + def dependency_metadata(self): + return dep_utils.repository_locations() + @cached_property + def ignored_cves(self): + return super().ignored_cves -def download_cve_data(urls): - """Download NIST CVE JSON databases from given URLs and parse. - Args: - urls: a list of URLs. - Returns: - cves: dictionary mapping CVE ID string to Cve object (output). - cpe_revmap: a reverse map from vendor normalized CPE to CVE ID string. - """ - cves = {} - cpe_revmap = defaultdict(set) - for url in urls: - print(f'Loading NIST CVE database from {url}...') - with urllib.request.urlopen(url) as request: - with gzip.GzipFile(fileobj=request) as json_data: - parse_cve_json(json.loads(json_data.read()), cves, cpe_revmap) - return cves, cpe_revmap +@abstracts.implementer(cve_scan.ACVEVersionMatcher) +class EnvoyCVEVersionMatcher: + pass -def format_cve_details(cve, deps): - formatted_deps = ', '.join(sorted(deps)) - wrapped_description = '\n '.join(textwrap.wrap(cve.description)) - return f""" - CVE ID: {cve.id} - CVSS v3 score: {cve.score} - Severity: {cve.severity} - Published date: {cve.published_date} - Last modified date: {cve.last_modified_date} - Dependencies: {formatted_deps} - Description: {wrapped_description} - Affected CPEs: - """ + '\n '.join(f'- {cpe}' for cpe in cve.cpes) +def main(*args) -> int: + return EnvoyCVEChecker(*args)() -FUZZY_DATE_RE = re.compile('(\d{4}).?(\d{2}).?(\d{2})') -FUZZY_SEMVER_RE = re.compile('(\d+)[:\.\-_](\d+)[:\.\-_](\d+)') - - -def regex_groups_match(regex, lhs, rhs): - """Do two strings match modulo a regular expression? - - Args: - regex: regular expression - lhs: LHS string - rhs: RHS string - Returns: - A boolean indicating match. - """ - lhs_match = regex.search(lhs) - if lhs_match: - rhs_match = regex.search(rhs) - if rhs_match and lhs_match.groups() == rhs_match.groups(): - return True - return False - - -def cpe_match(cpe, dep_metadata): - """Heuristically match dependency metadata against CPE. - - We have a number of rules below that should are easy to compute without having - to look at the dependency metadata. In the future, with additional access to - repository information we could do the following: - - For dependencies at a non-release version, walk back through git history to - the last known release version and attempt a match with this. - - For dependencies at a non-release version, use the commit date to look for a - version match where version is YYYY-MM-DD. - - Args: - cpe: Cpe object to match against. - dep_metadata: dependency metadata dictionary. - Returns: - A boolean indicating a match. - """ - dep_cpe = Cpe.from_string(dep_metadata['cpe']) - dep_version = dep_metadata['version'] - # The 'part' and 'vendor' must be an exact match. - if cpe.part != dep_cpe.part: - return False - if cpe.vendor != dep_cpe.vendor: - return False - # We allow Envoy dependency CPEs to wildcard the 'product', this is useful for - # LLVM where multiple product need to be covered. - if dep_cpe.product != '*' and cpe.product != dep_cpe.product: - return False - # Wildcard versions always match. - if cpe.version == '*': - return True - # An exact version match is a hit. - if cpe.version == dep_version: - return True - # Allow the 'release_date' dependency metadata to substitute for date. - # TODO(htuch): Consider fuzzier date ranges. - if cpe.version == dep_metadata['release_date']: - return True - # Try a fuzzy date match to deal with versions like fips-20190304 in dependency version. - if regex_groups_match(FUZZY_DATE_RE, dep_version, cpe.version): - return True - # Try a fuzzy semver match to deal with things like 2.1.0-beta3. - if regex_groups_match(FUZZY_SEMVER_RE, dep_version, cpe.version): - return True - # Fall-thru. - return False - - -def cve_match(cve, dep_metadata): - """Heuristically match dependency metadata against CVE. - - In general, we allow false positives but want to keep the noise low, to avoid - the toil around having to populate IGNORES_CVES. - - Args: - cve: Cve object to match against. - dep_metadata: dependency metadata dictionary. - Returns: - A boolean indicating a match. - """ - wildcard_version_match = False - # Consider each CPE attached to the CVE for a match against the dependency CPE. - for cpe in cve.cpes: - if cpe_match(cpe, dep_metadata): - # Wildcard version matches need additional heuristics unrelated to CPE to - # qualify, e.g. last updated date. - if cpe.version == '*': - wildcard_version_match = True - else: - return True - if wildcard_version_match: - # If the CVE was published after the dependency was last updated, it's a - # potential match. - last_dep_update = dt.date.fromisoformat(dep_metadata['release_date']) - if last_dep_update <= cve.published_date: - return True - return False - - -def cve_scan(cves, cpe_revmap, cve_allowlist, repository_locations): - """Scan for CVEs in a parsed NIST CVE database. - - Args: - cves: CVE dictionary as provided by download_cve_data(). - cve_revmap: CPE-CVE reverse map as provided by download_cve_data(). - cve_allowlist: an allowlist of CVE IDs to ignore. - repository_locations: a dictionary of dependency metadata in the format - described in api/bazel/external_deps.bzl. - Returns: - possible_cves: a dictionary mapping CVE IDs to Cve objects. - cve_deps: a dictionary mapping CVE IDs to dependency names. - """ - possible_cves = {} - cve_deps = defaultdict(list) - for dep, metadata in repository_locations.items(): - cpe = metadata.get('cpe', 'N/A') - if cpe == 'N/A': - continue - candidate_cve_ids = cpe_revmap.get(str(Cpe.from_string(cpe).vendor_normalized()), []) - for cve_id in candidate_cve_ids: - cve = cves[cve_id] - if cve.id in cve_allowlist: - continue - if cve_match(cve, metadata): - possible_cves[cve_id] = cve - cve_deps[cve_id].append(dep) - return possible_cves, cve_deps - - -if __name__ == '__main__': - cve_config = utils.from_yaml(sys.argv[1]) - - # Allow local overrides for NIST CVE database URLs via args. - urls = sys.argv[2:] - if not urls: - current_year = dt.datetime.now().year - scan_years = range(cve_config["start_year"], current_year + 1) - urls = [cve_config["ndist_url"].format(year=year) for year in scan_years] - cves, cpe_revmap = download_cve_data(urls) - - possible_cves, cve_deps = cve_scan( - cves, cpe_revmap, cve_config["ignore"], dep_utils.repository_locations()) - if possible_cves: - print( - '\nBased on heuristic matching with the NIST CVE database, Envoy may be vulnerable to:') - for cve_id in sorted(possible_cves): - print(f'{format_cve_details(possible_cves[cve_id], cve_deps[cve_id])}') - sys.exit(1) +if __name__ == "__main__": + sys.exit(main(*sys.argv[1:])) diff --git a/tools/dependency/cve_scan_test.py b/tools/dependency/cve_scan_test.py deleted file mode 100755 index 28ff3224c2f24..0000000000000 --- a/tools/dependency/cve_scan_test.py +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for cve_scan.""" - -from collections import defaultdict -import datetime as dt -import unittest - -import cve_scan - - -class CveScanTest(unittest.TestCase): - - def test_parse_cve_json(self): - cve_json = { - 'CVE_Items': [ - { - 'cve': { - 'CVE_data_meta': { - 'ID': 'CVE-2020-1234' - }, - 'description': { - 'description_data': [{ - 'value': 'foo' - }] - } - }, - 'configurations': { - 'nodes': [{ - 'cpe_match': [{ - 'cpe23Uri': 'cpe:2.3:a:foo:bar:1.2.3' - }], - }], - }, - 'impact': { - 'baseMetricV3': { - 'cvssV3': { - 'baseScore': 3.4, - 'baseSeverity': 'LOW' - } - } - }, - 'publishedDate': '2020-03-17T00:59Z', - 'lastModifiedDate': '2020-04-17T00:59Z' - }, - { - 'cve': { - 'CVE_data_meta': { - 'ID': 'CVE-2020-1235' - }, - 'description': { - 'description_data': [{ - 'value': 'bar' - }] - } - }, - 'configurations': { - 'nodes': [{ - 'cpe_match': [{ - 'cpe23Uri': 'cpe:2.3:a:foo:bar:1.2.3' - }], - 'children': [ - { - 'cpe_match': [{ - 'cpe23Uri': 'cpe:2.3:a:foo:baz:3.2.3' - }] - }, - { - 'cpe_match': [{ - 'cpe23Uri': 'cpe:2.3:a:foo:*:*' - }, { - 'cpe23Uri': 'cpe:2.3:a:wat:bar:1.2.3' - }] - }, - ], - }], - }, - 'impact': { - 'baseMetricV3': { - 'cvssV3': { - 'baseScore': 9.9, - 'baseSeverity': 'HIGH' - } - } - }, - 'publishedDate': '2020-03-18T00:59Z', - 'lastModifiedDate': '2020-04-18T00:59Z' - }, - ] - } - cves = {} - cpe_revmap = defaultdict(set) - cve_scan.parse_cve_json(cve_json, cves, cpe_revmap) - self.maxDiff = None - self.assertDictEqual( - cves, { - 'CVE-2020-1234': - cve_scan.Cve( - id='CVE-2020-1234', - description='foo', - cpes=set([self.build_cpe('cpe:2.3:a:foo:bar:1.2.3')]), - score=3.4, - severity='LOW', - published_date=dt.date(2020, 3, 17), - last_modified_date=dt.date(2020, 4, 17)), - 'CVE-2020-1235': - cve_scan.Cve( - id='CVE-2020-1235', - description='bar', - cpes=set( - map( - self.build_cpe, [ - 'cpe:2.3:a:foo:bar:1.2.3', 'cpe:2.3:a:foo:baz:3.2.3', - 'cpe:2.3:a:foo:*:*', 'cpe:2.3:a:wat:bar:1.2.3' - ])), - score=9.9, - severity='HIGH', - published_date=dt.date(2020, 3, 18), - last_modified_date=dt.date(2020, 4, 18)) - }) - self.assertDictEqual( - cpe_revmap, { - 'cpe:2.3:a:foo:*:*': {'CVE-2020-1234', 'CVE-2020-1235'}, - 'cpe:2.3:a:wat:*:*': {'CVE-2020-1235'} - }) - - def build_cpe(self, cpe_str): - return cve_scan.Cpe.from_string(cpe_str) - - def build_dep(self, cpe_str, version=None, release_date=None): - return {'cpe': cpe_str, 'version': version, 'release_date': release_date} - - def cpe_match(self, cpe_str, dep_cpe_str, version=None, release_date=None): - return cve_scan.cpe_match( - self.build_cpe(cpe_str), - self.build_dep(dep_cpe_str, version=version, release_date=release_date)) - - def test_cpe_match(self): - # Mismatched part - self.assertFalse(self.cpe_match('cpe:2.3:o:foo:bar:*', 'cpe:2.3:a:foo:bar:*')) - # Mismatched vendor - self.assertFalse(self.cpe_match('cpe:2.3:a:foo:bar:*', 'cpe:2.3:a:foz:bar:*')) - # Mismatched product - self.assertFalse(self.cpe_match('cpe:2.3:a:foo:bar:*', 'cpe:2.3:a:foo:baz:*')) - # Wildcard product - self.assertTrue(self.cpe_match('cpe:2.3:a:foo:bar:*', 'cpe:2.3:a:foo:*:*')) - # Wildcard version match - self.assertTrue(self.cpe_match('cpe:2.3:a:foo:bar:*', 'cpe:2.3:a:foo:bar:*')) - # Exact version match - self.assertTrue( - self.cpe_match('cpe:2.3:a:foo:bar:1.2.3', 'cpe:2.3:a:foo:bar:*', version='1.2.3')) - # Date version match - self.assertTrue( - self.cpe_match( - 'cpe:2.3:a:foo:bar:2020-03-05', 'cpe:2.3:a:foo:bar:*', release_date='2020-03-05')) - fuzzy_version_matches = [ - ('2020-03-05', '2020-03-05'), - ('2020-03-05', '20200305'), - ('2020-03-05', 'foo-20200305-bar'), - ('2020-03-05', 'foo-2020_03_05-bar'), - ('2020-03-05', 'foo-2020-03-05-bar'), - ('1.2.3', '1.2.3'), - ('1.2.3', '1-2-3'), - ('1.2.3', '1_2_3'), - ('1.2.3', '1:2:3'), - ('1.2.3', 'foo-1-2-3-bar'), - ] - for cpe_version, dep_version in fuzzy_version_matches: - self.assertTrue( - self.cpe_match( - f'cpe:2.3:a:foo:bar:{cpe_version}', 'cpe:2.3:a:foo:bar:*', version=dep_version)) - fuzzy_version_no_matches = [ - ('2020-03-05', '2020-3.5'), - ('2020-03-05', '2020--03-05'), - ('1.2.3', '1@2@3'), - ('1.2.3', '1..2.3'), - ] - for cpe_version, dep_version in fuzzy_version_no_matches: - self.assertFalse( - self.cpe_match( - f'cpe:2.3:a:foo:bar:{cpe_version}', 'cpe:2.3:a:foo:bar:*', version=dep_version)) - - def build_cve(self, cve_id, cpes, published_date): - return cve_scan.Cve( - cve_id, - description=None, - cpes=cpes, - score=None, - severity=None, - published_date=dt.date.fromisoformat(published_date), - last_modified_date=None) - - def cve_match(self, cve_id, cpes, published_date, dep_cpe_str, version=None, release_date=None): - return cve_scan.cve_match( - self.build_cve(cve_id, cpes=cpes, published_date=published_date), - self.build_dep(dep_cpe_str, version=version, release_date=release_date)) - - def test_cve_match(self): - # Empty CPEs, no match - self.assertFalse(self.cve_match('CVE-2020-123', set(), '2020-05-03', 'cpe:2.3:a:foo:bar:*')) - # Wildcard version, stale dependency match - self.assertTrue( - self.cve_match( - 'CVE-2020-123', - set([self.build_cpe('cpe:2.3:a:foo:bar:*')]), - '2020-05-03', - 'cpe:2.3:a:foo:bar:*', - release_date='2020-05-02')) - self.assertTrue( - self.cve_match( - 'CVE-2020-123', - set([self.build_cpe('cpe:2.3:a:foo:bar:*')]), - '2020-05-03', - 'cpe:2.3:a:foo:bar:*', - release_date='2020-05-03')) - # Wildcard version, recently updated - self.assertFalse( - self.cve_match( - 'CVE-2020-123', - set([self.build_cpe('cpe:2.3:a:foo:bar:*')]), - '2020-05-03', - 'cpe:2.3:a:foo:bar:*', - release_date='2020-05-04')) - # Version match - self.assertTrue( - self.cve_match( - 'CVE-2020-123', - set([self.build_cpe('cpe:2.3:a:foo:bar:1.2.3')]), - '2020-05-03', - 'cpe:2.3:a:foo:bar:*', - version='1.2.3')) - # Version mismatch - self.assertFalse( - self.cve_match( - 'CVE-2020-123', - set([self.build_cpe('cpe:2.3:a:foo:bar:1.2.3')]), - '2020-05-03', - 'cpe:2.3:a:foo:bar:*', - version='1.2.4', - release_date='2020-05-02')) - # Multiple CPEs, match first, don't match later. - self.assertTrue( - self.cve_match( - 'CVE-2020-123', - set([ - self.build_cpe('cpe:2.3:a:foo:bar:1.2.3'), - self.build_cpe('cpe:2.3:a:foo:baz:3.2.1') - ]), - '2020-05-03', - 'cpe:2.3:a:foo:bar:*', - version='1.2.3')) - - def test_cve_scan(self): - cves = { - 'CVE-2020-1234': - self.build_cve( - 'CVE-2020-1234', - set([ - self.build_cpe('cpe:2.3:a:foo:bar:1.2.3'), - self.build_cpe('cpe:2.3:a:foo:baz:3.2.1') - ]), '2020-05-03'), - 'CVE-2020-1235': - self.build_cve( - 'CVE-2020-1235', - set([ - self.build_cpe('cpe:2.3:a:foo:bar:1.2.3'), - self.build_cpe('cpe:2.3:a:foo:baz:3.2.1') - ]), '2020-05-03'), - 'CVE-2020-1236': - self.build_cve( - 'CVE-2020-1236', set([ - self.build_cpe('cpe:2.3:a:foo:wat:1.2.3'), - ]), '2020-05-03'), - } - cpe_revmap = { - 'cpe:2.3:a:foo:*:*': ['CVE-2020-1234', 'CVE-2020-1235', 'CVE-2020-1236'], - } - cve_allowlist = ['CVE-2020-1235'] - repository_locations = { - 'bar': self.build_dep('cpe:2.3:a:foo:bar:*', version='1.2.3'), - 'baz': self.build_dep('cpe:2.3:a:foo:baz:*', version='3.2.1'), - 'foo': self.build_dep('cpe:2.3:a:foo:*:*', version='1.2.3'), - 'blah': self.build_dep('N/A'), - } - possible_cves, cve_deps = cve_scan.cve_scan( - cves, cpe_revmap, cve_allowlist, repository_locations) - self.assertListEqual(sorted(possible_cves.keys()), ['CVE-2020-1234', 'CVE-2020-1236']) - self.assertDictEqual( - cve_deps, { - 'CVE-2020-1234': ['bar', 'baz', 'foo'], - 'CVE-2020-1236': ['foo'] - }) - - -if __name__ == '__main__': - unittest.main()