Skip to content

Commit d1db1d7

Browse files
keshav-spacemichaelehab
authored andcommitted
Use VersionRange.normalize to compare advisory
Signed-off-by: Keshav Priyadarshi <git@keshav.space> Signed-off-by: Michael Ehab Mikhail <michael.ehab@hotmail.com>
1 parent 3b9ac00 commit d1db1d7

1 file changed

Lines changed: 107 additions & 127 deletions

File tree

vulntotal/vulntotal_cli.py

Lines changed: 107 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@
1919

2020
# TODO: use saneyaml
2121
import yaml
22+
from fetchcode import package_versions
2223
from packageurl import PackageURL
2324
from texttable import Texttable
24-
from univers.normalized_range import NormalizedVersionRanges
25+
from univers.version_range import RANGE_CLASS_BY_SCHEMES
26+
from univers.version_range import VersionRange
27+
from univers.version_range import build_range_from_github_advisory_constraint
28+
from univers.version_range import build_range_from_snyk_advisory_string
29+
from univers.version_range import from_gitlab_native
2530

26-
from vulnerabilities.package_managers import VERSION_API_CLASSES_BY_PACKAGE_TYPE
2731
from vulntotal.datasources import DATASOURCE_REGISTRY
2832
from vulntotal.validator import VendorData
2933
from vulntotal.vulntotal_utils import get_item
@@ -189,7 +193,9 @@ def handler(
189193
prettyprint(purl, active_datasource, pagination, no_threading)
190194

191195
elif purl:
192-
prettyprint_group_by_cve(purl, active_datasource, pagination, no_threading, vers, no_compare)
196+
prettyprint_group_by_cve(
197+
purl, active_datasource, pagination, no_threading, vers, no_compare
198+
)
193199

194200

195201
def get_valid_datasources(datasources):
@@ -281,10 +287,12 @@ def run_datasources(purl, datasources, no_threading=False):
281287
return vulnerabilities
282288

283289

284-
class VulntotalEncoder(json.JSONEncoder):
290+
class VendorDataEncoder(json.JSONEncoder):
285291
def default(self, obj):
286-
if isinstance(obj, VendorData) or isinstance(obj, NormalizedVersionRanges):
292+
if isinstance(obj, VendorData):
287293
return obj.to_dict()
294+
if isinstance(obj, VersionRange):
295+
return str(obj)
288296
return json.JSONEncoder.default(self, obj)
289297

290298

@@ -298,7 +306,7 @@ def write_json_output(purl, datasources, json_output, no_threading, no_group, no
298306
grouped_by_cve = group_by_cve(vulnerabilities, PackageURL.from_string(purl), no_compare)
299307
results.update(grouped_by_cve)
300308

301-
return json.dump(results, json_output, cls=VulntotalEncoder, indent=2)
309+
return json.dump(results, json_output, cls=VendorDataEncoder, indent=2)
302310

303311

304312
def noop(self, *args, **kw):
@@ -316,30 +324,26 @@ def write_yaml_output(purl, datasources, yaml_output, no_threading, no_group, no
316324
results.update(vulnerabilities)
317325
else:
318326
grouped_by_cve = group_by_cve(vulnerabilities, PackageURL.from_string(purl), no_compare)
319-
serialize_normalized_range(grouped_by_cve, no_compare)
327+
serialize_version_range(grouped_by_cve, no_compare)
320328
results.update(grouped_by_cve)
321329

322330
return yaml.dump(results, yaml_output, default_flow_style=False, indent=2, sort_keys=False)
323331

324332

325-
def serialize_normalized_range(grouped_by_cve, no_compare):
333+
def serialize_version_range(grouped_by_cve, no_compare):
326334
if no_compare:
327335
return
328336
for cve, value in grouped_by_cve.items():
329337
if cve in ("NOCVE", "NOADVISORY"):
330338
continue
331-
for datasource, resources in value.items():
339+
for _, resources in value.items():
332340
for resource in resources:
333341
affected_versions = resource.get("normalized_affected_versions")
334342
fixed_versions = resource.get("normalized_fixed_versions")
335-
if isinstance(affected_versions, NormalizedVersionRanges):
336-
resource["normalized_affected_versions"] = [
337-
str(vers) for vers in affected_versions.version_ranges
338-
]
339-
if isinstance(fixed_versions, NormalizedVersionRanges):
340-
resource["normalized_fixed_versions"] = [
341-
str(vers) for vers in fixed_versions.version_ranges
342-
]
343+
if isinstance(affected_versions, VersionRange):
344+
resource["normalized_affected_versions"] = str(affected_versions)
345+
if isinstance(fixed_versions, VersionRange):
346+
resource["normalized_fixed_versions"] = str(fixed_versions)
343347

344348

345349
def prettyprint(purl, datasources, pagination, no_threading):
@@ -363,17 +367,6 @@ def prettyprint(purl, datasources, pagination, no_threading):
363367
pydoc.pager(metadata + table.draw()) if pagination else click.echo(metadata + table.draw())
364368

365369

366-
NORMALIZED_VERSION_RANGE_BY_DATASOURCE = {
367-
"deps": NormalizedVersionRanges.from_discrete,
368-
"github": NormalizedVersionRanges.from_github,
369-
"gitlab": NormalizedVersionRanges.from_gitlab,
370-
"oss_index": None,
371-
"osv": NormalizedVersionRanges.from_discrete,
372-
"snyk": NormalizedVersionRanges.from_snyk,
373-
"vulnerablecode": NormalizedVersionRanges.from_discrete,
374-
}
375-
376-
377370
def group_by_cve(vulnerabilities, purl, no_compare):
378371
grouped_by_cve = {}
379372
nocve = {}
@@ -382,32 +375,20 @@ def group_by_cve(vulnerabilities, purl, no_compare):
382375
if not advisories:
383376
if datasource not in noadvisory:
384377
noadvisory[datasource] = []
385-
noadvisory[datasource].append(
386-
{
387-
"advisory": None,
388-
}
389-
)
378+
noadvisory[datasource].append({"advisory": None})
390379
for advisory in advisories:
391380
cve = next((x for x in advisory.aliases if x.startswith("CVE")), None)
392381
if not cve:
393382
if datasource not in nocve:
394383
nocve[datasource] = []
395-
nocve[datasource].append(
396-
{
397-
"advisory": advisory,
398-
}
399-
)
384+
nocve[datasource].append({"advisory": advisory})
400385
continue
401386
if cve not in grouped_by_cve:
402387
grouped_by_cve[cve] = {}
403388

404389
if datasource not in grouped_by_cve[cve]:
405390
grouped_by_cve[cve][datasource] = []
406-
grouped_by_cve[cve][datasource].append(
407-
{
408-
"advisory": advisory,
409-
}
410-
)
391+
grouped_by_cve[cve][datasource].append({"advisory": advisory})
411392
grouped_by_cve["NOCVE"] = nocve
412393
grouped_by_cve["NOADVISORY"] = noadvisory
413394
if not no_compare:
@@ -418,7 +399,6 @@ def group_by_cve(vulnerabilities, purl, no_compare):
418399

419400
def normalize_version_ranges(grouped_by_cve, purl):
420401
package_versions = get_all_versions(purl)
421-
422402
for cve, value in grouped_by_cve.items():
423403
if cve in ("NOCVE", "NOADVISORY"):
424404
continue
@@ -427,20 +407,24 @@ def normalize_version_ranges(grouped_by_cve, purl):
427407
advisory = resource["advisory"]
428408
normalized_affected_versions = []
429409
normalized_fixed_versions = []
430-
datasource_normalizer = NORMALIZED_VERSION_RANGE_BY_DATASOURCE.get(datasource)
431-
if datasource_normalizer and advisory.affected_versions:
410+
version_range_func = VERSION_RANGE_BY_DATASOURCE.get(datasource)
411+
if version_range_func and advisory.affected_versions:
412+
affected = advisory.affected_versions
413+
if len(affected) == 1:
414+
affected = affected[0]
415+
432416
try:
433-
normalized_affected_versions = datasource_normalizer(
434-
advisory.affected_versions, purl.type, package_versions
435-
)
417+
vra = version_range_func(purl.type, affected)
418+
normalized_affected_versions = vra.normalize(package_versions)
436419
except Exception as err:
437420
normalized_affected_versions = [err]
438421

439422
if advisory.fixed_versions:
440423
try:
441-
normalized_fixed_versions = NormalizedVersionRanges.from_discrete(
442-
advisory.fixed_versions, purl.type, package_versions
424+
vrf = get_range_from_discrete_version_string(
425+
purl.type, advisory.fixed_versions
443426
)
427+
normalized_fixed_versions = vrf.normalize(package_versions)
444428
except Exception as err:
445429
normalized_fixed_versions = [err]
446430

@@ -449,35 +433,37 @@ def normalize_version_ranges(grouped_by_cve, purl):
449433

450434

451435
def compare(grouped_by_cve):
452-
for cve, value in grouped_by_cve.items():
436+
for cve, advisories in grouped_by_cve.items():
453437
if cve in ("NOCVE", "NOADVISORY"):
454438
continue
455-
sources = list(value.keys())
439+
sources = list(advisories.keys())
456440
board = {source: {} for source in sources}
457-
"""
458-
A typical board after comparison may look like this.
459-
460-
board = {
461-
"github":{
462-
"snyk": 0,
463-
"gitlab": 1,
464-
"deps": 0,
465-
"vulnerablecode": 1,
466-
"osv": 1,
467-
"oss_index": 1,
468-
},
469-
"snyk":{
470-
"github": 0,
471-
"gitlab": 1,
472-
"deps": 0,
473-
"vulnerablecode": 1,
474-
"osv": 1,
475-
"oss_index": 1,
476-
},
477-
...
478-
}
479-
"""
480-
for datasource, resources in value.items():
441+
442+
# For each unique CVE create the scoring board to score
443+
# advisory from different datasources.
444+
# A typical board after comparison may look like this.
445+
446+
# board = {
447+
# "github":{
448+
# "snyk": 0,
449+
# "gitlab": 1,
450+
# "deps": 0,
451+
# "vulnerablecode": 1,
452+
# "osv": 1,
453+
# "oss_index": 1,
454+
# },
455+
# "snyk":{
456+
# "github": 0,
457+
# "gitlab": 1,
458+
# "deps": 0,
459+
# "vulnerablecode": 1,
460+
# "osv": 1,
461+
# "oss_index": 1,
462+
# },
463+
# ...
464+
# }
465+
466+
for datasource, resources in advisories.items():
481467
normalized_affected_versions_a = get_item(resources, 0, "normalized_affected_versions")
482468
normalized_fixed_versions_a = get_item(resources, 0, "normalized_fixed_versions")
483469
if normalized_fixed_versions_a and normalized_affected_versions_a:
@@ -489,28 +475,31 @@ def compare(grouped_by_cve):
489475
):
490476
continue
491477
normalized_affected_versions_b = get_item(
492-
value, source, 0, "normalized_affected_versions"
478+
advisories, source, 0, "normalized_affected_versions"
493479
)
494480
normalized_fixed_versions_b = get_item(
495-
value, source, 0, "normalized_fixed_versions"
481+
advisories, source, 0, "normalized_fixed_versions"
496482
)
497483
board[datasource][source] = 0
498484
board[source][datasource] = 0
499-
if (
500-
normalized_fixed_versions_a == normalized_fixed_versions_b
501-
and normalized_affected_versions_a == normalized_affected_versions_b
502-
):
503-
board[datasource][source] = 1
504-
board[source][datasource] = 1
505-
506-
maximum = max([sum(list(table.values())) for table in board.values()])
485+
if normalized_fixed_versions_a == normalized_fixed_versions_b:
486+
board[datasource][source] += 0.5
487+
board[source][datasource] += 0.5
488+
elif normalized_affected_versions_a == normalized_affected_versions_b:
489+
board[datasource][source] += 0.5
490+
board[source][datasource] += 0.5
491+
492+
# Compute the relative score from the score board for each advisory.
493+
maximum = max([sum(table.values()) for table in board.values()])
507494
datasource_count = len(sources)
508495
for datasource, table in board.items():
509496
if maximum == 0:
510-
# NA if only one advisory else TC aka `Total Collision`.
511-
value[datasource][0]["score"] = "TC" if datasource_count > 1 else "NA"
497+
# NA if only one advisory and nothing to compare with.
498+
# TC (Total Collision) i.e no two advisory agree on common fixed or affected version.
499+
advisories[datasource][0]["score"] = "TC" if datasource_count > 1 else "NA"
512500
continue
513-
value[datasource][0]["score"] = (sum(list(table.values())) / maximum) * 100
501+
datasource_score = (sum(table.values()) / maximum) * 100
502+
advisories[datasource][0]["score"] = datasource_score
514503

515504

516505
def prettyprint_group_by_cve(purl, datasources, pagination, no_threading, vers, no_compare):
@@ -535,37 +524,21 @@ def prettyprint_group_by_cve(purl, datasources, pagination, no_threading, vers,
535524
if not no_compare and vers and "score" in resources[0]:
536525
na_affected = get_item(resources, 0, "normalized_affected_versions")
537526
na_fixed = get_item(resources, 0, "normalized_fixed_versions")
538-
na_affected = (
539-
na_affected.version_ranges
540-
if isinstance(na_affected, NormalizedVersionRanges)
541-
else na_affected
542-
)
543-
na_fixed = (
544-
na_fixed.version_ranges
545-
if isinstance(na_fixed, NormalizedVersionRanges)
546-
else na_fixed
547-
)
548-
na_affected = "\n".join([str(i) for i in na_affected])
549-
na_fixed = "\n".join([str(i) for i in na_fixed])
550527
table.add_row(["", "", "", na_affected, na_fixed, ""])
551528

552529
pydoc.pager(metadata + table.draw()) if pagination else click.echo(metadata + table.draw())
553530

554531

555-
def strip_leading_v(version):
556-
if version.startswith("v"):
557-
return version[1:]
558-
return version
559-
560-
561532
def get_texttable(no_group=False, no_compare=False):
562533
quantum = 100 / 125
563534
terminal_width = os.get_terminal_size().columns
564535
line_factor = terminal_width / 100
565536

566-
column_5x = math.floor(5 * quantum * line_factor)
567-
column_15x = math.floor(15 * quantum * line_factor)
568-
column_20x = math.floor(20 * quantum * line_factor)
537+
column_size = lambda f: math.floor(f * quantum * line_factor)
538+
column_7x = column_size(5)
539+
column_17x = column_size(10)
540+
column_15x = column_size(15)
541+
column_20x = column_size(20)
569542

570543
table = Texttable()
571544

@@ -581,37 +554,44 @@ def get_texttable(no_group=False, no_compare=False):
581554
table.set_cols_dtype(["a", "a", "a", "a", "a"])
582555
table.set_cols_align(["l", "l", "l", "l", "l"])
583556
table.set_cols_valign(["t", "t", "t", "a", "t"])
584-
table.set_cols_width([column_20x, column_15x, column_20x, column_20x, column_20x])
557+
table.set_cols_width([column_15x, column_15x, column_20x, column_20x, column_20x])
585558
table.header(["CVE", "DATASOURCE", "ALIASES", "AFFECTED", "FIXED"])
586559
return table
587560

588561
table.set_cols_dtype(["a", "a", "a", "a", "a", "a"])
589562
table.set_cols_align(["l", "l", "l", "l", "l", "l"])
590563
table.set_cols_valign(["t", "t", "t", "a", "t", "t"])
591-
table.set_cols_width([column_20x, column_15x, column_20x, column_20x, column_20x, column_5x])
564+
table.set_cols_width([column_17x, column_15x, column_15x, column_20x, column_20x, column_7x])
592565
table.header(["CVE", "DATASOURCE", "ALIASES", "AFFECTED", "FIXED", "SCORE"])
593566

594567
return table
595568

596569

597-
def get_all_versions(purl: PackageURL):
598-
if purl.type not in VERSION_API_CLASSES_BY_PACKAGE_TYPE:
599-
return
570+
def get_range_from_discrete_version_string(schema, versions):
571+
range_cls = RANGE_CLASS_BY_SCHEMES.get(schema)
572+
if isinstance(versions, str):
573+
versions = [versions]
574+
return range_cls.from_versions(versions)
600575

601-
versionAPI = None
602-
package_name = None
603576

604-
if purl.type == "maven":
605-
package_name = f"{purl.namespace}:{purl.name}"
606-
if purl.type in ("composer", "golang", "github"):
607-
package_name = f"{purl.namespace}/{purl.name}"
608-
if purl.type in ("nuget", "pypi", "gem", "npm", "hex", "deb", "cargo"):
609-
package_name = purl.name
577+
VERSION_RANGE_BY_DATASOURCE = {
578+
"deps": get_range_from_discrete_version_string,
579+
"github": build_range_from_github_advisory_constraint,
580+
"gitlab": from_gitlab_native,
581+
"oss_index": None,
582+
"osv": get_range_from_discrete_version_string,
583+
"snyk": build_range_from_snyk_advisory_string,
584+
"safetydb": build_range_from_snyk_advisory_string,
585+
"vulnerablecode": get_range_from_discrete_version_string,
586+
}
587+
610588

611-
versionAPI = VERSION_API_CLASSES_BY_PACKAGE_TYPE.get(purl.type)()
612-
all_versions = versionAPI.fetch(package_name)
589+
def get_all_versions(purl: PackageURL):
590+
if purl.type not in package_versions.SUPPORTED_ECOSYSTEMS:
591+
return
613592

614-
return [strip_leading_v(package_version.value) for package_version in all_versions]
593+
all_versions = package_versions.versions(str(purl))
594+
return [package_version.value for package_version in all_versions]
615595

616596

617597
if __name__ == "__main__":

0 commit comments

Comments
 (0)