Skip to content

Commit 7ece023

Browse files
authored
Write packages and vulnerabilities at the time of import (#1280)
* Write packages and vulnerabilities at the time of import Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Address review comments Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Fix tests Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Optimise queries Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * remove unused imports Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Add CHANGELOG Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Move clean summary function to AdvisoryData class Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Fix tests Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Address review comments Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> --------- Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent 582a1fb commit 7ece023

19 files changed

Lines changed: 379 additions & 303 deletions

CHANGELOG.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Version v33.5.0
99
- We added clickable links to the Essentials tab of the Vulnerability details template that enable
1010
the user to navigate to the Fixed by packages tab and the Affected packages tab.
1111
- We fixed severity range issue for handling unknown scores.
12-
12+
- We added support to write packages and vulnerabilities at the time of import.
1313

1414
Version v33.4.0
1515
----------------

vulnerabilities/import_runner.py

Lines changed: 262 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,26 @@
99

1010
import datetime
1111
import logging
12+
from traceback import format_exc as traceback_format_exc
1213
from typing import Iterable
1314
from typing import List
1415

16+
from django.core.exceptions import ValidationError
17+
from django.db import transaction
18+
1519
from vulnerabilities.importer import AdvisoryData
1620
from vulnerabilities.importer import Importer
21+
from vulnerabilities.improver import Inference
22+
from vulnerabilities.improvers.default import DefaultImporter
1723
from vulnerabilities.models import Advisory
24+
from vulnerabilities.models import Alias
25+
from vulnerabilities.models import Package
26+
from vulnerabilities.models import PackageRelatedVulnerability
27+
from vulnerabilities.models import Vulnerability
28+
from vulnerabilities.models import VulnerabilityReference
29+
from vulnerabilities.models import VulnerabilityRelatedReference
30+
from vulnerabilities.models import VulnerabilitySeverity
31+
from vulnerabilities.models import Weakness
1832

1933
logger = logging.getLogger(__name__)
2034

@@ -41,42 +55,259 @@ def run(self) -> None:
4155
importer_class = self.importer_class
4256
logger.info(f"Starting import for {importer_name}")
4357
advisory_datas = importer_class().advisory_data()
44-
count = process_advisories(advisory_datas=advisory_datas, importer_name=importer_name)
58+
count = self.process_advisories(advisory_datas=advisory_datas, importer_name=importer_name)
4559
logger.info(f"Finished import for {importer_name}. Imported {count} advisories.")
4660

61+
def do_import(self, advisories) -> None:
62+
advisory_importer = DefaultImporter(advisories=advisories)
63+
logger.info(f"Running importer: {advisory_importer.qualified_name}")
64+
importer_name = advisory_importer.qualified_name
65+
advisories = []
66+
for advisory in advisory_importer.interesting_advisories:
67+
if advisory.date_imported:
68+
continue
69+
logger.info(f"Processing advisory: {advisory!r}")
70+
try:
71+
inferences = advisory_importer.get_inferences(
72+
advisory_data=advisory.to_advisory_data()
73+
)
74+
process_inferences(
75+
inferences=inferences,
76+
advisory=advisory,
77+
improver_name=importer_name,
78+
)
79+
except Exception as e:
80+
logger.info(f"Failed to process advisory: {advisory!r} with error {e!r}")
81+
logger.info("Finished importing using %s.", advisory_importer.__class__.qualified_name)
4782

48-
def process_advisories(advisory_datas: Iterable[AdvisoryData], importer_name: str) -> List:
49-
"""
50-
Insert advisories into the database
51-
Return the number of inserted advisories.
52-
"""
53-
count = 0
54-
for data in advisory_datas:
55-
# https://nvd.nist.gov/vuln/detail/CVE-2013-4314
56-
# https://github.com/cms-dev/cms/issues/888#issuecomment-516977572
83+
def process_advisories(
84+
self, advisory_datas: Iterable[AdvisoryData], importer_name: str
85+
) -> List:
86+
"""
87+
Insert advisories into the database
88+
Return the number of inserted advisories.
89+
"""
90+
count = 0
91+
advisories = []
92+
for data in advisory_datas:
93+
try:
94+
obj, created = Advisory.objects.get_or_create(
95+
aliases=data.aliases,
96+
summary=data.summary,
97+
affected_packages=[pkg.to_dict() for pkg in data.affected_packages],
98+
references=[ref.to_dict() for ref in data.references],
99+
date_published=data.date_published,
100+
weaknesses=data.weaknesses,
101+
defaults={
102+
"created_by": importer_name,
103+
"date_collected": datetime.datetime.now(tz=datetime.timezone.utc),
104+
},
105+
)
106+
if not obj.date_imported:
107+
advisories.append(obj)
108+
except Exception as e:
109+
logger.error(
110+
f"Error while processing {data!r} with aliases {data.aliases!r}: {e!r} \n {traceback_format_exc()}"
111+
)
112+
continue
113+
if created:
114+
logger.info(
115+
f"[*] New Advisory with aliases: {obj.aliases!r}, created_by: {obj.created_by}"
116+
)
117+
count += 1
118+
else:
119+
logger.debug(f"Advisory with aliases: {obj.aliases!r} already exists.")
57120
try:
58-
data.summary = data.summary.replace("\x00", "\uFFFD")
59-
obj, created = Advisory.objects.get_or_create(
60-
aliases=data.aliases,
61-
summary=data.summary,
62-
affected_packages=[pkg.to_dict() for pkg in data.affected_packages],
63-
references=[ref.to_dict() for ref in data.references],
64-
date_published=data.date_published,
65-
weaknesses=data.weaknesses,
66-
defaults={
67-
"created_by": importer_name,
68-
"date_collected": datetime.datetime.now(tz=datetime.timezone.utc),
69-
},
70-
)
121+
self.do_import(advisories)
71122
except Exception as e:
72-
logger.error(f"Error while processing {data!r} with aliases {data.aliases!r}: {e!r}")
123+
logger.error(
124+
f"Error while processing advisories from {importer_name!r}: {e!r} \n {traceback_format_exc()}"
125+
)
126+
return count
127+
128+
129+
@transaction.atomic
130+
def process_inferences(inferences: List[Inference], advisory: Advisory, improver_name: str):
131+
"""
132+
Return number of inferences processed.
133+
An atomic transaction that updates both the Advisory (e.g. date_imported)
134+
and processes the given inferences to create or update corresponding
135+
database fields.
136+
137+
This avoids failing the entire improver when only a single inference is
138+
erroneous. Also, the atomic transaction for every advisory and its
139+
inferences makes sure that date_imported of advisory is consistent.
140+
"""
141+
inferences_processed_count = 0
142+
143+
if not inferences:
144+
logger.warning(f"Nothing to improve. Source: {improver_name} Advisory id: {advisory.id}")
145+
return inferences_processed_count
146+
147+
logger.info(f"Improving advisory id: {advisory.id}")
148+
149+
for inference in inferences:
150+
vulnerability = get_or_create_vulnerability_and_aliases(
151+
vulnerability_id=inference.vulnerability_id,
152+
alias_names=inference.aliases,
153+
summary=inference.summary,
154+
)
155+
156+
if not vulnerability:
157+
logger.warning(f"Unable to get vulnerability for inference: {inference!r}")
73158
continue
74-
if created:
75-
logger.info(
76-
f"[*] New Advisory with aliases: {obj.aliases!r}, created_by: {obj.created_by}"
159+
160+
for ref in inference.references:
161+
162+
reference = VulnerabilityReference.objects.get_or_none(
163+
reference_id=ref.reference_id,
164+
url=ref.url,
77165
)
78-
count += 1
79-
else:
80-
logger.debug(f"Advisory with aliases: {obj.aliases!r} already exists. Skipped.")
81166

82-
return count
167+
if not reference:
168+
reference = create_valid_vulnerability_reference(
169+
reference_id=ref.reference_id,
170+
url=ref.url,
171+
)
172+
if not reference:
173+
continue
174+
175+
VulnerabilityRelatedReference.objects.update_or_create(
176+
reference=reference,
177+
vulnerability=vulnerability,
178+
)
179+
180+
for severity in ref.severities:
181+
_vs, updated = VulnerabilitySeverity.objects.update_or_create(
182+
scoring_system=severity.system.identifier,
183+
reference=reference,
184+
defaults={
185+
"value": str(severity.value),
186+
"scoring_elements": str(severity.scoring_elements),
187+
},
188+
)
189+
if updated:
190+
logger.info(
191+
f"Severity updated for reference {ref!r} to value: {severity.value!r} "
192+
f"and scoring_elements: {severity.scoring_elements!r}"
193+
)
194+
195+
for affected_purl in inference.affected_purls or []:
196+
vulnerable_package = Package.objects.get_or_create_from_purl(purl=affected_purl)
197+
PackageRelatedVulnerability(
198+
vulnerability=vulnerability,
199+
package=vulnerable_package,
200+
created_by=improver_name,
201+
confidence=inference.confidence,
202+
fix=False,
203+
).update_or_create()
204+
205+
if inference.fixed_purl:
206+
fixed_package = Package.objects.get_or_create_from_purl(purl=inference.fixed_purl)
207+
PackageRelatedVulnerability(
208+
vulnerability=vulnerability,
209+
package=fixed_package,
210+
created_by=improver_name,
211+
confidence=inference.confidence,
212+
fix=True,
213+
).update_or_create()
214+
215+
if inference.weaknesses and vulnerability:
216+
for cwe_id in inference.weaknesses:
217+
cwe_obj, created = Weakness.objects.get_or_create(cwe_id=cwe_id)
218+
cwe_obj.vulnerabilities.add(vulnerability)
219+
cwe_obj.save()
220+
inferences_processed_count += 1
221+
222+
advisory.date_imported = datetime.datetime.now(tz=datetime.timezone.utc)
223+
advisory.save()
224+
return inferences_processed_count
225+
226+
227+
def create_valid_vulnerability_reference(url, reference_id=None):
228+
"""
229+
Create and return a new validated VulnerabilityReference from a
230+
``url`` and ``reference_id``.
231+
Return None and log a warning if this is not a valid reference.
232+
"""
233+
reference = VulnerabilityReference(
234+
reference_id=reference_id,
235+
url=url,
236+
)
237+
238+
try:
239+
reference.full_clean()
240+
except ValidationError as e:
241+
logger.warning(f"Invalid vulnerability reference: {reference!r}: {e}")
242+
return
243+
244+
reference.save()
245+
return reference
246+
247+
248+
def get_or_create_vulnerability_and_aliases(alias_names, vulnerability_id=None, summary=None):
249+
"""
250+
Get or create vulnerabilitiy and aliases such that all existing and new
251+
aliases point to the same vulnerability
252+
"""
253+
existing_vulns = set()
254+
alias_names = set(alias_names)
255+
new_alias_names = set()
256+
for alias_name in alias_names:
257+
try:
258+
alias = Alias.objects.get(alias=alias_name)
259+
existing_vulns.add(alias.vulnerability)
260+
except Alias.DoesNotExist:
261+
new_alias_names.add(alias_name)
262+
263+
# If given set of aliases point to different vulnerabilities in the
264+
# database, request is malformed
265+
# TODO: It is possible that all those vulnerabilities are actually
266+
# the same at data level, figure out a way to merge them
267+
if len(existing_vulns) > 1:
268+
logger.warning(
269+
f"Given aliases {alias_names} already exist and do not point "
270+
f"to a single vulnerability. Cannot improve. Skipped."
271+
)
272+
return
273+
274+
existing_alias_vuln = existing_vulns.pop() if existing_vulns else None
275+
276+
if (
277+
existing_alias_vuln
278+
and vulnerability_id
279+
and existing_alias_vuln.vulnerability_id != vulnerability_id
280+
):
281+
logger.warning(
282+
f"Given aliases {alias_names!r} already exist and point to existing"
283+
f"vulnerability {existing_alias_vuln}. Unable to create Vulnerability "
284+
f"with vulnerability_id {vulnerability_id}. Skipped"
285+
)
286+
return
287+
288+
if existing_alias_vuln:
289+
vulnerability = existing_alias_vuln
290+
elif vulnerability_id:
291+
try:
292+
vulnerability = Vulnerability.objects.get(vulnerability_id=vulnerability_id)
293+
except Vulnerability.DoesNotExist:
294+
logger.warning(
295+
f"Given vulnerability_id: {vulnerability_id} does not exist in the database"
296+
)
297+
return
298+
else:
299+
vulnerability = Vulnerability(summary=summary)
300+
vulnerability.save()
301+
302+
if summary and summary != vulnerability.summary:
303+
logger.warning(
304+
f"Inconsistent summary for {vulnerability!r}. "
305+
f"Existing: {vulnerability.summary}, provided: {summary}"
306+
)
307+
308+
for alias_name in new_alias_names:
309+
alias = Alias(alias=alias_name, vulnerability=vulnerability)
310+
alias.save()
311+
logger.info(f"New alias for {vulnerability!r}: {alias_name}")
312+
313+
return vulnerability

vulnerabilities/importer.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,16 @@ class AdvisoryData:
252252
def __post_init__(self):
253253
if self.date_published and not self.date_published.tzinfo:
254254
logger.warning(f"AdvisoryData with no tzinfo: {self!r}")
255+
if self.summary:
256+
self.summary = self.clean_summary(self.summary)
257+
258+
def clean_summary(self, summary):
259+
# https://nvd.nist.gov/vuln/detail/CVE-2013-4314
260+
# https://github.com/cms-dev/cms/issues/888#issuecomment-516977572
261+
summary = summary.strip()
262+
if summary:
263+
summary = summary.replace("\x00", "\uFFFD")
264+
return summary
255265

256266
def to_dict(self):
257267
return {

vulnerabilities/importers/__init__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,21 @@
3737
from vulnerabilities.importers import xen
3838

3939
IMPORTERS_REGISTRY = [
40+
nvd.NVDImporter,
41+
github.GitHubAPIImporter,
42+
gitlab.GitLabAPIImporter,
43+
npm.NpmImporter,
44+
pypa.PyPaImporter,
4045
nginx.NginxImporter,
46+
pysec.PyPIImporter,
4147
alpine_linux.AlpineImporter,
42-
github.GitHubAPIImporter,
43-
nvd.NVDImporter,
4448
openssl.OpensslImporter,
4549
redhat.RedhatImporter,
46-
pysec.PyPIImporter,
4750
debian.DebianImporter,
48-
gitlab.GitLabAPIImporter,
4951
postgresql.PostgreSQLImporter,
50-
pypa.PyPaImporter,
5152
archlinux.ArchlinuxImporter,
5253
ubuntu.UbuntuImporter,
5354
debian_oval.DebianOvalImporter,
54-
npm.NpmImporter,
5555
retiredotnet.RetireDotnetImporter,
5656
apache_httpd.ApacheHTTPDImporter,
5757
mozilla.MozillaImporter,

0 commit comments

Comments
 (0)