Skip to content

Commit c992168

Browse files
authored
Merge pull request #664 from TG1999/new_importer/nvd
Migrate nvd importer to importer-improver model Signed-off-by: Philippe Ombredanne <pombredanne@nexb.com>
2 parents 39ef487 + ec3e564 commit c992168

10 files changed

Lines changed: 1010 additions & 267 deletions

File tree

vulnerabilities/importers/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@
2222
from vulnerabilities.importers import alpine_linux
2323
from vulnerabilities.importers import github
2424
from vulnerabilities.importers import nginx
25+
from vulnerabilities.importers import nvd
2526

26-
IMPORTERS_REGISTRY = [nginx.NginxImporter, alpine_linux.AlpineImporter, github.GitHubAPIImporter]
27+
IMPORTERS_REGISTRY = [
28+
nginx.NginxImporter,
29+
alpine_linux.AlpineImporter,
30+
github.GitHubAPIImporter,
31+
nvd.NVDImporter,
32+
]
2733

2834
IMPORTERS_REGISTRY = {x.qualified_name: x for x in IMPORTERS_REGISTRY}

vulnerabilities/importers/nvd.py

Lines changed: 164 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -20,150 +20,189 @@
2020
# VulnerableCode is a free software code scanning tool from nexB Inc. and others.
2121
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
2222

23-
import dataclasses
2423
import gzip
2524
import json
2625
from datetime import date
26+
from typing import Iterable
2727

2828
import requests
2929
from dateutil import parser as dateparser
30+
from django.db.models.query import QuerySet
3031

31-
from vulnerabilities.helpers import create_etag
32-
from vulnerabilities.importer import Advisory
32+
from vulnerabilities.helpers import get_item
33+
from vulnerabilities.importer import AdvisoryData
3334
from vulnerabilities.importer import Importer
3435
from vulnerabilities.importer import Reference
3536
from vulnerabilities.importer import VulnerabilitySeverity
36-
from vulnerabilities.severity_systems import scoring_systems
37-
38-
BASE_URL = "https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-{}.json.gz"
37+
from vulnerabilities.improver import Improver
38+
from vulnerabilities.improver import Inference
39+
from vulnerabilities.models import Advisory
40+
from vulnerabilities.severity_systems import SCORING_SYSTEMS
3941

4042

4143
class NVDImporter(Importer):
42-
def updated_advisories(self):
44+
# See https://github.com/nexB/vulnerablecode/issues/665 for follow up
45+
spdx_license_expression = "LicenseRef-scancode-unknown"
46+
47+
def advisory_data(self):
48+
advisory_data = []
4349
current_year = date.today().year
4450
# NVD json feeds start from 2002.
4551
for year in range(2002, current_year + 1):
46-
download_url = BASE_URL.format(year)
47-
# Etags are like hashes of web responses. We maintain
48-
# (url, etag) mappings in the DB. `create_etag` creates
49-
# (url, etag) pair. If a (url, etag) already exists then the code
50-
# skips processing the response further to avoid duplicate work
51-
if create_etag(data_src=self, url=download_url, etag_key="etag"):
52-
data = self.fetch(download_url)
53-
yield self.to_advisories(data)
54-
55-
@staticmethod
56-
def fetch(url):
57-
gz_file = requests.get(url)
58-
data = gzip.decompress(gz_file.content)
59-
return json.loads(data)
60-
61-
def to_advisories(self, nvd_data):
62-
for cve_item in nvd_data["CVE_Items"]:
63-
if self.is_outdated(cve_item):
64-
continue
65-
66-
if self.related_to_hardware(cve_item):
67-
continue
68-
69-
cve_id = cve_item["cve"]["CVE_data_meta"]["ID"]
70-
ref_urls = self.extract_reference_urls(cve_item)
71-
references = [Reference(url=url) for url in ref_urls]
72-
severity_scores = self.extract_severity_scores(cve_item)
52+
download_url = f"https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-{year}.json.gz"
53+
data = fetch(download_url)
54+
advisory_data.extend(to_advisories(data))
55+
return advisory_data
56+
57+
58+
# Isolating network calls for simplicity of testing
59+
def fetch(url):
60+
gz_file = requests.get(url)
61+
data = gzip.decompress(gz_file.content)
62+
return json.loads(data)
63+
64+
65+
def extract_summary(cve_item):
66+
"""
67+
Return a summary for a given CVE item.
68+
"""
69+
# In 99% of cases len(cve_item['cve']['description']['description_data']) == 1 , so
70+
# this usually returns cve_item['cve']['description']['description_data'][0]['value']
71+
# In the remaining 1% cases this returns the longest summary.
72+
summaries = []
73+
for desc in get_item(cve_item, "cve", "description", "description_data") or []:
74+
if desc.get("value"):
75+
summaries.append(desc["value"])
76+
return max(summaries, key=len) if summaries else None
77+
78+
79+
def to_advisories(nvd_data):
80+
"""
81+
Yield AdvisoryData objects from a NVD json feed.
82+
"""
83+
for cve_item in nvd_data.get("CVE_Items") or []:
84+
cpes = extract_cpes(cve_item)
85+
if related_to_hardware(cpes):
86+
continue
87+
88+
aliases = []
89+
cve_id = get_item(cve_item, "cve", "CVE_data_meta", "ID")
90+
ref_urls = extract_reference_urls(cve_item)
91+
references = []
92+
severity_scores = list(extract_severity_scores(cve_item))
93+
for cpe in cpes:
7394
references.append(
7495
Reference(
75-
url=f"https://nvd.nist.gov/vuln/detail/{cve_id}",
76-
reference_id=cve_id,
77-
severities=severity_scores,
78-
)
79-
)
80-
summary = self.extract_summary(cve_item)
81-
yield Advisory(
82-
vulnerability_id=cve_id,
83-
summary=summary,
84-
references=references,
85-
)
86-
87-
@staticmethod
88-
def extract_summary(cve_item):
89-
# In 99% of cases len(cve_item['cve']['description']['description_data']) == 1 , so
90-
# this usually returns cve_item['cve']['description']['description_data'][0]['value']
91-
# In the remaining 1% cases this returns the longest summary.
92-
summaries = [desc["value"] for desc in cve_item["cve"]["description"]["description_data"]]
93-
return max(summaries, key=len)
94-
95-
@staticmethod
96-
def extract_severity_scores(cve_item):
97-
severity_scores = []
98-
99-
if cve_item["impact"].get("baseMetricV3"):
100-
severity_scores.append(
101-
VulnerabilitySeverity(
102-
system=scoring_systems["cvssv3"],
103-
value=str(cve_item["impact"]["baseMetricV3"]["cvssV3"]["baseScore"]),
96+
reference_id=cpe,
10497
)
10598
)
106-
severity_scores.append(
107-
VulnerabilitySeverity(
108-
system=scoring_systems["cvssv3_vector"],
109-
value=str(cve_item["impact"]["baseMetricV3"]["cvssV3"]["vectorString"]),
110-
)
111-
)
112-
113-
if cve_item["impact"].get("baseMetricV2"):
114-
severity_scores.append(
115-
VulnerabilitySeverity(
116-
system=scoring_systems["cvssv2"],
117-
value=str(cve_item["impact"]["baseMetricV2"]["cvssV2"]["baseScore"]),
118-
)
99+
references.append(
100+
Reference(
101+
url=f"https://nvd.nist.gov/vuln/detail/{cve_id}",
102+
reference_id=cve_id,
103+
severities=severity_scores,
119104
)
120-
severity_scores.append(
121-
VulnerabilitySeverity(
122-
system=scoring_systems["cvssv2_vector"],
123-
value=str(cve_item["impact"]["baseMetricV2"]["cvssV2"]["vectorString"]),
124-
)
105+
)
106+
if "https://nvd.nist.gov/vuln/detail/{cve_id}" in ref_urls:
107+
ref_urls.remove(f"https://nvd.nist.gov/vuln/detail/{cve_id}")
108+
references.extend([Reference(url=url) for url in ref_urls])
109+
if cve_id:
110+
aliases.append(cve_id)
111+
summary = extract_summary(cve_item)
112+
yield AdvisoryData(
113+
aliases=aliases,
114+
summary=summary,
115+
references=sorted(references),
116+
date_published=dateparser.parse(cve_item.get("publishedDate")),
117+
)
118+
119+
120+
def extract_reference_urls(cve_item):
121+
"""
122+
Return a list of reference URLs for a given CVE item.
123+
"""
124+
urls = set()
125+
for reference in get_item(cve_item, "cve", "references", "reference_data") or []:
126+
ref_url = reference.get("url")
127+
128+
if not ref_url:
129+
continue
130+
131+
if ref_url.startswith(
132+
(
133+
"http",
134+
"ftp",
125135
)
126-
127-
return severity_scores
128-
129-
def extract_reference_urls(self, cve_item):
130-
urls = set()
131-
for reference in cve_item["cve"]["references"]["reference_data"]:
132-
ref_url = reference["url"]
133-
134-
if not ref_url:
135-
continue
136-
137-
if ref_url.startswith("http") or ref_url.startswith("ftp"):
138-
urls.add(ref_url)
139-
140-
return urls
141-
142-
def is_outdated(self, cve_item):
143-
cve_last_modified_date = cve_item["lastModifiedDate"]
144-
cve_last_modified_date_obj = dateparser.parse(cve_last_modified_date)
145-
146-
if self.config.cutoff_date:
147-
return cve_last_modified_date_obj < self.config.cutoff_date
148-
149-
if self.config.last_run_date:
150-
return cve_last_modified_date_obj < self.config.last_run_date
151-
152-
return False
153-
154-
def related_to_hardware(self, cve_item):
155-
for cpe in self.extract_cpes(cve_item):
156-
cpe_comps = cpe.split(":")
157-
# CPE follow the format cpe:cpe_version:product_type:vendor:product
158-
if cpe_comps[2] == "h":
159-
return True
160-
161-
return False
162-
163-
@staticmethod
164-
def extract_cpes(cve_item):
165-
cpes = set()
166-
for node in cve_item["configurations"]["nodes"]:
167-
for cpe_data in node.get("cpe_match", []):
168-
cpes.add(cpe_data["cpe23Uri"])
169-
return cpes
136+
):
137+
urls.add(ref_url)
138+
139+
return urls
140+
141+
142+
def related_to_hardware(cpes):
143+
"""
144+
Return True if the CVE item is related to hardware.
145+
"""
146+
for cpe in cpes:
147+
cpe_comps = cpe.split(":")
148+
# CPE follow the format cpe:cpe_version:product_type:vendor:product
149+
if len(cpe_comps) > 2 and cpe_comps[2] == "h":
150+
return True
151+
152+
return False
153+
154+
155+
def extract_cpes(cve_item):
156+
"""
157+
Return a list of CPEs for a given CVE item.
158+
"""
159+
cpes = set()
160+
for node in get_item(cve_item, "configurations", "nodes") or []:
161+
for cpe_data in node.get("cpe_match") or []:
162+
cpe23_uri = cpe_data.get("cpe23Uri")
163+
if cpe23_uri:
164+
cpes.add(cpe23_uri)
165+
return cpes
166+
167+
168+
def extract_severity_scores(cve_item):
169+
"""
170+
Yield a vulnerability severity for each `cve_item`.
171+
"""
172+
if not isinstance(cve_item, dict):
173+
return None
174+
impact = cve_item.get("impact") or {}
175+
base_metric_v3 = impact.get("baseMetricV3") or {}
176+
if base_metric_v3:
177+
cvss_v3 = get_item(base_metric_v3, "cvssV3")
178+
yield VulnerabilitySeverity(
179+
system=SCORING_SYSTEMS["cvssv3"],
180+
value=str(cvss_v3.get("baseScore") or ""),
181+
)
182+
yield VulnerabilitySeverity(
183+
system=SCORING_SYSTEMS["cvssv3_vector"],
184+
value=str(cvss_v3.get("vectorString") or ""),
185+
)
186+
187+
base_metric_v2 = impact.get("baseMetricV2") or {}
188+
if base_metric_v2:
189+
cvss_v2 = base_metric_v2.get("cvssV2") or {}
190+
yield VulnerabilitySeverity(
191+
system=SCORING_SYSTEMS["cvssv2"],
192+
value=str(cvss_v2.get("baseScore") or ""),
193+
)
194+
yield VulnerabilitySeverity(
195+
system=SCORING_SYSTEMS["cvssv2_vector"],
196+
value=str(cvss_v2.get("vectorString") or ""),
197+
)
198+
199+
200+
class NVDBasicImprover(Improver):
201+
@property
202+
def interesting_advisories(self) -> QuerySet:
203+
return Advisory.objects.filter(created_by=NVDImporter.qualified_name)
204+
205+
def get_inferences(self, advisory_data: AdvisoryData) -> Iterable[Inference]:
206+
yield Inference.from_advisory_data(
207+
advisory_data=advisory_data, confidence=100, fixed_purl=None
208+
)

vulnerabilities/improvers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
importers.nginx.NginxBasicImprover,
77
importers.alpine_linux.AlpineBasicImprover,
88
importers.github.GitHubBasicImprover,
9+
importers.nvd.NVDBasicImprover,
910
]
1011

1112
IMPROVERS_REGISTRY = {x.qualified_name: x for x in IMPROVERS_REGISTRY}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 4.0.2 on 2022-04-08 18:53
2+
3+
from django.db import migrations
4+
from django.db import models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("vulnerabilities", "0006_alter_advisory_unique_together"),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name="vulnerabilityreference",
16+
name="reference_id",
17+
field=models.CharField(
18+
blank=True,
19+
help_text="An optional reference ID, such as DSA-4465-1 when available",
20+
max_length=200,
21+
null=True,
22+
),
23+
),
24+
]

vulnerabilities/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ class VulnerabilityReference(models.Model):
104104
max_length=1024, help_text="URL to the vulnerability reference", blank=True
105105
)
106106
reference_id = models.CharField(
107-
max_length=50,
107+
max_length=200,
108108
help_text="An optional reference ID, such as DSA-4465-1 when available",
109109
blank=True,
110110
null=True,

vulnerabilities/tests/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ def no_rmtree(monkeypatch):
4747
"test_apache_httpd.py",
4848
"test_npm.py",
4949
"test_apache_kafka.py",
50-
"test_nvd.py",
5150
"test_apache_tomcat.py",
5251
"test_openssl.py",
5352
"test_api.py",

0 commit comments

Comments
 (0)