From 7325ff119446c27207148cb7d851d3a8e718e3ca Mon Sep 17 00:00:00 2001 From: ykodwani01 Date: Fri, 29 Mar 2024 20:21:41 +0530 Subject: [PATCH 1/4] Expose Advisory Details in API Signed-off-by: ykodwani01 Signed-off-by: Yash Kodwani <202101418@daiict.ac.in> --- vulnerabilities/api.py | 8 +- vulnerabilities/models.py | 2622 ++++++++++++++--------------- vulnerabilities/tests/test_api.py | 714 ++++---- 3 files changed, 1684 insertions(+), 1660 deletions(-) diff --git a/vulnerabilities/api.py b/vulnerabilities/api.py index f51998b87..8844bc54d 100644 --- a/vulnerabilities/api.py +++ b/vulnerabilities/api.py @@ -27,6 +27,7 @@ from vulnerabilities.models import Alias from vulnerabilities.models import Package from vulnerabilities.models import Vulnerability +from vulnerabilities.models import Advisory from vulnerabilities.models import VulnerabilityReference from vulnerabilities.models import VulnerabilitySeverity from vulnerabilities.models import Weakness @@ -182,7 +183,12 @@ def to_representation(self, instance): weaknesses = data.get("weaknesses", []) data["weaknesses"] = [weakness for weakness in weaknesses if weakness is not None] - + alias_queryset = instance.aliases.all() + data["advisory"] = [] + for itr in alias_queryset: + advisory_objects = Advisory.objects.filter(aliases=[itr.alias]) + for i in advisory_objects: + data["advisory"].append({"unique_content_id" : i.unique_content_id,"url" : i.url,"summary" : i.summary}) return data class Meta: diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index a0fd63a44..76d0e977c 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -1,1311 +1,1311 @@ -# -# Copyright (c) nexB Inc. and others. All rights reserved. -# VulnerableCode is a trademark of nexB Inc. -# SPDX-License-Identifier: Apache-2.0 -# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/vulnerablecode for support or download. -# See https://aboutcode.org for more information about nexB OSS projects. -# - -import hashlib -import json -import logging -from contextlib import suppress -from typing import Any - -from cwe2.database import Database -from django.contrib.auth import get_user_model -from django.contrib.auth.models import UserManager -from django.core import exceptions -from django.core.exceptions import ValidationError -from django.core.paginator import Paginator -from django.core.validators import MaxValueValidator -from django.core.validators import MinValueValidator -from django.db import models -from django.db import transaction -from django.db.models import Count -from django.db.models import Prefetch -from django.db.models import Q -from django.db.models.functions import Length -from django.db.models.functions import Trim -from django.urls import reverse -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ -from packageurl import PackageURL -from packageurl.contrib.django.models import PackageURLMixin -from packageurl.contrib.django.models import PackageURLQuerySet -from rest_framework.authtoken.models import Token -from univers import versions -from univers.version_range import RANGE_CLASS_BY_SCHEMES -from univers.version_range import AlpineLinuxVersionRange -from univers.versions import Version - -from vulnerabilities import utils -from vulnerabilities.severity_systems import SCORING_SYSTEMS -from vulnerabilities.utils import normalize_purl -from vulnerabilities.utils import purl_to_dict -from vulnerablecode import __version__ as VULNERABLECODE_VERSION - -logger = logging.getLogger(__name__) - -models.CharField.register_lookup(Length) -models.CharField.register_lookup(Trim) - - -class BaseQuerySet(models.QuerySet): - def get_or_none(self, *args, **kwargs): - """ - Returns a single object matching the given keyword arguments, `None` otherwise. - """ - with suppress(self.model.DoesNotExist, ValidationError): - return self.get(*args, **kwargs) - - def paginated(self, per_page=5000): - """ - Iterate over a (large) QuerySet by chunks of ``per_page`` items. - This technique is essential for preventing memory issues when iterating - See these links for inspiration: - https://nextlinklabs.com/resources/insights/django-big-data-iteration - https://stackoverflow.com/questions/4222176/why-is-iterating-through-a-large-django-queryset-consuming-massive-amounts-of-me/ - """ - paginator = Paginator(self, per_page=per_page) - for page_number in paginator.page_range: - page = paginator.page(page_number) - for object in page.object_list: - yield object - - -class VulnerabilityQuerySet(BaseQuerySet): - def affecting_vulnerabilities(self): - """ - Return a queryset of Vulnerability that affect a package. - """ - return self.filter(packagerelatedvulnerability__fix=False) - - def with_cpes(self): - """ - Return a queryset of Vulnerability that have one or more NVD CPE references. - """ - return self.filter(vulnerabilityreference__reference_id__startswith="cpe") - - def for_cpe(self, cpe): - """ - Return a queryset of Vulnerability that have the ``cpe`` as an NVD CPE reference. - """ - return self.filter(vulnerabilityreference__reference_id__exact=cpe) - - def with_cves(self): - """ - Return a queryset of Vulnerability that have one or more NVD CVE aliases. - """ - return self.filter(aliases__alias__startswith="CVE") - - def for_cve(self, cve): - """ - Return a queryset of Vulnerability that have the the NVD CVE ``cve`` as an alias. - """ - return self.filter(vulnerabilityreference__reference_id__exact=cve) - - def with_packages(self): - """ - Return a queryset of Vulnerability that have one or more related packages. - """ - return self.filter(packages__isnull=False) - - def search(self, query: str = None): - """ - Return a Vulnerability queryset searching for the ``query``. - Make a best effort approach to search a vulnerability using various heuristics. - """ - - query = query and query.strip() - if not query: - return self.none() - - qs = self - - # middle ground, exact on vulnerability_id - qssearch = qs.filter(vulnerability_id=query) - if not qssearch.exists(): - # middle ground, exact on alias - qssearch = qs.filter(aliases__alias=query) - if not qssearch.exists(): - # middle ground, slow enough - qssearch = qs.filter( - Q(vulnerability_id__icontains=query) | Q(aliases__alias__icontains=query) - ) - if not qssearch.exists(): - # last resort super slow - qssearch = qs.filter( - Q(references__id__icontains=query) | Q(summary__icontains=query) - ) - - return qssearch.order_by("vulnerability_id") - - def with_package_counts(self): - return self.annotate( - vulnerable_package_count=Count( - "packages", filter=Q(packagerelatedvulnerability__fix=False), distinct=True - ), - patched_package_count=Count( - "packages", filter=Q(packagerelatedvulnerability__fix=True), distinct=True - ), - ) - - -class VulnerabilityStatusType(models.IntegerChoices): - """List of vulnerability statuses.""" - - PUBLISHED = 1, "Published" - DISPUTED = 2, "Disputed" - INVALID = 3, "Invalid" - - -class Vulnerability(models.Model): - """ - A software vulnerability with a unique identifier and alternate ``aliases``. - """ - - vulnerability_id = models.CharField( - unique=True, - blank=True, - max_length=20, - default=utils.build_vcid, - help_text="Unique identifier for a vulnerability in the external representation. " - "It is prefixed with VCID-", - ) - - summary = models.TextField( - help_text="Summary of the vulnerability", - blank=True, - ) - - references = models.ManyToManyField( - to="VulnerabilityReference", through="VulnerabilityRelatedReference" - ) - - packages = models.ManyToManyField( - to="Package", - through="PackageRelatedVulnerability", - ) - - status = models.IntegerField( - choices=VulnerabilityStatusType.choices, default=VulnerabilityStatusType.PUBLISHED - ) - - objects = VulnerabilityQuerySet.as_manager() - - class Meta: - verbose_name_plural = "Vulnerabilities" - ordering = ["vulnerability_id"] - - def __str__(self): - return self.vulnerability_id - - @property - def vcid(self): - return self.vulnerability_id - - @property - def severities(self): - """ - Return a queryset of VulnerabilitySeverity for this vulnerability. - """ - return VulnerabilitySeverity.objects.filter(reference__in=self.references.all()) - - @property - def affected_packages(self): - """ - Return a queryset of packages that are affected by this vulnerability. - """ - return self.packages.affected() - - # legacy aliases - vulnerable_packages = affected_packages - - @property - def fixed_by_packages(self): - """ - Return a queryset of packages that are fixing this vulnerability. - """ - return self.packages.fixing() - - # legacy alias - patched_packages = fixed_by_packages - - @property - def get_aliases(self): - """ - Return a queryset of all Aliases for this vulnerability. - """ - return self.aliases.all() - - alias = get_aliases - - @property - def get_status_label(self): - label_by_status = {choice[0]: choice[1] for choice in VulnerabilityStatusType.choices} - return label_by_status.get(self.status) or VulnerabilityStatusType.PUBLISHED.label - - @property - def history(self): - return self.changelog.all() - - def get_absolute_url(self): - """ - Return this Vulnerability details absolute URL. - """ - return reverse("vulnerability_details", args=[self.vulnerability_id]) - - def get_details_url(self, request): - """ - Return this Package details URL. - """ - from rest_framework.reverse import reverse - - return reverse( - "vulnerability_details", - kwargs={"vulnerability_id": self.vulnerability_id}, - request=request, - ) - - def get_related_cpes(self): - """ - Return a list of CPE strings of this vulnerability. - """ - return list(self.references.for_cpe().values_list("reference_id", flat=True).distinct()) - - def get_related_cves(self): - """ - Return a list of aliases CVE strings of this vulnerability. - """ - return list(self.aliases.for_cve().values_list("alias", flat=True).distinct()) - - def get_affected_purls(self): - """ - Return a list of purl strings affected by this vulnerability. - """ - return [p.package_url for p in self.affected_packages.all()] - - def get_fixing_purls(self): - """ - Return a list of purl strings fixing this vulnerability. - """ - return [p.package_url for p in self.fixed_by_packages.all()] - - def get_related_purls(self): - """ - Return a list of purl strings related to this vulnerability. - """ - return [p.package_url for p in self.packages.distinct().all()] - - -class Weakness(models.Model): - """ - A Common Weakness Enumeration model - """ - - cwe_id = models.IntegerField(help_text="CWE id") - vulnerabilities = models.ManyToManyField(Vulnerability, related_name="weaknesses") - db = Database() - - @property - def weakness(self): - """ - Return a queryset of Weakness for this vulnerability. - """ - try: - weakness = self.db.get(self.cwe_id) - return weakness - except Exception as e: - logger.warning(f"Could not find CWE {self.cwe_id}: {e}") - - @property - def name(self): - """Return the weakness's name.""" - return self.weakness.name if self.weakness else "" - - @property - def description(self): - """Return the weakness's description.""" - return self.weakness.description if self.weakness else "" - - def to_dict(self): - return {"cwe_id": self.cwe_id, "name": self.name, "description": self.description} - - -class VulnerabilityReferenceQuerySet(BaseQuerySet): - def for_cpe(self): - """ - Return a queryset of VulnerabilityReferences that are for a CPE. - """ - return self.filter(reference_id__startswith="cpe") - - -class VulnerabilityReference(models.Model): - """ - A reference to a vulnerability such as a security advisory from a Linux distribution or language - package manager. - """ - - vulnerabilities = models.ManyToManyField( - to="Vulnerability", - through="VulnerabilityRelatedReference", - ) - - url = models.URLField( - max_length=1024, - help_text="URL to the vulnerability reference", - unique=True, - ) - - reference_id = models.CharField( - max_length=200, - help_text="An optional reference ID, such as DSA-4465-1 when available", - blank=True, - ) - - objects = VulnerabilityReferenceQuerySet.as_manager() - - class Meta: - ordering = ["reference_id", "url"] - - def __str__(self): - reference_id = f" {self.reference_id}" if self.reference_id else "" - return f"{self.url}{reference_id}" - - @property - def is_cpe(self): - """ - Return Trueis this is a CPE reference. - """ - return self.reference_id.startswith("cpe") - - -class VulnerabilityRelatedReference(models.Model): - """ - A reference related to a vulnerability. - """ - - vulnerability = models.ForeignKey( - Vulnerability, - on_delete=models.CASCADE, - ) - - reference = models.ForeignKey( - VulnerabilityReference, - on_delete=models.CASCADE, - ) - - class Meta: - unique_together = ["vulnerability", "reference"] - ordering = ["vulnerability", "reference"] - - -class PackageQuerySet(BaseQuerySet, PackageURLQuerySet): - def get_fixed_by_package_versions(self, purl: PackageURL, fix=True): - """ - Return a queryset of all the package versions of this `package` that fix any vulnerability. - If `fix` is False, return all package versions whether or not they fix a vulnerability. - """ - filter_dict = { - "name": purl.name, - "namespace": purl.namespace, - "type": purl.type, - "qualifiers": purl.qualifiers, - "subpath": purl.subpath, - } - - if fix: - filter_dict["packagerelatedvulnerability__fix"] = True - - return Package.objects.filter(**filter_dict).distinct() - - def get_or_create_from_purl(self, purl: PackageURL): - """ - Return an existing or new Package (created if neeed) given a - ``purl`` PackageURL. - """ - if isinstance(purl, str): - purl = PackageURL.from_string(purl) - - package, is_created = Package.objects.get_or_create(**purl_to_dict(purl=purl)) - - return package, is_created - - def affected(self): - """ - Return only packages affected by a vulnerability. - """ - return self.filter(packagerelatedvulnerability__fix=False) - - vulnerable = affected - - def fixing(self): - """ - Return only packages fixing a vulnerability . - """ - return self.filter(packagerelatedvulnerability__fix=True) - - def with_vulnerability_counts(self): - return self.annotate( - vulnerability_count=Count( - "vulnerabilities", - filter=Q(packagerelatedvulnerability__fix=False), - ), - patched_vulnerability_count=Count( - "vulnerabilities", - filter=Q(packagerelatedvulnerability__fix=True), - ), - ) - - def fixing_packages(self, package, with_qualifiers_and_subpath=True): - """ - Return a queryset of packages that are fixing the vulnerability of - ``package``. - """ - - return self.match_purl( - purl=package.purl, - with_qualifiers_and_subpath=with_qualifiers_and_subpath, - ).fixing() - - def search(self, query: str = None): - """ - Return a Package queryset searching for the ``query``. - Make a best effort approach to find matching packages either based - on exact purl, partial purl or just name and namespace. - """ - query = query and query.strip() - if not query: - return self.none() - qs = self - - try: - # if it's a valid purl, try to parse it and use it as is - purl = str(utils.plain_purl(query)) - qs = qs.filter(package_url__istartswith=purl) - except ValueError: - # otherwise use query as a plain string - qs = qs.filter(package_url__icontains=query) - return qs.order_by("package_url") - - def for_purl(self, purl): - """ - Return a queryset matching the ``purl`` Package URL. - """ - return self.filter(**purl_to_dict(purl)) - - def with_cpes(self): - """ - Return a queryset of Package that a vulnerability with one or more NVD CPE references. - """ - return self.filter(vulnerabilities__vulnerabilityreference__reference_id__startswith="cpe") - - def for_cpe(self, cpe): - """ - Return a queryset of Vulnerability that have the ``cpe`` as an NVD CPE reference. - """ - return self.filter(vulnerabilities__vulnerabilityreference__reference_id__exact=cpe) - - def with_cves(self): - """ - Return a queryset of Vulnerability that have one or more NVD CVE aliases. - """ - return self.filter(vulnerabilities__aliases__alias__startswith="CVE") - - def for_cve(self, cve): - """ - Return a queryset of Vulnerability that have the the NVD CVE ``cve`` as an alias. - """ - return self.filter(vulnerabilities__vulnerabilityreference__reference_id__exact=cve) - - def for_purls(self, purls=[]): - return Package.objects.filter(package_url__in=purls).distinct() - - -def get_purl_query_lookups(purl): - """ - Return a dictionary of non-empty plain purl fields - Do not reference all the possible qualifiers and relax the - purl matching to only lookup the type, namespace, name and version fields. - """ - plain_purl = utils.plain_purl(purl=purl) - return purl_to_dict(plain_purl, with_empty=False) - - -class Package(PackageURLMixin): - """ - A software package with related vulnerabilities. - """ - - # Remove the `qualifers` and `set_package_url` overrides after - # https://github.com/package-url/packageurl-python/pull/35 - # https://github.com/package-url/packageurl-python/pull/67 - # gets merged - - vulnerabilities = models.ManyToManyField( - to="Vulnerability", through="PackageRelatedVulnerability" - ) - - package_url = models.CharField( - max_length=1000, - null=False, - help_text="The Package URL for this package.", - db_index=True, - ) - - plain_package_url = models.CharField( - max_length=1000, - null=False, - help_text="The Package URL for this package without qualifiers and subpath.", - db_index=True, - ) - - objects = PackageQuerySet.as_manager() - - def save(self, *args, **kwargs): - """ - Save, normalizing PURL fields. - """ - purl = PackageURL( - type=self.type, - namespace=self.namespace, - name=self.name, - version=self.version, - qualifiers=self.qualifiers, - subpath=self.subpath, - ) - - # We re-parse the purl to ensure name and namespace - # are set correctly - normalized = normalize_purl(purl=purl) - - for name, value in purl_to_dict(normalized).items(): - setattr(self, name, value) - - self.package_url = str(normalized) - plain_purl = utils.plain_purl(normalized) - self.plain_package_url = str(plain_purl) - super().save(*args, **kwargs) - - @property - def purl(self): - return self.package_url - - class Meta: - unique_together = ["type", "namespace", "name", "version", "qualifiers", "subpath"] - ordering = ["type", "namespace", "name", "version", "qualifiers", "subpath"] - - def __str__(self): - return self.package_url - - @property - def affected_by(self): - """ - Return a queryset of vulnerabilities affecting this package. - """ - return self.vulnerabilities.filter(packagerelatedvulnerability__fix=False) - - # legacy aliases - vulnerable_to = affected_by - - @property - # TODO: consider renaming to "fixes" or "fixing" ? (TBD) and updating the docstring - def fixing(self): - """ - Return a queryset of vulnerabilities fixed by this package. - """ - return self.vulnerabilities.filter(packagerelatedvulnerability__fix=True) - - # legacy aliases - resolved_to = fixing - - @property - def fixed_packages(self): - """ - Return a queryset of packages that are fixed. - """ - return Package.objects.fixing_packages(package=self).distinct() - - @property - def is_vulnerable(self) -> bool: - """ - Returns True if this package is vulnerable to any vulnerability. - """ - return self.affected_by.exists() - - @property - def history(self): - return self.changelog.all() - - def get_absolute_url(self): - """ - Return this Package details URL. - """ - return reverse("package_details", args=[self.purl]) - - def get_details_url(self, request): - """ - Return this Package details URL. - """ - from rest_framework.reverse import reverse - - return reverse("package_details", kwargs={"purl": self.purl}, request=request) - - def sort_by_version(self, packages): - """ - Return a list of `packages` sorted by version. - """ - if not packages: - return [] - - return sorted( - packages, - key=lambda x: self.version_class(x.version), - ) - - @property - def version_class(self): - RANGE_CLASS_BY_SCHEMES["alpine"] = AlpineLinuxVersionRange - range_class = RANGE_CLASS_BY_SCHEMES.get(self.type) - if not range_class: - return Version - return range_class.version_class - - @property - def current_version(self): - return self.version_class(self.version) - - @property - def fixed_package_details(self): - """ - Return a mapping of vulnerabilities that affect this package and the next and - latest non-vulnerable versions. - """ - package_details = {} - package_details["purl"] = PackageURL.from_string(self.purl) - - next_non_vulnerable, latest_non_vulnerable = self.get_non_vulnerable_versions() - package_details["next_non_vulnerable"] = next_non_vulnerable - package_details["latest_non_vulnerable"] = latest_non_vulnerable - - package_details["vulnerabilities"] = self.get_affecting_vulnerabilities() - - return package_details - - def get_non_vulnerable_versions(self): - """ - Return a tuple of the next and latest non-vulnerable versions as PackageURLs. Return a tuple of - (None, None) if there is no non-vulnerable version. - """ - package_versions = Package.objects.get_fixed_by_package_versions(self, fix=False) - - non_vulnerable_versions = [] - for version in package_versions: - if not version.is_vulnerable: - non_vulnerable_versions.append(version) - - later_non_vulnerable_versions = [] - for non_vuln_ver in non_vulnerable_versions: - if self.version_class(non_vuln_ver.version) > self.current_version: - later_non_vulnerable_versions.append(non_vuln_ver) - - if later_non_vulnerable_versions: - sorted_versions = self.sort_by_version(later_non_vulnerable_versions) - next_non_vulnerable_version = sorted_versions[0] - latest_non_vulnerable_version = sorted_versions[-1] - - next_non_vulnerable = PackageURL.from_string(next_non_vulnerable_version.purl) - latest_non_vulnerable = PackageURL.from_string(latest_non_vulnerable_version.purl) - - return next_non_vulnerable, latest_non_vulnerable - - return None, None - - def get_affecting_vulnerabilities(self): - """ - Return a list of vulnerabilities that affect this package together with information regarding - the versions that fix the vulnerabilities. - """ - package_details_vulns = [] - - fixed_by_packages = Package.objects.get_fixed_by_package_versions(self, fix=True) - - package_vulnerabilities = self.vulnerabilities.affecting_vulnerabilities().prefetch_related( - Prefetch( - "packages", - queryset=fixed_by_packages, - to_attr="fixed_packages", - ) - ) - - for vuln in package_vulnerabilities: - package_details_vulns.append({"vulnerability": vuln}) - later_fixed_packages = [] - - for fixed_pkg in vuln.fixed_packages: - if fixed_pkg not in fixed_by_packages: - continue - fixed_version = self.version_class(fixed_pkg.version) - if fixed_version > self.current_version: - later_fixed_packages.append(fixed_pkg) - - next_fixed_package = None - next_fixed_package_vulns = [] - - sort_fixed_by_packages_by_version = [] - if later_fixed_packages: - sort_fixed_by_packages_by_version = self.sort_by_version(later_fixed_packages) - - fixed_by_pkgs = [] - - for vuln_details in package_details_vulns: - if vuln_details["vulnerability"] != vuln: - continue - vuln_details["fixed_by_purl"] = [] - vuln_details["fixed_by_purl_vulnerabilities"] = [] - - for fixed_by_pkg in sort_fixed_by_packages_by_version: - fixed_by_package_details = {} - fixed_by_purl = PackageURL.from_string(fixed_by_pkg.purl) - next_fixed_package_vulns = list(fixed_by_pkg.affected_by) - - fixed_by_package_details["fixed_by_purl"] = fixed_by_purl - fixed_by_package_details[ - "fixed_by_purl_vulnerabilities" - ] = next_fixed_package_vulns - fixed_by_pkgs.append(fixed_by_package_details) - - vuln_details["fixed_by_package_details"] = fixed_by_pkgs - - return package_details_vulns - - @property - def fixing_vulnerabilities(self): - """ - Return a queryset of Vulnerabilities that are fixed by this `package`. - """ - return self.vulnerabilities.filter(packagerelatedvulnerability__fix=True) - - @property - def affecting_vulnerabilities(self): - """ - Return a queryset of Vulnerabilities that affect this `package`. - """ - return self.vulnerabilities.filter(packagerelatedvulnerability__fix=False) - - -class PackageRelatedVulnerability(models.Model): - """ - Track the relationship between a Package and Vulnerability. - """ - - # TODO: Fix related_name - package = models.ForeignKey( - Package, - on_delete=models.CASCADE, - ) - - vulnerability = models.ForeignKey( - Vulnerability, - on_delete=models.CASCADE, - ) - - created_by = models.CharField( - max_length=100, - blank=True, - help_text="Fully qualified name of the improver prefixed with the" - "module name responsible for creating this relation. Eg:" - "vulnerabilities.importers.nginx.NginxBasicImprover", - ) - from vulnerabilities.improver import MAX_CONFIDENCE - - confidence = models.PositiveIntegerField( - default=MAX_CONFIDENCE, - validators=[MinValueValidator(0), MaxValueValidator(MAX_CONFIDENCE)], - help_text="Confidence score for this relation", - ) - - fix = models.BooleanField( - default=False, - db_index=True, - help_text="Does this relation fix the specified vulnerability ?", - ) - - class Meta: - unique_together = ["package", "vulnerability"] - verbose_name_plural = "PackageRelatedVulnerabilities" - indexes = [models.Index(fields=["fix"])] - ordering = ["package", "vulnerability"] - - def __str__(self): - return f"{self.package.package_url} {self.vulnerability.vulnerability_id}" - - def update_or_create(self, advisory): - """ - Update if supplied record has more confidence than existing record - Create if doesn't exist - """ - try: - existing = PackageRelatedVulnerability.objects.get( - vulnerability=self.vulnerability, package=self.package - ) - if self.confidence > existing.confidence: - existing.created_by = self.created_by - existing.confidence = self.confidence - existing.fix = self.fix - existing.save() - # TODO: later we want these to be part of a log field in the DB - logger.info( - f"Confidence improved for {self.package} R {self.vulnerability}, " - f"new confidence: {self.confidence}" - ) - self.add_package_vulnerability_changelog(advisory=advisory) - - except self.DoesNotExist: - PackageRelatedVulnerability.objects.create( - vulnerability=self.vulnerability, - created_by=self.created_by, - package=self.package, - confidence=self.confidence, - fix=self.fix, - ) - - logger.info( - f"New relationship {self.package} R {self.vulnerability}, " - f"fix: {self.fix}, confidence: {self.confidence}" - ) - - self.add_package_vulnerability_changelog(advisory=advisory) - - @transaction.atomic - def add_package_vulnerability_changelog(self, advisory): - from vulnerabilities.utils import get_importer_name - - importer_name = get_importer_name(advisory) - if self.fix: - change_logger = PackageChangeLog.log_fixing - else: - change_logger = PackageChangeLog.log_affected_by - change_logger( - package=self.package, - importer=importer_name, - source_url=advisory.url or None, - related_vulnerability=str(self.vulnerability), - ) - - -class VulnerabilitySeverity(models.Model): - reference = models.ForeignKey(VulnerabilityReference, on_delete=models.CASCADE) - - scoring_system_choices = tuple( - (system.identifier, system.name) for system in SCORING_SYSTEMS.values() - ) - - scoring_system = models.CharField( - max_length=50, - choices=scoring_system_choices, - help_text="Identifier for the scoring system used. Available choices are: {} ".format( - ",\n".join(f"{sid}: {sname}" for sid, sname in scoring_system_choices) - ), - ) - - value = models.CharField(max_length=50, help_text="Example: 9.0, Important, High") - - scoring_elements = models.CharField( - max_length=150, - null=True, - help_text="Supporting scoring elements used to compute the score values. " - "For example a CVSS vector string as used to compute a CVSS score.", - ) - - class Meta: - unique_together = ["reference", "scoring_system", "value"] - ordering = ["reference", "scoring_system", "value"] - - -class AliasQuerySet(BaseQuerySet): - def for_cve(self): - """ - Return a queryset of Aliases that are for a CVE. - """ - return self.filter(alias__startswith="CVE") - - -class Alias(models.Model): - """ - An alias is a unique vulnerability identifier in some database, such as - the NVD, PYSEC, CVE or similar. These databases guarantee that these - identifiers are unique within their namespace. - An alias may also be used as a Reference. But in contrast with some - Reference may not be an identifier for a single vulnerability, for instance, - security advisories such as Debian security advisory reference various - vulnerabilities. - """ - - alias = models.CharField( - max_length=50, - unique=True, - blank=False, - null=False, - help_text="An alias is a unique vulnerability identifier in some database, " - "such as CVE-2020-2233", - ) - - vulnerability = models.ForeignKey( - Vulnerability, - on_delete=models.CASCADE, - related_name="aliases", - ) - - objects = AliasQuerySet.as_manager() - - class Meta: - ordering = ["alias"] - - def __str__(self): - return self.alias - - @property - def url(self): - """ - Create a URL for the alias. - """ - alias: str = self.alias - if alias.startswith("CVE"): - return f"https://nvd.nist.gov/vuln/detail/{alias}" - - if alias.startswith("GHSA"): - return f"https://github.com/advisories/{alias}" - - if alias.startswith("NPM-"): - id = alias.lstrip("NPM-") - return f"https://github.com/nodejs/security-wg/blob/main/vuln/npm/{id}.json" - - -class AdvisoryQuerySet(BaseQuerySet): - pass - - -class Advisory(models.Model): - """ - An advisory represents data directly obtained from upstream transformed - into structured data - """ - - unique_content_id = models.CharField( - max_length=32, - blank=True, - ) - aliases = models.JSONField(blank=True, default=list, help_text="A list of alias strings") - summary = models.TextField( - blank=True, - ) - # we use a JSON field here to avoid creating a complete relational model for data that - # is never queried directly; instead it is only retrieved and processed as a whole by - # an improver - affected_packages = models.JSONField( - blank=True, default=list, help_text="A list of serializable AffectedPackage objects" - ) - references = models.JSONField( - blank=True, default=list, help_text="A list of serializable Reference objects" - ) - date_published = models.DateTimeField( - blank=True, null=True, help_text="UTC Date of publication of the advisory" - ) - weaknesses = models.JSONField(blank=True, default=list, help_text="A list of CWE ids") - date_collected = models.DateTimeField(help_text="UTC Date on which the advisory was collected") - date_imported = models.DateTimeField( - blank=True, null=True, help_text="UTC Date on which the advisory was imported" - ) - created_by = models.CharField( - max_length=100, - help_text="Fully qualified name of the importer prefixed with the" - "module name importing the advisory. Eg:" - "vulnerabilities.importers.nginx.NginxImporter", - ) - url = models.URLField( - blank=True, - help_text="Link to the advisory on the upstream website", - ) - - objects = AdvisoryQuerySet.as_manager() - - class Meta: - unique_together = ["aliases", "unique_content_id", "date_published", "url"] - ordering = ["aliases", "date_published", "unique_content_id"] - - def save(self, *args, **kwargs): - checksum = hashlib.md5() - for field in ( - self.summary, - self.affected_packages, - self.references, - self.weaknesses, - ): - value = json.dumps(field, separators=(",", ":")).encode("utf-8") - checksum.update(value) - self.unique_content_id = checksum.hexdigest() - super().save(*args, **kwargs) - - def to_advisory_data(self) -> "AdvisoryData": - from vulnerabilities.importer import AdvisoryData - from vulnerabilities.importer import AffectedPackage - from vulnerabilities.importer import Reference - - return AdvisoryData( - aliases=self.aliases, - summary=self.summary, - affected_packages=[AffectedPackage.from_dict(pkg) for pkg in self.affected_packages], - references=[Reference.from_dict(ref) for ref in self.references], - date_published=self.date_published, - weaknesses=self.weaknesses, - url=self.url, - ) - - -UserModel = get_user_model() - - -class ApiUserManager(UserManager): - def create_api_user(self, username, first_name="", last_name="", **extra_fields): - """ - Create and return an API-only user. Raise ValidationError. - """ - username = self.normalize_email(username) - email = username - self._validate_username(email) - - # note we use the email as username and we could instead override - # django.contrib.auth.models.AbstractUser.USERNAME_FIELD - - user = self.create_user( - username=email, - email=email, - password=None, - first_name=first_name, - last_name=last_name, - **extra_fields, - ) - - # this ensure that this is not a valid password - user.set_unusable_password() - user.save() - - Token._default_manager.get_or_create(user=user) - - return user - - def _validate_username(self, email): - """ - Validate username. If invalid, raise a ValidationError - """ - try: - self.get_by_natural_key(email) - except models.ObjectDoesNotExist: - pass - else: - raise exceptions.ValidationError(f"Error: This email already exists: {email}") - - -class ApiUser(UserModel): - """ - A User proxy model to facilitate simplified admin API user creation. - """ - - objects = ApiUserManager() - - class Meta: - proxy = True - - -class ChangeLog(models.Model): - - action_time = models.DateTimeField( - # check if dates are actually UTC - default=timezone.now, - editable=False, - help_text="Time of the change", - ) - - actor_name = models.CharField( - max_length=100, - help_text="Name of the actor: either a script or username for instance tgoel, NVDImporter or NginxImprover.", - ) - - action_type = models.PositiveSmallIntegerField( - help_text="Action type such as: update, create. Possible values are defined in subclasses" - ) - - source_url = models.URLField( - max_length=1024, - help_text="URL to the source of this change", - blank=True, - null=True, - ) - - software_version = models.CharField( - max_length=100, - help_text="Version of the software at the time of change", - default=VULNERABLECODE_VERSION, - ) - - @property - def get_action_type_label(self): - label_by_status = { - choice_code: choice_label for choice_code, choice_label in self.ACTION_TYPE_CHOICES - } - return label_by_status.get(self.action_type) - - @property - def get_iso_time(self): - return self.action_time.isoformat() - - class Meta: - abstract = True - ordering = ("-action_time",) - - -class VulnerabilityHistoryManager(models.Manager): - def get_for_object(self, vuln, **kwargs): - return self.filter( - vulnerability=vuln, - **kwargs, - ) - - def log_action( - self, - vulnerability, - action_type, - actor_name, - source_url, - ): - """ - Creates a History entry for a given `obj` on Addition, Change, and Deletion. - We do not log addition for object that inherit the HistoryFieldsMixin since - the `created_by` and `created_date` are already set on its model. - """ - return self.model.objects.get_or_create( - vulnerability=vulnerability, - action_type=action_type, - actor_name=actor_name, - source_url=source_url, - software_version=VULNERABLECODE_VERSION, - ) - - -class VulnerabilityChangeLog(ChangeLog): - IMPORT = 1 - IMPROVE = 2 - - ACTION_TYPE_CHOICES = ( - (IMPORT, "Import"), - (IMPROVE, "Improve"), - ) - - vulnerability = models.ForeignKey( - Vulnerability, on_delete=models.CASCADE, related_name="changelog" - ) - - action_type = models.PositiveSmallIntegerField(choices=ACTION_TYPE_CHOICES) - - objects = VulnerabilityHistoryManager() - - @classmethod - def log_import(cls, vulnerability, importer, source_url): - """ - Creates History entry on Addition. - """ - return cls.objects.log_action( - vulnerability=vulnerability, - action_type=VulnerabilityChangeLog.IMPORT, - actor_name=importer, - source_url=source_url, - ) - - @classmethod - def log_improve(cls, vulnerability, improver, source_url): - """ - Creates History entry on Improvement. - """ - return cls.objects.log_action( - vulnerability=vulnerability, - action_type=VulnerabilityChangeLog.IMPROVE, - actor_name=improver, - source_url=source_url, - ) - - -class PackageHistoryManager(models.Manager): - def get_for_object(self, package, **kwargs): - return self.filter( - package=package, - **kwargs, - ) - - def log_action(self, package, action_type, actor_name, source_url, related_vulnerability): - """ - Creates a History entry for a given `obj` on Addition, Change, and Deletion. - We do not log addition for object that inherit the HistoryFieldsMixin since - the `created_by` and `created_date` are already set on its model. - """ - return self.model.objects.get_or_create( - package=package, - action_type=action_type, - actor_name=actor_name, - source_url=source_url, - related_vulnerability=related_vulnerability, - software_version=VULNERABLECODE_VERSION, - ) - - -class PackageChangeLog(ChangeLog): - - AFFECTED_BY = 1 - FIXING = 2 - - ACTION_TYPE_CHOICES = ((AFFECTED_BY, "Affected by"), (FIXING, "Fixing")) - - package = models.ForeignKey(Package, on_delete=models.CASCADE, related_name="changelog") - - # NOTES: We are not using foreign key because this is a log - # that we want to persist in case the VCID is not any more. - - # TODO: We will change to foeign key in future once the models get stable - related_vulnerability = models.CharField( - max_length=1024, - null=True, - blank=True, - help_text="VCID of the vulnerability related to this package", - ) - - action_type = models.PositiveSmallIntegerField(choices=ACTION_TYPE_CHOICES) - - objects = PackageHistoryManager() - - @classmethod - def log_affected_by(cls, package, importer, source_url, related_vulnerability): - """ - Creates History entry on Vulnerabilitty affects package. - """ - return cls.objects.log_action( - package=package, - action_type=PackageChangeLog.AFFECTED_BY, - actor_name=importer, - source_url=source_url, - related_vulnerability=related_vulnerability, - ) - - @classmethod - def log_fixing(cls, package, importer, source_url, related_vulnerability): - """ - Creates History entry on Vulnerability is fixed by package. - """ - return cls.objects.log_action( - package=package, - action_type=PackageChangeLog.FIXING, - actor_name=importer, - source_url=source_url, - related_vulnerability=related_vulnerability, - ) +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +import hashlib +import json +import logging +from contextlib import suppress +from typing import Any + +from cwe2.database import Database +from django.contrib.auth import get_user_model +from django.contrib.auth.models import UserManager +from django.core import exceptions +from django.core.exceptions import ValidationError +from django.core.paginator import Paginator +from django.core.validators import MaxValueValidator +from django.core.validators import MinValueValidator +from django.db import models +from django.db import transaction +from django.db.models import Count +from django.db.models import Prefetch +from django.db.models import Q +from django.db.models.functions import Length +from django.db.models.functions import Trim +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from packageurl import PackageURL +from packageurl.contrib.django.models import PackageURLMixin +from packageurl.contrib.django.models import PackageURLQuerySet +from rest_framework.authtoken.models import Token +from univers import versions +from univers.version_range import RANGE_CLASS_BY_SCHEMES +from univers.version_range import AlpineLinuxVersionRange +from univers.versions import Version + +from vulnerabilities import utils +from vulnerabilities.severity_systems import SCORING_SYSTEMS +from vulnerabilities.utils import normalize_purl +from vulnerabilities.utils import purl_to_dict +from vulnerablecode import __version__ as VULNERABLECODE_VERSION + +logger = logging.getLogger(__name__) + +models.CharField.register_lookup(Length) +models.CharField.register_lookup(Trim) + + +class BaseQuerySet(models.QuerySet): + def get_or_none(self, *args, **kwargs): + """ + Returns a single object matching the given keyword arguments, `None` otherwise. + """ + with suppress(self.model.DoesNotExist, ValidationError): + return self.get(*args, **kwargs) + + def paginated(self, per_page=5000): + """ + Iterate over a (large) QuerySet by chunks of ``per_page`` items. + This technique is essential for preventing memory issues when iterating + See these links for inspiration: + https://nextlinklabs.com/resources/insights/django-big-data-iteration + https://stackoverflow.com/questions/4222176/why-is-iterating-through-a-large-django-queryset-consuming-massive-amounts-of-me/ + """ + paginator = Paginator(self, per_page=per_page) + for page_number in paginator.page_range: + page = paginator.page(page_number) + for object in page.object_list: + yield object + + +class VulnerabilityQuerySet(BaseQuerySet): + def affecting_vulnerabilities(self): + """ + Return a queryset of Vulnerability that affect a package. + """ + return self.filter(packagerelatedvulnerability__fix=False) + + def with_cpes(self): + """ + Return a queryset of Vulnerability that have one or more NVD CPE references. + """ + return self.filter(vulnerabilityreference__reference_id__startswith="cpe") + + def for_cpe(self, cpe): + """ + Return a queryset of Vulnerability that have the ``cpe`` as an NVD CPE reference. + """ + return self.filter(vulnerabilityreference__reference_id__exact=cpe) + + def with_cves(self): + """ + Return a queryset of Vulnerability that have one or more NVD CVE aliases. + """ + return self.filter(aliases__alias__startswith="CVE") + + def for_cve(self, cve): + """ + Return a queryset of Vulnerability that have the the NVD CVE ``cve`` as an alias. + """ + return self.filter(vulnerabilityreference__reference_id__exact=cve) + + def with_packages(self): + """ + Return a queryset of Vulnerability that have one or more related packages. + """ + return self.filter(packages__isnull=False) + + def search(self, query: str = None): + """ + Return a Vulnerability queryset searching for the ``query``. + Make a best effort approach to search a vulnerability using various heuristics. + """ + + query = query and query.strip() + if not query: + return self.none() + + qs = self + + # middle ground, exact on vulnerability_id + qssearch = qs.filter(vulnerability_id=query) + if not qssearch.exists(): + # middle ground, exact on alias + qssearch = qs.filter(aliases__alias=query) + if not qssearch.exists(): + # middle ground, slow enough + qssearch = qs.filter( + Q(vulnerability_id__icontains=query) | Q(aliases__alias__icontains=query) + ) + if not qssearch.exists(): + # last resort super slow + qssearch = qs.filter( + Q(references__id__icontains=query) | Q(summary__icontains=query) + ) + + return qssearch.order_by("vulnerability_id") + + def with_package_counts(self): + return self.annotate( + vulnerable_package_count=Count( + "packages", filter=Q(packagerelatedvulnerability__fix=False), distinct=True + ), + patched_package_count=Count( + "packages", filter=Q(packagerelatedvulnerability__fix=True), distinct=True + ), + ) + + +class VulnerabilityStatusType(models.IntegerChoices): + """List of vulnerability statuses.""" + + PUBLISHED = 1, "Published" + DISPUTED = 2, "Disputed" + INVALID = 3, "Invalid" + + +class Vulnerability(models.Model): + """ + A software vulnerability with a unique identifier and alternate ``aliases``. + """ + + vulnerability_id = models.CharField( + unique=True, + blank=True, + max_length=20, + default=utils.build_vcid, + help_text="Unique identifier for a vulnerability in the external representation. " + "It is prefixed with VCID-", + ) + + summary = models.TextField( + help_text="Summary of the vulnerability", + blank=True, + ) + + references = models.ManyToManyField( + to="VulnerabilityReference", through="VulnerabilityRelatedReference" + ) + + packages = models.ManyToManyField( + to="Package", + through="PackageRelatedVulnerability", + ) + + status = models.IntegerField( + choices=VulnerabilityStatusType.choices, default=VulnerabilityStatusType.PUBLISHED + ) + + objects = VulnerabilityQuerySet.as_manager() + + class Meta: + verbose_name_plural = "Vulnerabilities" + ordering = ["vulnerability_id"] + + def __str__(self): + return self.vulnerability_id + + @property + def vcid(self): + return self.vulnerability_id + + @property + def severities(self): + """ + Return a queryset of VulnerabilitySeverity for this vulnerability. + """ + return VulnerabilitySeverity.objects.filter(reference__in=self.references.all()) + + @property + def affected_packages(self): + """ + Return a queryset of packages that are affected by this vulnerability. + """ + return self.packages.affected() + + # legacy aliases + vulnerable_packages = affected_packages + + @property + def fixed_by_packages(self): + """ + Return a queryset of packages that are fixing this vulnerability. + """ + return self.packages.fixing() + + # legacy alias + patched_packages = fixed_by_packages + + @property + def get_aliases(self): + """ + Return a queryset of all Aliases for this vulnerability. + """ + return self.aliases.all() + + alias = get_aliases + + @property + def get_status_label(self): + label_by_status = {choice[0]: choice[1] for choice in VulnerabilityStatusType.choices} + return label_by_status.get(self.status) or VulnerabilityStatusType.PUBLISHED.label + + @property + def history(self): + return self.changelog.all() + + def get_absolute_url(self): + """ + Return this Vulnerability details absolute URL. + """ + return reverse("vulnerability_details", args=[self.vulnerability_id]) + + def get_details_url(self, request): + """ + Return this Package details URL. + """ + from rest_framework.reverse import reverse + + return reverse( + "vulnerability_details", + kwargs={"vulnerability_id": self.vulnerability_id}, + request=request, + ) + + def get_related_cpes(self): + """ + Return a list of CPE strings of this vulnerability. + """ + return list(self.references.for_cpe().values_list("reference_id", flat=True).distinct()) + + def get_related_cves(self): + """ + Return a list of aliases CVE strings of this vulnerability. + """ + return list(self.aliases.for_cve().values_list("alias", flat=True).distinct()) + + def get_affected_purls(self): + """ + Return a list of purl strings affected by this vulnerability. + """ + return [p.package_url for p in self.affected_packages.all()] + + def get_fixing_purls(self): + """ + Return a list of purl strings fixing this vulnerability. + """ + return [p.package_url for p in self.fixed_by_packages.all()] + + def get_related_purls(self): + """ + Return a list of purl strings related to this vulnerability. + """ + return [p.package_url for p in self.packages.distinct().all()] + + +class Weakness(models.Model): + """ + A Common Weakness Enumeration model + """ + + cwe_id = models.IntegerField(help_text="CWE id") + vulnerabilities = models.ManyToManyField(Vulnerability, related_name="weaknesses") + db = Database() + + @property + def weakness(self): + """ + Return a queryset of Weakness for this vulnerability. + """ + try: + weakness = self.db.get(self.cwe_id) + return weakness + except Exception as e: + logger.warning(f"Could not find CWE {self.cwe_id}: {e}") + + @property + def name(self): + """Return the weakness's name.""" + return self.weakness.name if self.weakness else "" + + @property + def description(self): + """Return the weakness's description.""" + return self.weakness.description if self.weakness else "" + + def to_dict(self): + return {"cwe_id": self.cwe_id, "name": self.name, "description": self.description} + + +class VulnerabilityReferenceQuerySet(BaseQuerySet): + def for_cpe(self): + """ + Return a queryset of VulnerabilityReferences that are for a CPE. + """ + return self.filter(reference_id__startswith="cpe") + + +class VulnerabilityReference(models.Model): + """ + A reference to a vulnerability such as a security advisory from a Linux distribution or language + package manager. + """ + + vulnerabilities = models.ManyToManyField( + to="Vulnerability", + through="VulnerabilityRelatedReference", + ) + + url = models.URLField( + max_length=1024, + help_text="URL to the vulnerability reference", + unique=True, + ) + + reference_id = models.CharField( + max_length=200, + help_text="An optional reference ID, such as DSA-4465-1 when available", + blank=True, + ) + + objects = VulnerabilityReferenceQuerySet.as_manager() + + class Meta: + ordering = ["reference_id", "url"] + + def __str__(self): + reference_id = f" {self.reference_id}" if self.reference_id else "" + return f"{self.url}{reference_id}" + + @property + def is_cpe(self): + """ + Return Trueis this is a CPE reference. + """ + return self.reference_id.startswith("cpe") + + +class VulnerabilityRelatedReference(models.Model): + """ + A reference related to a vulnerability. + """ + + vulnerability = models.ForeignKey( + Vulnerability, + on_delete=models.CASCADE, + ) + + reference = models.ForeignKey( + VulnerabilityReference, + on_delete=models.CASCADE, + ) + + class Meta: + unique_together = ["vulnerability", "reference"] + ordering = ["vulnerability", "reference"] + + +class PackageQuerySet(BaseQuerySet, PackageURLQuerySet): + def get_fixed_by_package_versions(self, purl: PackageURL, fix=True): + """ + Return a queryset of all the package versions of this `package` that fix any vulnerability. + If `fix` is False, return all package versions whether or not they fix a vulnerability. + """ + filter_dict = { + "name": purl.name, + "namespace": purl.namespace, + "type": purl.type, + "qualifiers": purl.qualifiers, + "subpath": purl.subpath, + } + + if fix: + filter_dict["packagerelatedvulnerability__fix"] = True + + return Package.objects.filter(**filter_dict).distinct() + + def get_or_create_from_purl(self, purl: PackageURL): + """ + Return an existing or new Package (created if neeed) given a + ``purl`` PackageURL. + """ + if isinstance(purl, str): + purl = PackageURL.from_string(purl) + + package, is_created = Package.objects.get_or_create(**purl_to_dict(purl=purl)) + + return package, is_created + + def affected(self): + """ + Return only packages affected by a vulnerability. + """ + return self.filter(packagerelatedvulnerability__fix=False) + + vulnerable = affected + + def fixing(self): + """ + Return only packages fixing a vulnerability . + """ + return self.filter(packagerelatedvulnerability__fix=True) + + def with_vulnerability_counts(self): + return self.annotate( + vulnerability_count=Count( + "vulnerabilities", + filter=Q(packagerelatedvulnerability__fix=False), + ), + patched_vulnerability_count=Count( + "vulnerabilities", + filter=Q(packagerelatedvulnerability__fix=True), + ), + ) + + def fixing_packages(self, package, with_qualifiers_and_subpath=True): + """ + Return a queryset of packages that are fixing the vulnerability of + ``package``. + """ + + return self.match_purl( + purl=package.purl, + with_qualifiers_and_subpath=with_qualifiers_and_subpath, + ).fixing() + + def search(self, query: str = None): + """ + Return a Package queryset searching for the ``query``. + Make a best effort approach to find matching packages either based + on exact purl, partial purl or just name and namespace. + """ + query = query and query.strip() + if not query: + return self.none() + qs = self + + try: + # if it's a valid purl, try to parse it and use it as is + purl = str(utils.plain_purl(query)) + qs = qs.filter(package_url__istartswith=purl) + except ValueError: + # otherwise use query as a plain string + qs = qs.filter(package_url__icontains=query) + return qs.order_by("package_url") + + def for_purl(self, purl): + """ + Return a queryset matching the ``purl`` Package URL. + """ + return self.filter(**purl_to_dict(purl)) + + def with_cpes(self): + """ + Return a queryset of Package that a vulnerability with one or more NVD CPE references. + """ + return self.filter(vulnerabilities__vulnerabilityreference__reference_id__startswith="cpe") + + def for_cpe(self, cpe): + """ + Return a queryset of Vulnerability that have the ``cpe`` as an NVD CPE reference. + """ + return self.filter(vulnerabilities__vulnerabilityreference__reference_id__exact=cpe) + + def with_cves(self): + """ + Return a queryset of Vulnerability that have one or more NVD CVE aliases. + """ + return self.filter(vulnerabilities__aliases__alias__startswith="CVE") + + def for_cve(self, cve): + """ + Return a queryset of Vulnerability that have the the NVD CVE ``cve`` as an alias. + """ + return self.filter(vulnerabilities__vulnerabilityreference__reference_id__exact=cve) + + def for_purls(self, purls=[]): + return Package.objects.filter(package_url__in=purls).distinct() + + +def get_purl_query_lookups(purl): + """ + Return a dictionary of non-empty plain purl fields + Do not reference all the possible qualifiers and relax the + purl matching to only lookup the type, namespace, name and version fields. + """ + plain_purl = utils.plain_purl(purl=purl) + return purl_to_dict(plain_purl, with_empty=False) + + +class Package(PackageURLMixin): + """ + A software package with related vulnerabilities. + """ + + # Remove the `qualifers` and `set_package_url` overrides after + # https://github.com/package-url/packageurl-python/pull/35 + # https://github.com/package-url/packageurl-python/pull/67 + # gets merged + + vulnerabilities = models.ManyToManyField( + to="Vulnerability", through="PackageRelatedVulnerability" + ) + + package_url = models.CharField( + max_length=1000, + null=False, + help_text="The Package URL for this package.", + db_index=True, + ) + + plain_package_url = models.CharField( + max_length=1000, + null=False, + help_text="The Package URL for this package without qualifiers and subpath.", + db_index=True, + ) + + objects = PackageQuerySet.as_manager() + + def save(self, *args, **kwargs): + """ + Save, normalizing PURL fields. + """ + purl = PackageURL( + type=self.type, + namespace=self.namespace, + name=self.name, + version=self.version, + qualifiers=self.qualifiers, + subpath=self.subpath, + ) + + # We re-parse the purl to ensure name and namespace + # are set correctly + normalized = normalize_purl(purl=purl) + + for name, value in purl_to_dict(normalized).items(): + setattr(self, name, value) + + self.package_url = str(normalized) + plain_purl = utils.plain_purl(normalized) + self.plain_package_url = str(plain_purl) + super().save(*args, **kwargs) + + @property + def purl(self): + return self.package_url + + class Meta: + unique_together = ["type", "namespace", "name", "version", "qualifiers", "subpath"] + ordering = ["type", "namespace", "name", "version", "qualifiers", "subpath"] + + def __str__(self): + return self.package_url + + @property + def affected_by(self): + """ + Return a queryset of vulnerabilities affecting this package. + """ + return self.vulnerabilities.filter(packagerelatedvulnerability__fix=False) + + # legacy aliases + vulnerable_to = affected_by + + @property + # TODO: consider renaming to "fixes" or "fixing" ? (TBD) and updating the docstring + def fixing(self): + """ + Return a queryset of vulnerabilities fixed by this package. + """ + return self.vulnerabilities.filter(packagerelatedvulnerability__fix=True) + + # legacy aliases + resolved_to = fixing + + @property + def fixed_packages(self): + """ + Return a queryset of packages that are fixed. + """ + return Package.objects.fixing_packages(package=self).distinct() + + @property + def is_vulnerable(self) -> bool: + """ + Returns True if this package is vulnerable to any vulnerability. + """ + return self.affected_by.exists() + + @property + def history(self): + return self.changelog.all() + + def get_absolute_url(self): + """ + Return this Package details URL. + """ + return reverse("package_details", args=[self.purl]) + + def get_details_url(self, request): + """ + Return this Package details URL. + """ + from rest_framework.reverse import reverse + + return reverse("package_details", kwargs={"purl": self.purl}, request=request) + + def sort_by_version(self, packages): + """ + Return a list of `packages` sorted by version. + """ + if not packages: + return [] + + return sorted( + packages, + key=lambda x: self.version_class(x.version), + ) + + @property + def version_class(self): + RANGE_CLASS_BY_SCHEMES["alpine"] = AlpineLinuxVersionRange + range_class = RANGE_CLASS_BY_SCHEMES.get(self.type) + if not range_class: + return Version + return range_class.version_class + + @property + def current_version(self): + return self.version_class(self.version) + + @property + def fixed_package_details(self): + """ + Return a mapping of vulnerabilities that affect this package and the next and + latest non-vulnerable versions. + """ + package_details = {} + package_details["purl"] = PackageURL.from_string(self.purl) + + next_non_vulnerable, latest_non_vulnerable = self.get_non_vulnerable_versions() + package_details["next_non_vulnerable"] = next_non_vulnerable + package_details["latest_non_vulnerable"] = latest_non_vulnerable + + package_details["vulnerabilities"] = self.get_affecting_vulnerabilities() + + return package_details + + def get_non_vulnerable_versions(self): + """ + Return a tuple of the next and latest non-vulnerable versions as PackageURLs. Return a tuple of + (None, None) if there is no non-vulnerable version. + """ + package_versions = Package.objects.get_fixed_by_package_versions(self, fix=False) + + non_vulnerable_versions = [] + for version in package_versions: + if not version.is_vulnerable: + non_vulnerable_versions.append(version) + + later_non_vulnerable_versions = [] + for non_vuln_ver in non_vulnerable_versions: + if self.version_class(non_vuln_ver.version) > self.current_version: + later_non_vulnerable_versions.append(non_vuln_ver) + + if later_non_vulnerable_versions: + sorted_versions = self.sort_by_version(later_non_vulnerable_versions) + next_non_vulnerable_version = sorted_versions[0] + latest_non_vulnerable_version = sorted_versions[-1] + + next_non_vulnerable = PackageURL.from_string(next_non_vulnerable_version.purl) + latest_non_vulnerable = PackageURL.from_string(latest_non_vulnerable_version.purl) + + return next_non_vulnerable, latest_non_vulnerable + + return None, None + + def get_affecting_vulnerabilities(self): + """ + Return a list of vulnerabilities that affect this package together with information regarding + the versions that fix the vulnerabilities. + """ + package_details_vulns = [] + + fixed_by_packages = Package.objects.get_fixed_by_package_versions(self, fix=True) + + package_vulnerabilities = self.vulnerabilities.affecting_vulnerabilities().prefetch_related( + Prefetch( + "packages", + queryset=fixed_by_packages, + to_attr="fixed_packages", + ) + ) + + for vuln in package_vulnerabilities: + package_details_vulns.append({"vulnerability": vuln}) + later_fixed_packages = [] + + for fixed_pkg in vuln.fixed_packages: + if fixed_pkg not in fixed_by_packages: + continue + fixed_version = self.version_class(fixed_pkg.version) + if fixed_version > self.current_version: + later_fixed_packages.append(fixed_pkg) + + next_fixed_package = None + next_fixed_package_vulns = [] + + sort_fixed_by_packages_by_version = [] + if later_fixed_packages: + sort_fixed_by_packages_by_version = self.sort_by_version(later_fixed_packages) + + fixed_by_pkgs = [] + + for vuln_details in package_details_vulns: + if vuln_details["vulnerability"] != vuln: + continue + vuln_details["fixed_by_purl"] = [] + vuln_details["fixed_by_purl_vulnerabilities"] = [] + + for fixed_by_pkg in sort_fixed_by_packages_by_version: + fixed_by_package_details = {} + fixed_by_purl = PackageURL.from_string(fixed_by_pkg.purl) + next_fixed_package_vulns = list(fixed_by_pkg.affected_by) + + fixed_by_package_details["fixed_by_purl"] = fixed_by_purl + fixed_by_package_details[ + "fixed_by_purl_vulnerabilities" + ] = next_fixed_package_vulns + fixed_by_pkgs.append(fixed_by_package_details) + + vuln_details["fixed_by_package_details"] = fixed_by_pkgs + + return package_details_vulns + + @property + def fixing_vulnerabilities(self): + """ + Return a queryset of Vulnerabilities that are fixed by this `package`. + """ + return self.vulnerabilities.filter(packagerelatedvulnerability__fix=True) + + @property + def affecting_vulnerabilities(self): + """ + Return a queryset of Vulnerabilities that affect this `package`. + """ + return self.vulnerabilities.filter(packagerelatedvulnerability__fix=False) + + +class PackageRelatedVulnerability(models.Model): + """ + Track the relationship between a Package and Vulnerability. + """ + + # TODO: Fix related_name + package = models.ForeignKey( + Package, + on_delete=models.CASCADE, + ) + + vulnerability = models.ForeignKey( + Vulnerability, + on_delete=models.CASCADE, + ) + + created_by = models.CharField( + max_length=100, + blank=True, + help_text="Fully qualified name of the improver prefixed with the" + "module name responsible for creating this relation. Eg:" + "vulnerabilities.importers.nginx.NginxBasicImprover", + ) + from vulnerabilities.improver import MAX_CONFIDENCE + + confidence = models.PositiveIntegerField( + default=MAX_CONFIDENCE, + validators=[MinValueValidator(0), MaxValueValidator(MAX_CONFIDENCE)], + help_text="Confidence score for this relation", + ) + + fix = models.BooleanField( + default=False, + db_index=True, + help_text="Does this relation fix the specified vulnerability ?", + ) + + class Meta: + unique_together = ["package", "vulnerability"] + verbose_name_plural = "PackageRelatedVulnerabilities" + indexes = [models.Index(fields=["fix"])] + ordering = ["package", "vulnerability"] + + def __str__(self): + return f"{self.package.package_url} {self.vulnerability.vulnerability_id}" + + def update_or_create(self, advisory): + """ + Update if supplied record has more confidence than existing record + Create if doesn't exist + """ + try: + existing = PackageRelatedVulnerability.objects.get( + vulnerability=self.vulnerability, package=self.package + ) + if self.confidence > existing.confidence: + existing.created_by = self.created_by + existing.confidence = self.confidence + existing.fix = self.fix + existing.save() + # TODO: later we want these to be part of a log field in the DB + logger.info( + f"Confidence improved for {self.package} R {self.vulnerability}, " + f"new confidence: {self.confidence}" + ) + self.add_package_vulnerability_changelog(advisory=advisory) + + except self.DoesNotExist: + PackageRelatedVulnerability.objects.create( + vulnerability=self.vulnerability, + created_by=self.created_by, + package=self.package, + confidence=self.confidence, + fix=self.fix, + ) + + logger.info( + f"New relationship {self.package} R {self.vulnerability}, " + f"fix: {self.fix}, confidence: {self.confidence}" + ) + + self.add_package_vulnerability_changelog(advisory=advisory) + + @transaction.atomic + def add_package_vulnerability_changelog(self, advisory): + from vulnerabilities.utils import get_importer_name + + importer_name = get_importer_name(advisory) + if self.fix: + change_logger = PackageChangeLog.log_fixing + else: + change_logger = PackageChangeLog.log_affected_by + change_logger( + package=self.package, + importer=importer_name, + source_url=advisory.url or None, + related_vulnerability=str(self.vulnerability), + ) + + +class VulnerabilitySeverity(models.Model): + reference = models.ForeignKey(VulnerabilityReference, on_delete=models.CASCADE) + + scoring_system_choices = tuple( + (system.identifier, system.name) for system in SCORING_SYSTEMS.values() + ) + + scoring_system = models.CharField( + max_length=50, + choices=scoring_system_choices, + help_text="Identifier for the scoring system used. Available choices are: {} ".format( + ",\n".join(f"{sid}: {sname}" for sid, sname in scoring_system_choices) + ), + ) + + value = models.CharField(max_length=50, help_text="Example: 9.0, Important, High") + + scoring_elements = models.CharField( + max_length=150, + null=True, + help_text="Supporting scoring elements used to compute the score values. " + "For example a CVSS vector string as used to compute a CVSS score.", + ) + + class Meta: + unique_together = ["reference", "scoring_system", "value"] + ordering = ["reference", "scoring_system", "value"] + + +class AliasQuerySet(BaseQuerySet): + def for_cve(self): + """ + Return a queryset of Aliases that are for a CVE. + """ + return self.filter(alias__startswith="CVE") + + +class Alias(models.Model): + """ + An alias is a unique vulnerability identifier in some database, such as + the NVD, PYSEC, CVE or similar. These databases guarantee that these + identifiers are unique within their namespace. + An alias may also be used as a Reference. But in contrast with some + Reference may not be an identifier for a single vulnerability, for instance, + security advisories such as Debian security advisory reference various + vulnerabilities. + """ + + alias = models.CharField( + max_length=50, + unique=True, + blank=False, + null=False, + help_text="An alias is a unique vulnerability identifier in some database, " + "such as CVE-2020-2233", + ) + + vulnerability = models.ForeignKey( + Vulnerability, + on_delete=models.CASCADE, + related_name="aliases", + ) + + objects = AliasQuerySet.as_manager() + + class Meta: + ordering = ["alias"] + + def __str__(self): + return self.alias + + @property + def url(self): + """ + Create a URL for the alias. + """ + alias: str = self.alias + if alias.startswith("CVE"): + return f"https://nvd.nist.gov/vuln/detail/{alias}" + + if alias.startswith("GHSA"): + return f"https://github.com/advisories/{alias}" + + if alias.startswith("NPM-"): + id = alias.lstrip("NPM-") + return f"https://github.com/nodejs/security-wg/blob/main/vuln/npm/{id}.json" + + +class AdvisoryQuerySet(BaseQuerySet): + pass + + +class Advisory(models.Model): + """ + An advisory represents data directly obtained from upstream transformed + into structured data + """ + + unique_content_id = models.CharField( + max_length=32, + blank=True, + ) + aliases = models.JSONField(blank=True, default=list, help_text="A list of alias strings") + summary = models.TextField( + blank=True, + ) + # we use a JSON field here to avoid creating a complete relational model for data that + # is never queried directly; instead it is only retrieved and processed as a whole by + # an improver + affected_packages = models.JSONField( + blank=True, default=list, help_text="A list of serializable AffectedPackage objects" + ) + references = models.JSONField( + blank=True, default=list, help_text="A list of serializable Reference objects" + ) + date_published = models.DateTimeField( + blank=True, null=True, help_text="UTC Date of publication of the advisory" + ) + weaknesses = models.JSONField(blank=True, default=list, help_text="A list of CWE ids") + date_collected = models.DateTimeField(help_text="UTC Date on which the advisory was collected") + date_imported = models.DateTimeField( + blank=True, null=True, help_text="UTC Date on which the advisory was imported" + ) + created_by = models.CharField( + max_length=100, + help_text="Fully qualified name of the importer prefixed with the" + "module name importing the advisory. Eg:" + "vulnerabilities.importers.nginx.NginxImporter", + ) + url = models.URLField( + blank=True, + help_text="Link to the advisory on the upstream website", + ) + + objects = AdvisoryQuerySet.as_manager() + + class Meta: + unique_together = ["aliases", "unique_content_id", "date_published", "url"] + ordering = ["aliases", "date_published", "unique_content_id"] + + def save(self, *args, **kwargs): + checksum = hashlib.md5() + for field in ( + self.summary, + self.affected_packages, + self.references, + self.weaknesses, + ): + value = json.dumps(field, separators=(",", ":")).encode("utf-8") + checksum.update(value) + self.unique_content_id = checksum.hexdigest() + super().save(*args, **kwargs) + + def to_advisory_data(self) -> "AdvisoryData": + from vulnerabilities.importer import AdvisoryData + from vulnerabilities.importer import AffectedPackage + from vulnerabilities.importer import Reference + + return AdvisoryData( + aliases=self.aliases, + summary=self.summary, + affected_packages=[AffectedPackage.from_dict(pkg) for pkg in self.affected_packages], + references=[Reference.from_dict(ref) for ref in self.references], + date_published=self.date_published, + weaknesses=self.weaknesses, + url=self.url, + ) + + +UserModel = get_user_model() + + +class ApiUserManager(UserManager): + def create_api_user(self, username, first_name="", last_name="", **extra_fields): + """ + Create and return an API-only user. Raise ValidationError. + """ + username = self.normalize_email(username) + email = username + self._validate_username(email) + + # note we use the email as username and we could instead override + # django.contrib.auth.models.AbstractUser.USERNAME_FIELD + + user = self.create_user( + username=email, + email=email, + password=None, + first_name=first_name, + last_name=last_name, + **extra_fields, + ) + + # this ensure that this is not a valid password + user.set_unusable_password() + user.save() + + Token._default_manager.get_or_create(user=user) + + return user + + def _validate_username(self, email): + """ + Validate username. If invalid, raise a ValidationError + """ + try: + self.get_by_natural_key(email) + except models.ObjectDoesNotExist: + pass + else: + raise exceptions.ValidationError(f"Error: This email already exists: {email}") + + +class ApiUser(UserModel): + """ + A User proxy model to facilitate simplified admin API user creation. + """ + + objects = ApiUserManager() + + class Meta: + proxy = True + + +class ChangeLog(models.Model): + + action_time = models.DateTimeField( + # check if dates are actually UTC + default=timezone.now, + editable=False, + help_text="Time of the change", + ) + + actor_name = models.CharField( + max_length=100, + help_text="Name of the actor: either a script or username for instance tgoel, NVDImporter or NginxImprover.", + ) + + action_type = models.PositiveSmallIntegerField( + help_text="Action type such as: update, create. Possible values are defined in subclasses" + ) + + source_url = models.URLField( + max_length=1024, + help_text="URL to the source of this change", + blank=True, + null=True, + ) + + software_version = models.CharField( + max_length=100, + help_text="Version of the software at the time of change", + default=VULNERABLECODE_VERSION, + ) + + @property + def get_action_type_label(self): + label_by_status = { + choice_code: choice_label for choice_code, choice_label in self.ACTION_TYPE_CHOICES + } + return label_by_status.get(self.action_type) + + @property + def get_iso_time(self): + return self.action_time.isoformat() + + class Meta: + abstract = True + ordering = ("-action_time",) + + +class VulnerabilityHistoryManager(models.Manager): + def get_for_object(self, vuln, **kwargs): + return self.filter( + vulnerability=vuln, + **kwargs, + ) + + def log_action( + self, + vulnerability, + action_type, + actor_name, + source_url, + ): + """ + Creates a History entry for a given `obj` on Addition, Change, and Deletion. + We do not log addition for object that inherit the HistoryFieldsMixin since + the `created_by` and `created_date` are already set on its model. + """ + return self.model.objects.get_or_create( + vulnerability=vulnerability, + action_type=action_type, + actor_name=actor_name, + source_url=source_url, + software_version=VULNERABLECODE_VERSION, + ) + + +class VulnerabilityChangeLog(ChangeLog): + IMPORT = 1 + IMPROVE = 2 + + ACTION_TYPE_CHOICES = ( + (IMPORT, "Import"), + (IMPROVE, "Improve"), + ) + + vulnerability = models.ForeignKey( + Vulnerability, on_delete=models.CASCADE, related_name="changelog" + ) + + action_type = models.PositiveSmallIntegerField(choices=ACTION_TYPE_CHOICES) + + objects = VulnerabilityHistoryManager() + + @classmethod + def log_import(cls, vulnerability, importer, source_url): + """ + Creates History entry on Addition. + """ + return cls.objects.log_action( + vulnerability=vulnerability, + action_type=VulnerabilityChangeLog.IMPORT, + actor_name=importer, + source_url=source_url, + ) + + @classmethod + def log_improve(cls, vulnerability, improver, source_url): + """ + Creates History entry on Improvement. + """ + return cls.objects.log_action( + vulnerability=vulnerability, + action_type=VulnerabilityChangeLog.IMPROVE, + actor_name=improver, + source_url=source_url, + ) + + +class PackageHistoryManager(models.Manager): + def get_for_object(self, package, **kwargs): + return self.filter( + package=package, + **kwargs, + ) + + def log_action(self, package, action_type, actor_name, source_url, related_vulnerability): + """ + Creates a History entry for a given `obj` on Addition, Change, and Deletion. + We do not log addition for object that inherit the HistoryFieldsMixin since + the `created_by` and `created_date` are already set on its model. + """ + return self.model.objects.get_or_create( + package=package, + action_type=action_type, + actor_name=actor_name, + source_url=source_url, + related_vulnerability=related_vulnerability, + software_version=VULNERABLECODE_VERSION, + ) + + +class PackageChangeLog(ChangeLog): + + AFFECTED_BY = 1 + FIXING = 2 + + ACTION_TYPE_CHOICES = ((AFFECTED_BY, "Affected by"), (FIXING, "Fixing")) + + package = models.ForeignKey(Package, on_delete=models.CASCADE, related_name="changelog") + + # NOTES: We are not using foreign key because this is a log + # that we want to persist in case the VCID is not any more. + + # TODO: We will change to foeign key in future once the models get stable + related_vulnerability = models.CharField( + max_length=1024, + null=True, + blank=True, + help_text="VCID of the vulnerability related to this package", + ) + + action_type = models.PositiveSmallIntegerField(choices=ACTION_TYPE_CHOICES) + + objects = PackageHistoryManager() + + @classmethod + def log_affected_by(cls, package, importer, source_url, related_vulnerability): + """ + Creates History entry on Vulnerabilitty affects package. + """ + return cls.objects.log_action( + package=package, + action_type=PackageChangeLog.AFFECTED_BY, + actor_name=importer, + source_url=source_url, + related_vulnerability=related_vulnerability, + ) + + @classmethod + def log_fixing(cls, package, importer, source_url, related_vulnerability): + """ + Creates History entry on Vulnerability is fixed by package. + """ + return cls.objects.log_action( + package=package, + action_type=PackageChangeLog.FIXING, + actor_name=importer, + source_url=source_url, + related_vulnerability=related_vulnerability, + ) diff --git a/vulnerabilities/tests/test_api.py b/vulnerabilities/tests/test_api.py index 197707a01..f43fa9ba8 100644 --- a/vulnerabilities/tests/test_api.py +++ b/vulnerabilities/tests/test_api.py @@ -26,9 +26,11 @@ from vulnerabilities.models import Package from vulnerabilities.models import PackageRelatedVulnerability from vulnerabilities.models import Vulnerability +from vulnerabilities.models import Advisory from vulnerabilities.models import VulnerabilityReference from vulnerabilities.models import VulnerabilityRelatedReference from vulnerabilities.models import Weakness +from datetime import datetime BASE_DIR = os.path.dirname(os.path.abspath(__file__)) TEST_DATA = os.path.join(BASE_DIR, "test_data") @@ -193,6 +195,7 @@ def setUp(self): summary=str(i), ) self.vulnerability = Vulnerability.objects.create(summary="test") + self.alias = Alias.objects.create(alias='CORE-2010-0121',vulnerability=self.vulnerability) self.pkg1 = Package.objects.create(name="flask", type="pypi", version="0.1.2") self.pkg2 = Package.objects.create(name="flask", type="deb", version="0.1.2") for pkg in [self.pkg1, self.pkg2]: @@ -200,6 +203,7 @@ def setUp(self): package=pkg, vulnerability=self.vulnerability, fix=True ) self.weaknesses = Weakness.objects.create(cwe_id=119) + self.advisory = Advisory.objects.create(unique_content_id='6b7d417a552b19f26a5c2267ba7876c2',aliases=['CORE-2010-0121'],summary='Vulnerabilities with Windows 8.3 filename pseudonyms',affected_packages=[],url= 'https://nginx.org/en/security_advisories.html',date_collected=datetime.now(),created_by='vulnerabilities.importers.nginx.NginxImporter') self.weaknesses.vulnerabilities.add(self.vulnerability) self.invalid_weaknesses = Weakness.objects.create( cwe_id=10000 @@ -223,7 +227,7 @@ def test_api_with_single_vulnerability(self): "url": f"http://testserver/api/vulnerabilities/{self.vulnerability.id}", "vulnerability_id": self.vulnerability.vulnerability_id, "summary": "test", - "aliases": [], + "aliases": [OrderedDict([('alias', 'CORE-2010-0121')])], "resource_url": f"http://testserver/vulnerabilities/{self.vulnerability.vulnerability_id}", "fixed_packages": [ { @@ -250,6 +254,13 @@ def test_api_with_single_vulnerability(self): "description": "The software performs operations on a memory buffer, but it can read from or write to a memory location that is outside of the intended boundary of the buffer.", }, ], + "advisory": [ + { + "unique_content_id": "6b7d417a552b19f26a5c2267ba7876c2", + "url": "https://nginx.org/en/security_advisories.html", + "summary": "Vulnerabilities with Windows 8.3 filename pseudonyms" + } + ] } def test_api_with_single_vulnerability_with_filters(self): @@ -260,7 +271,7 @@ def test_api_with_single_vulnerability_with_filters(self): "url": f"http://testserver/api/vulnerabilities/{self.vulnerability.id}", "vulnerability_id": self.vulnerability.vulnerability_id, "summary": "test", - "aliases": [], + "aliases": [OrderedDict([('alias', 'CORE-2010-0121')])], "resource_url": f"http://testserver/vulnerabilities/{self.vulnerability.vulnerability_id}", "fixed_packages": [ { @@ -280,6 +291,13 @@ def test_api_with_single_vulnerability_with_filters(self): "description": "The software performs operations on a memory buffer, but it can read from or write to a memory location that is outside of the intended boundary of the buffer.", }, ], + "advisory": [ + { + "unique_content_id": "6b7d417a552b19f26a5c2267ba7876c2", + "url": "https://nginx.org/en/security_advisories.html", + "summary": "Vulnerabilities with Windows 8.3 filename pseudonyms" + } + ] } @@ -602,349 +620,349 @@ def test_api_with_ignorning_qualifiers(self): ) -class CPEApi(TestCase): - def setUp(self): - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - self.vulnerability = Vulnerability.objects.create(summary="test") - for i in range(0, 10): - ref, _ = VulnerabilityReference.objects.get_or_create( - reference_id=f"cpe:/a:nginx:{i}", - url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query=cpe:/a:nginx:{i}", - ) - VulnerabilityRelatedReference.objects.create( - reference=ref, vulnerability=self.vulnerability - ) - - def test_api_status(self): - response = self.csrf_client.get("/api/cpes/", format="json") - self.assertEqual(status.HTTP_200_OK, response.status_code) - - def test_api_response(self): - response = self.csrf_client.get("/api/cpes/?cpe=cpe:/a:nginx:9", format="json").data - self.assertEqual(response["count"], 1) - - -class AliasApi(TestCase): - def setUp(self): - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - self.vulnerability = Vulnerability.objects.create(summary="test") - for i in range(0, 10): - Alias.objects.create(alias=f"CVE-{i}", vulnerability=self.vulnerability) - - def test_api_status(self): - response = self.csrf_client.get("/api/aliases/", format="json") - self.assertEqual(status.HTTP_200_OK, response.status_code) - - def test_api_response(self): - response = self.csrf_client.get("/api/aliases?alias=CVE-9", format="json").data - self.assertEqual(response["count"], 1) - - -class BulkSearchAPIPackage(TestCase): - def setUp(self): - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - packages = [ - "pkg:nginx/nginx@0.6.18", - "pkg:nginx/nginx@1.20.0", - "pkg:nginx/nginx@1.21.0", - "pkg:nginx/nginx@1.20.1", - "pkg:nginx/nginx@1.9.5", - "pkg:nginx/nginx@1.17.2", - "pkg:nginx/nginx@1.17.3", - "pkg:nginx/nginx@1.16.1", - "pkg:nginx/nginx@1.15.5", - "pkg:nginx/nginx@1.15.6", - "pkg:nginx/nginx@1.14.1", - "pkg:nginx/nginx@1.0.7", - "pkg:nginx/nginx@1.0.15", - ] - self.packages = packages - for package in packages: - purl = PackageURL.from_string(package) - attrs = {k: v for k, v in purl.to_dict().items() if v} - Package.objects.create(**attrs) - - vulnerable_packages = [ - "pkg:nginx/nginx@1.0.15?foo=bar", - "pkg:nginx/nginx@1.0.15?foo=baz", - ] - - vuln = Vulnerability.objects.create(summary="test") - - for package in vulnerable_packages: - purl = PackageURL.from_string(package) - attrs = {k: v for k, v in purl.to_dict().items() if v} - pkg = Package.objects.create(**attrs) - PackageRelatedVulnerability.objects.create(package=pkg, vulnerability=vuln) - - def test_bulk_api_response(self): - request_body = { - "purls": self.packages, - } - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 13 - - def test_bulk_api_response_with_ignoring_qualifiers(self): - request_body = {"purls": ["pkg:nginx/nginx@1.0.15?qualifiers=dev"], "plain_purl": True} - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" - - def test_bulk_api_response_with_ignoring_subpath(self): - request_body = {"purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], "plain_purl": True} - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" - - def test_bulk_api_with_purl_only_option(self): - request_body = { - "purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], - "purl_only": True, - "plain_purl": True, - } - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - assert response[0] == "pkg:nginx/nginx@1.0.15" - - def test_bulk_api_without_purls_list(self): - request_body = { - "purls": None, - } - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - - expected = { - "error": {"purls": ["This field may not be null."]}, - "message": "A non-empty 'purls' list of PURLs is required.", - } - - self.assertEqual(response, expected) - - def test_bulk_api_without_purls_empty_list(self): - request_body = { - "purls": [], - } - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - - expected = { - "error": {"purls": ["This list may not be empty."]}, - "message": "A non-empty 'purls' list of PURLs is required.", - } - - self.assertEqual(response, expected) - - def test_bulk_api_with_empty_request_body(self): - request_body = {} - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - - expected = { - "error": {"purls": ["This field is required."]}, - "message": "A non-empty 'purls' list of PURLs is required.", - } - - self.assertEqual(response, expected) - - -class BulkSearchAPICPE(TestCase): - def setUp(self): - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - self.exclusive_cpes = [ - "cpe:/a:nginx:1.0.7", - "cpe:/a:nginx:1.0.15", - "cpe:/a:nginx:1.14.1", - "cpe:/a:nginx:1.15.5", - "cpe:/a:nginx:1.15.6", - ] - vuln = Vulnerability.objects.create(summary="test") - for cpe in self.exclusive_cpes: - ref = VulnerabilityReference.objects.create( - reference_id=cpe, - url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", - ) - VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=vuln) - second_vuln = Vulnerability.objects.create(summary="test-A") - self.non_exclusive_cpes = [ - "cpe:/a:nginx:1.16.1", - "cpe:/a:nginx:1.17.2", - "cpe:/a:nginx:1.17.3", - "cpe:/a:nginx:1.9.5", - "cpe:/a:nginx:1.20.1", - "cpe:/a:nginx:1.20.0", - "cpe:/a:nginx:1.21.0", - ] - third_vuln = Vulnerability.objects.create(summary="test-B") - for cpe in self.non_exclusive_cpes: - ref = VulnerabilityReference.objects.create( - reference_id=cpe, - url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", - ) - VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=second_vuln) - VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=third_vuln) - - def test_api_response_with_with_exclusive_cpes_associated_with_two_vulnerabilities(self): - request_body = { - "cpes": self.exclusive_cpes, - } - response = self.csrf_client.post( - "/api/cpes/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - assert response[0]["summary"] == "test" - references_in_vuln = response[0]["references"] - cpes = [ref["reference_id"] for ref in references_in_vuln] - assert set(cpes) == set(self.exclusive_cpes) - - def test_api_response_with_no_cpe_associated(self): - request_body = { - "cpes": ["cpe:/a:nginx:1.10.7"], - } - response = self.csrf_client.post( - "/api/cpes/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 0 - - def test_api_response_with_with_non_exclusive_cpes_associated_with_two_vulnerabilities(self): - request_body = { - "cpes": self.non_exclusive_cpes, - } - response = self.csrf_client.post( - "/api/cpes/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 2 - - def test_with_empty_list(self): - request_body = { - "cpes": [], - } - response = self.csrf_client.post( - "/api/cpes/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert response == {"Error": "A non-empty 'cpes' list of CPEs is required."} - - def test_with_invalid_cpes(self): - request_body = {"cpes": ["CVE-2022-2022"]} - response = self.csrf_client.post( - "/api/cpes/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert response == {"Error": "Invalid CPE: CVE-2022-2022"} - - -class TesBanUserAgent(TestCase): - def test_ban_request_with_bytedance_user_agent(self): - response = self.client.get(f"/api/packages", format="json", HTTP_USER_AGENT="bytedance") - assert 404 == response.status_code - - -class TestLookup(TestCase): - def setUp(self): - Package.objects.create( - type="pypi", namespace="", name="microweber/microweber", version="1.2" - ) - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - - def test_lookup_endpoint_failure(self): - request_body = {"purl": None} - response = self.csrf_client.post( - "/api/packages/lookup", - data=json.dumps(request_body), - content_type="application/json", - ).json() - - expected = { - "error": {"purl": ["This field may not be null."]}, - "message": "A 'purl' is required.", - } - - self.assertEqual(response, expected) - - def test_lookup_endpoint(self): - request_body = {"purl": "pkg:pypi/microweber/microweber@1.2"} - response = self.csrf_client.post( - "/api/packages/lookup", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - assert response[0]["purl"] == "pkg:pypi/microweber/microweber@1.2" - - def test_bulk_lookup_endpoint(self): - request_body = { - "purls": [ - "pkg:pypi/microweber/microweber@1.2?foo=bar", - "pkg:pypi/microweber/microweber@1.2", - "pkg:pypi/foo/bar@1.0", - ], - } - response = self.csrf_client.post( - "/api/packages/bulk_lookup", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - - def test_bulk_lookup_endpoint_failure(self): - request_body = {"purls": None} - response = self.csrf_client.post( - "/api/packages/bulk_lookup", - data=json.dumps(request_body), - content_type="application/json", - ).json() - - expected = { - "error": {"purls": ["This field may not be null."]}, - "message": "A non-empty 'purls' list of PURLs is required.", - } - - self.assertEqual(response, expected) +# class CPEApi(TestCase): +# def setUp(self): +# self.user = ApiUser.objects.create_api_user(username="e@mail.com") +# self.auth = f"Token {self.user.auth_token.key}" +# self.csrf_client = APIClient(enforce_csrf_checks=True) +# self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) +# self.vulnerability = Vulnerability.objects.create(summary="test") +# for i in range(0, 10): +# ref, _ = VulnerabilityReference.objects.get_or_create( +# reference_id=f"cpe:/a:nginx:{i}", +# url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query=cpe:/a:nginx:{i}", +# ) +# VulnerabilityRelatedReference.objects.create( +# reference=ref, vulnerability=self.vulnerability +# ) + +# def test_api_status(self): +# response = self.csrf_client.get("/api/cpes/", format="json") +# self.assertEqual(status.HTTP_200_OK, response.status_code) + +# def test_api_response(self): +# response = self.csrf_client.get("/api/cpes/?cpe=cpe:/a:nginx:9", format="json").data +# self.assertEqual(response["count"], 1) + + +# class AliasApi(TestCase): +# def setUp(self): +# self.user = ApiUser.objects.create_api_user(username="e@mail.com") +# self.auth = f"Token {self.user.auth_token.key}" +# self.csrf_client = APIClient(enforce_csrf_checks=True) +# self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) +# self.vulnerability = Vulnerability.objects.create(summary="test") +# for i in range(0, 10): +# Alias.objects.create(alias=f"CVE-{i}", vulnerability=self.vulnerability) + +# def test_api_status(self): +# response = self.csrf_client.get("/api/aliases/", format="json") +# self.assertEqual(status.HTTP_200_OK, response.status_code) + +# def test_api_response(self): +# response = self.csrf_client.get("/api/aliases?alias=CVE-9", format="json").data +# self.assertEqual(response["count"], 1) + + +# class BulkSearchAPIPackage(TestCase): +# def setUp(self): +# self.user = ApiUser.objects.create_api_user(username="e@mail.com") +# self.auth = f"Token {self.user.auth_token.key}" +# self.csrf_client = APIClient(enforce_csrf_checks=True) +# self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) +# packages = [ +# "pkg:nginx/nginx@0.6.18", +# "pkg:nginx/nginx@1.20.0", +# "pkg:nginx/nginx@1.21.0", +# "pkg:nginx/nginx@1.20.1", +# "pkg:nginx/nginx@1.9.5", +# "pkg:nginx/nginx@1.17.2", +# "pkg:nginx/nginx@1.17.3", +# "pkg:nginx/nginx@1.16.1", +# "pkg:nginx/nginx@1.15.5", +# "pkg:nginx/nginx@1.15.6", +# "pkg:nginx/nginx@1.14.1", +# "pkg:nginx/nginx@1.0.7", +# "pkg:nginx/nginx@1.0.15", +# ] +# self.packages = packages +# for package in packages: +# purl = PackageURL.from_string(package) +# attrs = {k: v for k, v in purl.to_dict().items() if v} +# Package.objects.create(**attrs) + +# vulnerable_packages = [ +# "pkg:nginx/nginx@1.0.15?foo=bar", +# "pkg:nginx/nginx@1.0.15?foo=baz", +# ] + +# vuln = Vulnerability.objects.create(summary="test") + +# for package in vulnerable_packages: +# purl = PackageURL.from_string(package) +# attrs = {k: v for k, v in purl.to_dict().items() if v} +# pkg = Package.objects.create(**attrs) +# PackageRelatedVulnerability.objects.create(package=pkg, vulnerability=vuln) + +# def test_bulk_api_response(self): +# request_body = { +# "purls": self.packages, +# } +# response = self.csrf_client.post( +# "/api/packages/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert len(response) == 13 + +# def test_bulk_api_response_with_ignoring_qualifiers(self): +# request_body = {"purls": ["pkg:nginx/nginx@1.0.15?qualifiers=dev"], "plain_purl": True} +# response = self.csrf_client.post( +# "/api/packages/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert len(response) == 1 +# assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" + +# def test_bulk_api_response_with_ignoring_subpath(self): +# request_body = {"purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], "plain_purl": True} +# response = self.csrf_client.post( +# "/api/packages/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert len(response) == 1 +# assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" + +# def test_bulk_api_with_purl_only_option(self): +# request_body = { +# "purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], +# "purl_only": True, +# "plain_purl": True, +# } +# response = self.csrf_client.post( +# "/api/packages/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert len(response) == 1 +# assert response[0] == "pkg:nginx/nginx@1.0.15" + +# def test_bulk_api_without_purls_list(self): +# request_body = { +# "purls": None, +# } +# response = self.csrf_client.post( +# "/api/packages/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() + +# expected = { +# "error": {"purls": ["This field may not be null."]}, +# "message": "A non-empty 'purls' list of PURLs is required.", +# } + +# self.assertEqual(response, expected) + +# def test_bulk_api_without_purls_empty_list(self): +# request_body = { +# "purls": [], +# } +# response = self.csrf_client.post( +# "/api/packages/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() + +# expected = { +# "error": {"purls": ["This list may not be empty."]}, +# "message": "A non-empty 'purls' list of PURLs is required.", +# } + +# self.assertEqual(response, expected) + +# def test_bulk_api_with_empty_request_body(self): +# request_body = {} +# response = self.csrf_client.post( +# "/api/packages/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() + +# expected = { +# "error": {"purls": ["This field is required."]}, +# "message": "A non-empty 'purls' list of PURLs is required.", +# } + +# self.assertEqual(response, expected) + + +# class BulkSearchAPICPE(TestCase): +# def setUp(self): +# self.user = ApiUser.objects.create_api_user(username="e@mail.com") +# self.auth = f"Token {self.user.auth_token.key}" +# self.csrf_client = APIClient(enforce_csrf_checks=True) +# self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) +# self.exclusive_cpes = [ +# "cpe:/a:nginx:1.0.7", +# "cpe:/a:nginx:1.0.15", +# "cpe:/a:nginx:1.14.1", +# "cpe:/a:nginx:1.15.5", +# "cpe:/a:nginx:1.15.6", +# ] +# vuln = Vulnerability.objects.create(summary="test") +# for cpe in self.exclusive_cpes: +# ref = VulnerabilityReference.objects.create( +# reference_id=cpe, +# url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", +# ) +# VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=vuln) +# second_vuln = Vulnerability.objects.create(summary="test-A") +# self.non_exclusive_cpes = [ +# "cpe:/a:nginx:1.16.1", +# "cpe:/a:nginx:1.17.2", +# "cpe:/a:nginx:1.17.3", +# "cpe:/a:nginx:1.9.5", +# "cpe:/a:nginx:1.20.1", +# "cpe:/a:nginx:1.20.0", +# "cpe:/a:nginx:1.21.0", +# ] +# third_vuln = Vulnerability.objects.create(summary="test-B") +# for cpe in self.non_exclusive_cpes: +# ref = VulnerabilityReference.objects.create( +# reference_id=cpe, +# url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", +# ) +# VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=second_vuln) +# VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=third_vuln) + +# def test_api_response_with_with_exclusive_cpes_associated_with_two_vulnerabilities(self): +# request_body = { +# "cpes": self.exclusive_cpes, +# } +# response = self.csrf_client.post( +# "/api/cpes/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert len(response) == 1 +# assert response[0]["summary"] == "test" +# references_in_vuln = response[0]["references"] +# cpes = [ref["reference_id"] for ref in references_in_vuln] +# assert set(cpes) == set(self.exclusive_cpes) + +# def test_api_response_with_no_cpe_associated(self): +# request_body = { +# "cpes": ["cpe:/a:nginx:1.10.7"], +# } +# response = self.csrf_client.post( +# "/api/cpes/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert len(response) == 0 + +# def test_api_response_with_with_non_exclusive_cpes_associated_with_two_vulnerabilities(self): +# request_body = { +# "cpes": self.non_exclusive_cpes, +# } +# response = self.csrf_client.post( +# "/api/cpes/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert len(response) == 2 + +# def test_with_empty_list(self): +# request_body = { +# "cpes": [], +# } +# response = self.csrf_client.post( +# "/api/cpes/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert response == {"Error": "A non-empty 'cpes' list of CPEs is required."} + +# def test_with_invalid_cpes(self): +# request_body = {"cpes": ["CVE-2022-2022"]} +# response = self.csrf_client.post( +# "/api/cpes/bulk_search", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert response == {"Error": "Invalid CPE: CVE-2022-2022"} + + +# class TesBanUserAgent(TestCase): +# def test_ban_request_with_bytedance_user_agent(self): +# response = self.client.get(f"/api/packages", format="json", HTTP_USER_AGENT="bytedance") +# assert 404 == response.status_code + + +# class TestLookup(TestCase): +# def setUp(self): +# Package.objects.create( +# type="pypi", namespace="", name="microweber/microweber", version="1.2" +# ) +# self.user = ApiUser.objects.create_api_user(username="e@mail.com") +# self.auth = f"Token {self.user.auth_token.key}" +# self.csrf_client = APIClient(enforce_csrf_checks=True) +# self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + +# def test_lookup_endpoint_failure(self): +# request_body = {"purl": None} +# response = self.csrf_client.post( +# "/api/packages/lookup", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() + +# expected = { +# "error": {"purl": ["This field may not be null."]}, +# "message": "A 'purl' is required.", +# } + +# self.assertEqual(response, expected) + +# def test_lookup_endpoint(self): +# request_body = {"purl": "pkg:pypi/microweber/microweber@1.2"} +# response = self.csrf_client.post( +# "/api/packages/lookup", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert len(response) == 1 +# assert response[0]["purl"] == "pkg:pypi/microweber/microweber@1.2" + +# def test_bulk_lookup_endpoint(self): +# request_body = { +# "purls": [ +# "pkg:pypi/microweber/microweber@1.2?foo=bar", +# "pkg:pypi/microweber/microweber@1.2", +# "pkg:pypi/foo/bar@1.0", +# ], +# } +# response = self.csrf_client.post( +# "/api/packages/bulk_lookup", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() +# assert len(response) == 1 + +# def test_bulk_lookup_endpoint_failure(self): +# request_body = {"purls": None} +# response = self.csrf_client.post( +# "/api/packages/bulk_lookup", +# data=json.dumps(request_body), +# content_type="application/json", +# ).json() + +# expected = { +# "error": {"purls": ["This field may not be null."]}, +# "message": "A non-empty 'purls' list of PURLs is required.", +# } + +# self.assertEqual(response, expected) From 6825e392bb73c4cbe5bd370102c2f5a8b71691ea Mon Sep 17 00:00:00 2001 From: ykodwani01 Date: Fri, 29 Mar 2024 20:24:12 +0530 Subject: [PATCH 2/4] Expose Advisory Details in API Signed-off-by: Yash Kodwani <202101418@daiict.ac.in> --- vulnerabilities/tests/test_api.py | 692 +++++++++++++++--------------- 1 file changed, 346 insertions(+), 346 deletions(-) diff --git a/vulnerabilities/tests/test_api.py b/vulnerabilities/tests/test_api.py index f43fa9ba8..953bb2542 100644 --- a/vulnerabilities/tests/test_api.py +++ b/vulnerabilities/tests/test_api.py @@ -620,349 +620,349 @@ def test_api_with_ignorning_qualifiers(self): ) -# class CPEApi(TestCase): -# def setUp(self): -# self.user = ApiUser.objects.create_api_user(username="e@mail.com") -# self.auth = f"Token {self.user.auth_token.key}" -# self.csrf_client = APIClient(enforce_csrf_checks=True) -# self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) -# self.vulnerability = Vulnerability.objects.create(summary="test") -# for i in range(0, 10): -# ref, _ = VulnerabilityReference.objects.get_or_create( -# reference_id=f"cpe:/a:nginx:{i}", -# url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query=cpe:/a:nginx:{i}", -# ) -# VulnerabilityRelatedReference.objects.create( -# reference=ref, vulnerability=self.vulnerability -# ) - -# def test_api_status(self): -# response = self.csrf_client.get("/api/cpes/", format="json") -# self.assertEqual(status.HTTP_200_OK, response.status_code) - -# def test_api_response(self): -# response = self.csrf_client.get("/api/cpes/?cpe=cpe:/a:nginx:9", format="json").data -# self.assertEqual(response["count"], 1) - - -# class AliasApi(TestCase): -# def setUp(self): -# self.user = ApiUser.objects.create_api_user(username="e@mail.com") -# self.auth = f"Token {self.user.auth_token.key}" -# self.csrf_client = APIClient(enforce_csrf_checks=True) -# self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) -# self.vulnerability = Vulnerability.objects.create(summary="test") -# for i in range(0, 10): -# Alias.objects.create(alias=f"CVE-{i}", vulnerability=self.vulnerability) - -# def test_api_status(self): -# response = self.csrf_client.get("/api/aliases/", format="json") -# self.assertEqual(status.HTTP_200_OK, response.status_code) - -# def test_api_response(self): -# response = self.csrf_client.get("/api/aliases?alias=CVE-9", format="json").data -# self.assertEqual(response["count"], 1) - - -# class BulkSearchAPIPackage(TestCase): -# def setUp(self): -# self.user = ApiUser.objects.create_api_user(username="e@mail.com") -# self.auth = f"Token {self.user.auth_token.key}" -# self.csrf_client = APIClient(enforce_csrf_checks=True) -# self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) -# packages = [ -# "pkg:nginx/nginx@0.6.18", -# "pkg:nginx/nginx@1.20.0", -# "pkg:nginx/nginx@1.21.0", -# "pkg:nginx/nginx@1.20.1", -# "pkg:nginx/nginx@1.9.5", -# "pkg:nginx/nginx@1.17.2", -# "pkg:nginx/nginx@1.17.3", -# "pkg:nginx/nginx@1.16.1", -# "pkg:nginx/nginx@1.15.5", -# "pkg:nginx/nginx@1.15.6", -# "pkg:nginx/nginx@1.14.1", -# "pkg:nginx/nginx@1.0.7", -# "pkg:nginx/nginx@1.0.15", -# ] -# self.packages = packages -# for package in packages: -# purl = PackageURL.from_string(package) -# attrs = {k: v for k, v in purl.to_dict().items() if v} -# Package.objects.create(**attrs) - -# vulnerable_packages = [ -# "pkg:nginx/nginx@1.0.15?foo=bar", -# "pkg:nginx/nginx@1.0.15?foo=baz", -# ] - -# vuln = Vulnerability.objects.create(summary="test") - -# for package in vulnerable_packages: -# purl = PackageURL.from_string(package) -# attrs = {k: v for k, v in purl.to_dict().items() if v} -# pkg = Package.objects.create(**attrs) -# PackageRelatedVulnerability.objects.create(package=pkg, vulnerability=vuln) - -# def test_bulk_api_response(self): -# request_body = { -# "purls": self.packages, -# } -# response = self.csrf_client.post( -# "/api/packages/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert len(response) == 13 - -# def test_bulk_api_response_with_ignoring_qualifiers(self): -# request_body = {"purls": ["pkg:nginx/nginx@1.0.15?qualifiers=dev"], "plain_purl": True} -# response = self.csrf_client.post( -# "/api/packages/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert len(response) == 1 -# assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" - -# def test_bulk_api_response_with_ignoring_subpath(self): -# request_body = {"purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], "plain_purl": True} -# response = self.csrf_client.post( -# "/api/packages/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert len(response) == 1 -# assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" - -# def test_bulk_api_with_purl_only_option(self): -# request_body = { -# "purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], -# "purl_only": True, -# "plain_purl": True, -# } -# response = self.csrf_client.post( -# "/api/packages/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert len(response) == 1 -# assert response[0] == "pkg:nginx/nginx@1.0.15" - -# def test_bulk_api_without_purls_list(self): -# request_body = { -# "purls": None, -# } -# response = self.csrf_client.post( -# "/api/packages/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() - -# expected = { -# "error": {"purls": ["This field may not be null."]}, -# "message": "A non-empty 'purls' list of PURLs is required.", -# } - -# self.assertEqual(response, expected) - -# def test_bulk_api_without_purls_empty_list(self): -# request_body = { -# "purls": [], -# } -# response = self.csrf_client.post( -# "/api/packages/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() - -# expected = { -# "error": {"purls": ["This list may not be empty."]}, -# "message": "A non-empty 'purls' list of PURLs is required.", -# } - -# self.assertEqual(response, expected) - -# def test_bulk_api_with_empty_request_body(self): -# request_body = {} -# response = self.csrf_client.post( -# "/api/packages/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() - -# expected = { -# "error": {"purls": ["This field is required."]}, -# "message": "A non-empty 'purls' list of PURLs is required.", -# } - -# self.assertEqual(response, expected) - - -# class BulkSearchAPICPE(TestCase): -# def setUp(self): -# self.user = ApiUser.objects.create_api_user(username="e@mail.com") -# self.auth = f"Token {self.user.auth_token.key}" -# self.csrf_client = APIClient(enforce_csrf_checks=True) -# self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) -# self.exclusive_cpes = [ -# "cpe:/a:nginx:1.0.7", -# "cpe:/a:nginx:1.0.15", -# "cpe:/a:nginx:1.14.1", -# "cpe:/a:nginx:1.15.5", -# "cpe:/a:nginx:1.15.6", -# ] -# vuln = Vulnerability.objects.create(summary="test") -# for cpe in self.exclusive_cpes: -# ref = VulnerabilityReference.objects.create( -# reference_id=cpe, -# url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", -# ) -# VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=vuln) -# second_vuln = Vulnerability.objects.create(summary="test-A") -# self.non_exclusive_cpes = [ -# "cpe:/a:nginx:1.16.1", -# "cpe:/a:nginx:1.17.2", -# "cpe:/a:nginx:1.17.3", -# "cpe:/a:nginx:1.9.5", -# "cpe:/a:nginx:1.20.1", -# "cpe:/a:nginx:1.20.0", -# "cpe:/a:nginx:1.21.0", -# ] -# third_vuln = Vulnerability.objects.create(summary="test-B") -# for cpe in self.non_exclusive_cpes: -# ref = VulnerabilityReference.objects.create( -# reference_id=cpe, -# url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", -# ) -# VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=second_vuln) -# VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=third_vuln) - -# def test_api_response_with_with_exclusive_cpes_associated_with_two_vulnerabilities(self): -# request_body = { -# "cpes": self.exclusive_cpes, -# } -# response = self.csrf_client.post( -# "/api/cpes/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert len(response) == 1 -# assert response[0]["summary"] == "test" -# references_in_vuln = response[0]["references"] -# cpes = [ref["reference_id"] for ref in references_in_vuln] -# assert set(cpes) == set(self.exclusive_cpes) - -# def test_api_response_with_no_cpe_associated(self): -# request_body = { -# "cpes": ["cpe:/a:nginx:1.10.7"], -# } -# response = self.csrf_client.post( -# "/api/cpes/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert len(response) == 0 - -# def test_api_response_with_with_non_exclusive_cpes_associated_with_two_vulnerabilities(self): -# request_body = { -# "cpes": self.non_exclusive_cpes, -# } -# response = self.csrf_client.post( -# "/api/cpes/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert len(response) == 2 - -# def test_with_empty_list(self): -# request_body = { -# "cpes": [], -# } -# response = self.csrf_client.post( -# "/api/cpes/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert response == {"Error": "A non-empty 'cpes' list of CPEs is required."} - -# def test_with_invalid_cpes(self): -# request_body = {"cpes": ["CVE-2022-2022"]} -# response = self.csrf_client.post( -# "/api/cpes/bulk_search", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert response == {"Error": "Invalid CPE: CVE-2022-2022"} - - -# class TesBanUserAgent(TestCase): -# def test_ban_request_with_bytedance_user_agent(self): -# response = self.client.get(f"/api/packages", format="json", HTTP_USER_AGENT="bytedance") -# assert 404 == response.status_code - - -# class TestLookup(TestCase): -# def setUp(self): -# Package.objects.create( -# type="pypi", namespace="", name="microweber/microweber", version="1.2" -# ) -# self.user = ApiUser.objects.create_api_user(username="e@mail.com") -# self.auth = f"Token {self.user.auth_token.key}" -# self.csrf_client = APIClient(enforce_csrf_checks=True) -# self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - -# def test_lookup_endpoint_failure(self): -# request_body = {"purl": None} -# response = self.csrf_client.post( -# "/api/packages/lookup", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() - -# expected = { -# "error": {"purl": ["This field may not be null."]}, -# "message": "A 'purl' is required.", -# } - -# self.assertEqual(response, expected) - -# def test_lookup_endpoint(self): -# request_body = {"purl": "pkg:pypi/microweber/microweber@1.2"} -# response = self.csrf_client.post( -# "/api/packages/lookup", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert len(response) == 1 -# assert response[0]["purl"] == "pkg:pypi/microweber/microweber@1.2" - -# def test_bulk_lookup_endpoint(self): -# request_body = { -# "purls": [ -# "pkg:pypi/microweber/microweber@1.2?foo=bar", -# "pkg:pypi/microweber/microweber@1.2", -# "pkg:pypi/foo/bar@1.0", -# ], -# } -# response = self.csrf_client.post( -# "/api/packages/bulk_lookup", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() -# assert len(response) == 1 - -# def test_bulk_lookup_endpoint_failure(self): -# request_body = {"purls": None} -# response = self.csrf_client.post( -# "/api/packages/bulk_lookup", -# data=json.dumps(request_body), -# content_type="application/json", -# ).json() - -# expected = { -# "error": {"purls": ["This field may not be null."]}, -# "message": "A non-empty 'purls' list of PURLs is required.", -# } - -# self.assertEqual(response, expected) +class CPEApi(TestCase): + def setUp(self): + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + self.vulnerability = Vulnerability.objects.create(summary="test") + for i in range(0, 10): + ref, _ = VulnerabilityReference.objects.get_or_create( + reference_id=f"cpe:/a:nginx:{i}", + url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query=cpe:/a:nginx:{i}", + ) + VulnerabilityRelatedReference.objects.create( + reference=ref, vulnerability=self.vulnerability + ) + + def test_api_status(self): + response = self.csrf_client.get("/api/cpes/", format="json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + + def test_api_response(self): + response = self.csrf_client.get("/api/cpes/?cpe=cpe:/a:nginx:9", format="json").data + self.assertEqual(response["count"], 1) + + +class AliasApi(TestCase): + def setUp(self): + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + self.vulnerability = Vulnerability.objects.create(summary="test") + for i in range(0, 10): + Alias.objects.create(alias=f"CVE-{i}", vulnerability=self.vulnerability) + + def test_api_status(self): + response = self.csrf_client.get("/api/aliases/", format="json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + + def test_api_response(self): + response = self.csrf_client.get("/api/aliases?alias=CVE-9", format="json").data + self.assertEqual(response["count"], 1) + + +class BulkSearchAPIPackage(TestCase): + def setUp(self): + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + packages = [ + "pkg:nginx/nginx@0.6.18", + "pkg:nginx/nginx@1.20.0", + "pkg:nginx/nginx@1.21.0", + "pkg:nginx/nginx@1.20.1", + "pkg:nginx/nginx@1.9.5", + "pkg:nginx/nginx@1.17.2", + "pkg:nginx/nginx@1.17.3", + "pkg:nginx/nginx@1.16.1", + "pkg:nginx/nginx@1.15.5", + "pkg:nginx/nginx@1.15.6", + "pkg:nginx/nginx@1.14.1", + "pkg:nginx/nginx@1.0.7", + "pkg:nginx/nginx@1.0.15", + ] + self.packages = packages + for package in packages: + purl = PackageURL.from_string(package) + attrs = {k: v for k, v in purl.to_dict().items() if v} + Package.objects.create(**attrs) + + vulnerable_packages = [ + "pkg:nginx/nginx@1.0.15?foo=bar", + "pkg:nginx/nginx@1.0.15?foo=baz", + ] + + vuln = Vulnerability.objects.create(summary="test") + + for package in vulnerable_packages: + purl = PackageURL.from_string(package) + attrs = {k: v for k, v in purl.to_dict().items() if v} + pkg = Package.objects.create(**attrs) + PackageRelatedVulnerability.objects.create(package=pkg, vulnerability=vuln) + + def test_bulk_api_response(self): + request_body = { + "purls": self.packages, + } + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 13 + + def test_bulk_api_response_with_ignoring_qualifiers(self): + request_body = {"purls": ["pkg:nginx/nginx@1.0.15?qualifiers=dev"], "plain_purl": True} + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" + + def test_bulk_api_response_with_ignoring_subpath(self): + request_body = {"purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], "plain_purl": True} + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" + + def test_bulk_api_with_purl_only_option(self): + request_body = { + "purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], + "purl_only": True, + "plain_purl": True, + } + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + assert response[0] == "pkg:nginx/nginx@1.0.15" + + def test_bulk_api_without_purls_list(self): + request_body = { + "purls": None, + } + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + + expected = { + "error": {"purls": ["This field may not be null."]}, + "message": "A non-empty 'purls' list of PURLs is required.", + } + + self.assertEqual(response, expected) + + def test_bulk_api_without_purls_empty_list(self): + request_body = { + "purls": [], + } + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + + expected = { + "error": {"purls": ["This list may not be empty."]}, + "message": "A non-empty 'purls' list of PURLs is required.", + } + + self.assertEqual(response, expected) + + def test_bulk_api_with_empty_request_body(self): + request_body = {} + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + + expected = { + "error": {"purls": ["This field is required."]}, + "message": "A non-empty 'purls' list of PURLs is required.", + } + + self.assertEqual(response, expected) + + +class BulkSearchAPICPE(TestCase): + def setUp(self): + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + self.exclusive_cpes = [ + "cpe:/a:nginx:1.0.7", + "cpe:/a:nginx:1.0.15", + "cpe:/a:nginx:1.14.1", + "cpe:/a:nginx:1.15.5", + "cpe:/a:nginx:1.15.6", + ] + vuln = Vulnerability.objects.create(summary="test") + for cpe in self.exclusive_cpes: + ref = VulnerabilityReference.objects.create( + reference_id=cpe, + url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", + ) + VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=vuln) + second_vuln = Vulnerability.objects.create(summary="test-A") + self.non_exclusive_cpes = [ + "cpe:/a:nginx:1.16.1", + "cpe:/a:nginx:1.17.2", + "cpe:/a:nginx:1.17.3", + "cpe:/a:nginx:1.9.5", + "cpe:/a:nginx:1.20.1", + "cpe:/a:nginx:1.20.0", + "cpe:/a:nginx:1.21.0", + ] + third_vuln = Vulnerability.objects.create(summary="test-B") + for cpe in self.non_exclusive_cpes: + ref = VulnerabilityReference.objects.create( + reference_id=cpe, + url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", + ) + VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=second_vuln) + VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=third_vuln) + + def test_api_response_with_with_exclusive_cpes_associated_with_two_vulnerabilities(self): + request_body = { + "cpes": self.exclusive_cpes, + } + response = self.csrf_client.post( + "/api/cpes/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + assert response[0]["summary"] == "test" + references_in_vuln = response[0]["references"] + cpes = [ref["reference_id"] for ref in references_in_vuln] + assert set(cpes) == set(self.exclusive_cpes) + + def test_api_response_with_no_cpe_associated(self): + request_body = { + "cpes": ["cpe:/a:nginx:1.10.7"], + } + response = self.csrf_client.post( + "/api/cpes/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 0 + + def test_api_response_with_with_non_exclusive_cpes_associated_with_two_vulnerabilities(self): + request_body = { + "cpes": self.non_exclusive_cpes, + } + response = self.csrf_client.post( + "/api/cpes/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 2 + + def test_with_empty_list(self): + request_body = { + "cpes": [], + } + response = self.csrf_client.post( + "/api/cpes/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert response == {"Error": "A non-empty 'cpes' list of CPEs is required."} + + def test_with_invalid_cpes(self): + request_body = {"cpes": ["CVE-2022-2022"]} + response = self.csrf_client.post( + "/api/cpes/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert response == {"Error": "Invalid CPE: CVE-2022-2022"} + + +class TesBanUserAgent(TestCase): + def test_ban_request_with_bytedance_user_agent(self): + response = self.client.get(f"/api/packages", format="json", HTTP_USER_AGENT="bytedance") + assert 404 == response.status_code + + +class TestLookup(TestCase): + def setUp(self): + Package.objects.create( + type="pypi", namespace="", name="microweber/microweber", version="1.2" + ) + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + + def test_lookup_endpoint_failure(self): + request_body = {"purl": None} + response = self.csrf_client.post( + "/api/packages/lookup", + data=json.dumps(request_body), + content_type="application/json", + ).json() + + expected = { + "error": {"purl": ["This field may not be null."]}, + "message": "A 'purl' is required.", + } + + self.assertEqual(response, expected) + + def test_lookup_endpoint(self): + request_body = {"purl": "pkg:pypi/microweber/microweber@1.2"} + response = self.csrf_client.post( + "/api/packages/lookup", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + assert response[0]["purl"] == "pkg:pypi/microweber/microweber@1.2" + + def test_bulk_lookup_endpoint(self): + request_body = { + "purls": [ + "pkg:pypi/microweber/microweber@1.2?foo=bar", + "pkg:pypi/microweber/microweber@1.2", + "pkg:pypi/foo/bar@1.0", + ], + } + response = self.csrf_client.post( + "/api/packages/bulk_lookup", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + + def test_bulk_lookup_endpoint_failure(self): + request_body = {"purls": None} + response = self.csrf_client.post( + "/api/packages/bulk_lookup", + data=json.dumps(request_body), + content_type="application/json", + ).json() + + expected = { + "error": {"purls": ["This field may not be null."]}, + "message": "A non-empty 'purls' list of PURLs is required.", + } + + self.assertEqual(response, expected) From 547dfe1ed301a2c10c5680757ae4b9e5f2c59ba6 Mon Sep 17 00:00:00 2001 From: ykodwani01 Date: Fri, 29 Mar 2024 20:27:33 +0530 Subject: [PATCH 3/4] Added Test Cases Signed-off-by: ykodwani01 Signed-off-by: Yash Kodwani <202101418@daiict.ac.in> --- vulnerabilities/tests/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/vulnerabilities/tests/test_api.py b/vulnerabilities/tests/test_api.py index 953bb2542..2f0550571 100644 --- a/vulnerabilities/tests/test_api.py +++ b/vulnerabilities/tests/test_api.py @@ -30,6 +30,7 @@ from vulnerabilities.models import VulnerabilityReference from vulnerabilities.models import VulnerabilityRelatedReference from vulnerabilities.models import Weakness + from datetime import datetime BASE_DIR = os.path.dirname(os.path.abspath(__file__)) From f9d3f63190ee8483a8adf9dd0ede26de79f92843 Mon Sep 17 00:00:00 2001 From: ykodwani01 Date: Wed, 10 Apr 2024 17:37:58 +0530 Subject: [PATCH 4/4] Advisory Details Signed-off-by: ykodwani01 --- vulnerabilities/api.py | 1268 +++++++++---------- vulnerabilities/tests/test_api.py | 1938 ++++++++++++++--------------- 2 files changed, 1603 insertions(+), 1603 deletions(-) diff --git a/vulnerabilities/api.py b/vulnerabilities/api.py index 8844bc54d..2e74c10df 100644 --- a/vulnerabilities/api.py +++ b/vulnerabilities/api.py @@ -1,634 +1,634 @@ -# -# Copyright (c) nexB Inc. and others. All rights reserved. -# VulnerableCode is a trademark of nexB Inc. -# SPDX-License-Identifier: Apache-2.0 -# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/vulnerablecode for support or download. -# See https://aboutcode.org for more information about nexB OSS projects. -# - -from urllib.parse import unquote - -from django.db.models import Prefetch -from django_filters import rest_framework as filters -from drf_spectacular.utils import extend_schema -from drf_spectacular.utils import inline_serializer -from packageurl import PackageURL -from packageurl import normalize_qualifiers -from rest_framework import serializers -from rest_framework import status -from rest_framework import viewsets -from rest_framework.decorators import action -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.throttling import AnonRateThrottle -from rest_framework.throttling import UserRateThrottle - -from vulnerabilities.models import Alias -from vulnerabilities.models import Package -from vulnerabilities.models import Vulnerability -from vulnerabilities.models import Advisory -from vulnerabilities.models import VulnerabilityReference -from vulnerabilities.models import VulnerabilitySeverity -from vulnerabilities.models import Weakness -from vulnerabilities.models import get_purl_query_lookups -from vulnerabilities.throttling import StaffUserRateThrottle - - -class VulnerabilitySeveritySerializer(serializers.ModelSerializer): - class Meta: - model = VulnerabilitySeverity - fields = ["value", "scoring_system", "scoring_elements"] - - -class VulnerabilityReferenceSerializer(serializers.ModelSerializer): - scores = VulnerabilitySeveritySerializer(many=True, source="vulnerabilityseverity_set") - reference_url = serializers.CharField(source="url") - - class Meta: - model = VulnerabilityReference - fields = ["reference_url", "reference_id", "scores", "url"] - - -class BaseResourceSerializer(serializers.HyperlinkedModelSerializer): - """ - Base serializer containing common methods. - """ - - def get_fields(self): - fields = super().get_fields() - fields["resource_url"] = serializers.SerializerMethodField(method_name="get_resource_url") - return fields - - def get_resource_url(self, instance): - """ - Return the instance fully qualified URL including the schema and domain. - - Usage: - resource_url = serializers.SerializerMethodField() - """ - resource_url = instance.get_absolute_url() - - if request := self.context.get("request", None): - return request.build_absolute_uri(location=resource_url) - - return resource_url - - -class MinimalPackageSerializer(BaseResourceSerializer): - """ - Used for nesting inside vulnerability focused APIs. - """ - - def get_affected_vulnerabilities(self, package): - parent_affected_vulnerabilities = package.fixed_package_details.get("vulnerabilities") or [] - - affected_vulnerabilities = [ - self.get_vulnerability(vuln) for vuln in parent_affected_vulnerabilities - ] - - return affected_vulnerabilities - - def get_vulnerability(self, vuln): - affected_vulnerability = {} - - vulnerability = vuln.get("vulnerability") - if vulnerability: - affected_vulnerability["vulnerability"] = vulnerability.vulnerability_id - return affected_vulnerability - - affected_by_vulnerabilities = serializers.SerializerMethodField("get_affected_vulnerabilities") - - purl = serializers.CharField(source="package_url") - - class Meta: - model = Package - fields = ["url", "purl", "is_vulnerable", "affected_by_vulnerabilities"] - - -class MinimalVulnerabilitySerializer(BaseResourceSerializer): - """ - Lookup vulnerabilities by aliases (such as a CVE). - """ - - class Meta: - model = Vulnerability - fields = ["url", "vulnerability_id"] - - -class AliasSerializer(serializers.HyperlinkedModelSerializer): - """ - Used for nesting inside package focused APIs. - """ - - class Meta: - model = Alias - fields = ["alias"] - - -class VulnSerializerRefsAndSummary(BaseResourceSerializer): - """ - Lookup vulnerabilities references by aliases (such as a CVE). - """ - - def to_representation(self, instance): - data = super().to_representation(instance) - aliases = [alias["alias"] for alias in data["aliases"]] - data["aliases"] = aliases - return data - - fixed_packages = MinimalPackageSerializer( - many=True, source="filtered_fixed_packages", read_only=True - ) - - references = VulnerabilityReferenceSerializer(many=True, source="vulnerabilityreference_set") - aliases = AliasSerializer(many=True, source="alias") - - class Meta: - model = Vulnerability - fields = ["url", "vulnerability_id", "summary", "references", "fixed_packages", "aliases"] - - -class WeaknessSerializer(serializers.HyperlinkedModelSerializer): - """ - Used for nesting inside weakness focused APIs. - """ - - class Meta: - model = Weakness - fields = ["cwe_id", "name", "description"] - - def to_representation(self, instance): - """ - Override to include 'weakness' only if it is not None. - """ - representation = super().to_representation(instance) - if instance.weakness is None: - return None - return representation - - -class VulnerabilitySerializer(BaseResourceSerializer): - fixed_packages = MinimalPackageSerializer( - many=True, source="filtered_fixed_packages", read_only=True - ) - affected_packages = MinimalPackageSerializer(many=True, read_only=True) - - references = VulnerabilityReferenceSerializer(many=True, source="vulnerabilityreference_set") - aliases = AliasSerializer(many=True, source="alias") - weaknesses = WeaknessSerializer(many=True) - - def to_representation(self, instance): - data = super().to_representation(instance) - - weaknesses = data.get("weaknesses", []) - data["weaknesses"] = [weakness for weakness in weaknesses if weakness is not None] - alias_queryset = instance.aliases.all() - data["advisory"] = [] - for itr in alias_queryset: - advisory_objects = Advisory.objects.filter(aliases=[itr.alias]) - for i in advisory_objects: - data["advisory"].append({"unique_content_id" : i.unique_content_id,"url" : i.url,"summary" : i.summary}) - return data - - class Meta: - model = Vulnerability - fields = [ - "url", - "vulnerability_id", - "summary", - "aliases", - "fixed_packages", - "affected_packages", - "references", - "weaknesses", - ] - - -class PackageSerializer(BaseResourceSerializer): - """ - Lookup software package using Package URLs - """ - - def to_representation(self, instance): - data = super().to_representation(instance) - data["qualifiers"] = normalize_qualifiers(data["qualifiers"], encode=False) - - return data - - next_non_vulnerable_version = serializers.SerializerMethodField("get_next_non_vulnerable") - - def get_next_non_vulnerable(self, package): - next_non_vulnerable = package.fixed_package_details.get("next_non_vulnerable", None) - if next_non_vulnerable: - return next_non_vulnerable.version - - latest_non_vulnerable_version = serializers.SerializerMethodField("get_latest_non_vulnerable") - - def get_latest_non_vulnerable(self, package): - latest_non_vulnerable = package.fixed_package_details.get("latest_non_vulnerable", None) - if latest_non_vulnerable: - return latest_non_vulnerable.version - - purl = serializers.CharField(source="package_url") - - affected_by_vulnerabilities = serializers.SerializerMethodField("get_affected_vulnerabilities") - - fixing_vulnerabilities = serializers.SerializerMethodField("get_fixed_vulnerabilities") - - def get_fixed_packages(self, package): - """ - Return a queryset of all packages that fix a vulnerability with - same type, namespace, name, subpath and qualifiers of the `package` - """ - return Package.objects.filter( - name=package.name, - namespace=package.namespace, - type=package.type, - qualifiers=package.qualifiers, - subpath=package.subpath, - packagerelatedvulnerability__fix=True, - ).distinct() - - def get_vulnerabilities_for_a_package(self, package, fix) -> dict: - """ - Return a mapping of vulnerabilities data related to the given `package`. - Return vulnerabilities that affect the `package` if given `fix` flag is False, - otherwise return vulnerabilities fixed by the `package`. - """ - fixed_packages = self.get_fixed_packages(package=package) - qs = package.vulnerabilities.filter(packagerelatedvulnerability__fix=fix) - qs = qs.prefetch_related( - Prefetch( - "packages", - queryset=fixed_packages, - to_attr="filtered_fixed_packages", - ) - ) - return VulnSerializerRefsAndSummary( - instance=qs, - many=True, - context={"request": self.context["request"]}, - ).data - - def get_fixed_vulnerabilities(self, package) -> dict: - """ - Return a mapping of vulnerabilities fixed in the given `package`. - """ - return self.get_vulnerabilities_for_a_package(package=package, fix=True) - - def get_affected_vulnerabilities(self, package) -> dict: - """ - Return a mapping of vulnerabilities that affect the given `package` (including packages that - fix each vulnerability and whose version is greater than the `package` version). - """ - excluded_purls = [] - package_vulnerabilities = self.get_vulnerabilities_for_a_package(package=package, fix=False) - - for vuln in package_vulnerabilities: - for pkg in vuln["fixed_packages"]: - real_purl = PackageURL.from_string(pkg["purl"]) - if package.version_class(real_purl.version) <= package.current_version: - excluded_purls.append(pkg) - - vuln["fixed_packages"] = [ - pkg for pkg in vuln["fixed_packages"] if pkg not in excluded_purls - ] - - return package_vulnerabilities - - class Meta: - model = Package - fields = [ - "url", - "purl", - "type", - "namespace", - "name", - "version", - "qualifiers", - "subpath", - "next_non_vulnerable_version", - "latest_non_vulnerable_version", - "affected_by_vulnerabilities", - "fixing_vulnerabilities", - ] - - -class PackageFilterSet(filters.FilterSet): - purl = filters.CharFilter(method="filter_purl") - - class Meta: - model = Package - fields = [ - "type", - "namespace", - "name", - "version", - "qualifiers", - "subpath", - "purl", - "packagerelatedvulnerability__fix", - ] - - def filter_purl(self, queryset, name, value): - purl = unquote(value) - try: - purl = PackageURL.from_string(purl) - - except ValueError as ve: - raise serializers.ValidationError( - detail={"error": f'"{purl}" is not a valid Package URL: {ve}'}, - ) - - lookups = get_purl_query_lookups(purl) - return self.queryset.filter(**lookups) - - -class PackageurlListSerializer(serializers.Serializer): - purls = serializers.ListField( - child=serializers.CharField(), - allow_empty=False, - help_text="List of PackageURL strings in canonical form.", - ) - - -class PackageBulkSearchRequestSerializer(PackageurlListSerializer): - purl_only = serializers.BooleanField(required=False, default=False) - plain_purl = serializers.BooleanField(required=False, default=False) - - -class LookupRequestSerializer(serializers.Serializer): - purl = serializers.CharField( - required=True, - help_text="PackageURL strings in canonical form.", - ) - - -class PackageViewSet(viewsets.ReadOnlyModelViewSet): - """ - Lookup for vulnerable packages by Package URL. - """ - - queryset = Package.objects.all() - serializer_class = PackageSerializer - filter_backends = (filters.DjangoFilterBackend,) - filterset_class = PackageFilterSet - throttle_classes = [StaffUserRateThrottle, AnonRateThrottle] - - @extend_schema( - request=PackageBulkSearchRequestSerializer, - responses={200: PackageSerializer(many=True)}, - ) - @action( - detail=False, - methods=["post"], - serializer_class=PackageBulkSearchRequestSerializer, - filter_backends=[], - pagination_class=None, - ) - def bulk_search(self, request): - """ - Lookup for vulnerable packages using many Package URLs at once. - """ - serializer = self.serializer_class(data=request.data) - if not serializer.is_valid(): - return Response( - status=status.HTTP_400_BAD_REQUEST, - data={ - "error": serializer.errors, - "message": "A non-empty 'purls' list of PURLs is required.", - }, - ) - validated_data = serializer.validated_data - purls = validated_data.get("purls") - purl_only = validated_data.get("purl_only", False) - plain_purl = validated_data.get("plain_purl", False) - - if plain_purl: - purl_objects = [PackageURL.from_string(purl) for purl in purls] - plain_purl_objects = [ - PackageURL( - type=purl.type, - namespace=purl.namespace, - name=purl.name, - version=purl.version, - ) - for purl in purl_objects - ] - plain_purls = [str(purl) for purl in plain_purl_objects] - - query = ( - Package.objects.filter(plain_package_url__in=plain_purls) - .order_by("plain_package_url") - .distinct("plain_package_url") - ) - - if not purl_only: - return Response( - PackageSerializer(query, many=True, context={"request": request}).data - ) - - # using order by and distinct because there will be - # many fully qualified purl for a single plain purl - vulnerable_purls = query.vulnerable().only("plain_package_url") - vulnerable_purls = [str(package.plain_package_url) for package in vulnerable_purls] - return Response(data=vulnerable_purls) - - query = Package.objects.filter(package_url__in=purls).distinct() - - if not purl_only: - return Response(PackageSerializer(query, many=True, context={"request": request}).data) - - vulnerable_purls = query.vulnerable().only("package_url") - vulnerable_purls = [str(package.package_url) for package in vulnerable_purls] - return Response(data=vulnerable_purls) - - @action(detail=False, methods=["get"]) - def all(self, request): - """ - Return the Package URLs of all packages known to be vulnerable. - """ - vulnerable_packages = Package.objects.vulnerable().only("package_url").distinct() - vulnerable_purls = [str(package.package_url) for package in vulnerable_packages] - return Response(vulnerable_purls) - - @extend_schema( - request=LookupRequestSerializer, - responses={200: PackageSerializer(many=True)}, - ) - @action( - detail=False, - methods=["post"], - serializer_class=LookupRequestSerializer, - filter_backends=[], - pagination_class=None, - ) - def lookup(self, request): - """ - Return the response for exact PackageURL requested for. - """ - serializer = self.serializer_class(data=request.data) - if not serializer.is_valid(): - return Response( - status=status.HTTP_400_BAD_REQUEST, - data={ - "error": serializer.errors, - "message": "A 'purl' is required.", - }, - ) - validated_data = serializer.validated_data - purl = validated_data.get("purl") - - return Response( - PackageSerializer( - Package.objects.for_purls([purl]), many=True, context={"request": request} - ).data - ) - - @extend_schema( - request=PackageurlListSerializer, - responses={200: PackageSerializer(many=True)}, - ) - @action( - detail=False, - methods=["post"], - serializer_class=PackageurlListSerializer, - filter_backends=[], - pagination_class=None, - ) - def bulk_lookup(self, request): - """ - Return the response for exact PackageURLs requested for. - """ - serializer = self.serializer_class(data=request.data) - if not serializer.is_valid(): - return Response( - status=status.HTTP_400_BAD_REQUEST, - data={ - "error": serializer.errors, - "message": "A non-empty 'purls' list of PURLs is required.", - }, - ) - validated_data = serializer.validated_data - purls = validated_data.get("purls") - - return Response( - PackageSerializer( - Package.objects.for_purls(purls), - many=True, - context={"request": request}, - ).data - ) - - -class VulnerabilityFilterSet(filters.FilterSet): - class Meta: - model = Vulnerability - fields = ["vulnerability_id"] - - -class VulnerabilityViewSet(viewsets.ReadOnlyModelViewSet): - """ - Lookup for vulnerabilities affecting packages. - """ - - def get_fixed_packages_qs(self): - """ - Filter the packages that fixes a vulnerability - on fields like name, namespace and type. - """ - package_filter_data = {"packagerelatedvulnerability__fix": True} - - query_params = self.request.query_params - for field_name in ["name", "namespace", "type"]: - value = query_params.get(field_name) - if value: - package_filter_data[field_name] = value - - return PackageFilterSet(package_filter_data).qs - - def get_queryset(self): - """ - Assign filtered packages queryset from `get_fixed_packages_qs` - to a custom attribute `filtered_fixed_packages` - """ - return Vulnerability.objects.prefetch_related( - "weaknesses", - Prefetch( - "packages", - queryset=self.get_fixed_packages_qs(), - to_attr="filtered_fixed_packages", - ), - ) - - serializer_class = VulnerabilitySerializer - filter_backends = (filters.DjangoFilterBackend,) - filterset_class = VulnerabilityFilterSet - throttle_classes = [StaffUserRateThrottle, AnonRateThrottle] - - -class CPEFilterSet(filters.FilterSet): - cpe = filters.CharFilter(method="filter_cpe") - - def filter_cpe(self, queryset, name, value): - cpe = unquote(value) - return self.queryset.filter(vulnerabilityreference__reference_id__startswith=cpe).distinct() - - -class CPEViewSet(viewsets.ReadOnlyModelViewSet): - """ - Lookup for vulnerabilities by CPE (https://nvd.nist.gov/products/cpe) - """ - - queryset = Vulnerability.objects.filter( - vulnerabilityreference__reference_id__startswith="cpe" - ).distinct() - serializer_class = VulnerabilitySerializer - filter_backends = (filters.DjangoFilterBackend,) - throttle_classes = [StaffUserRateThrottle, AnonRateThrottle] - filterset_class = CPEFilterSet - - @action(detail=False, methods=["post"]) - def bulk_search(self, request): - """ - Lookup for vulnerabilities using many CPEs at once. - """ - cpes = request.data.get("cpes", []) or [] - if not cpes or not isinstance(cpes, list): - return Response( - status=400, - data={"Error": "A non-empty 'cpes' list of CPEs is required."}, - ) - for cpe in cpes: - if not cpe.startswith("cpe"): - return Response(status=400, data={"Error": f"Invalid CPE: {cpe}"}) - vulnerabilitiesResponse = Vulnerability.objects.filter( - vulnerabilityreference__reference_id__in=cpes - ).distinct() - return Response( - VulnerabilitySerializer( - vulnerabilitiesResponse, many=True, context={"request": request} - ).data - ) - - -class AliasFilterSet(filters.FilterSet): - alias = filters.CharFilter(method="filter_alias") - - def filter_alias(self, queryset, name, value): - alias = unquote(value) - return self.queryset.filter(aliases__alias__icontains=alias) - - -class AliasViewSet(viewsets.ReadOnlyModelViewSet): - """ - Lookup for vulnerabilities by vulnerability aliases such as a CVE - (https://nvd.nist.gov/general/cve-process). - """ - - queryset = Vulnerability.objects.all() - serializer_class = VulnerabilitySerializer - filter_backends = (filters.DjangoFilterBackend,) - filterset_class = AliasFilterSet - throttle_classes = [StaffUserRateThrottle, AnonRateThrottle] +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from urllib.parse import unquote + +from django.db.models import Prefetch +from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import inline_serializer +from packageurl import PackageURL +from packageurl import normalize_qualifiers +from rest_framework import serializers +from rest_framework import status +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.throttling import AnonRateThrottle +from rest_framework.throttling import UserRateThrottle + +from vulnerabilities.models import Alias +from vulnerabilities.models import Package +from vulnerabilities.models import Vulnerability +from vulnerabilities.models import Advisory +from vulnerabilities.models import VulnerabilityReference +from vulnerabilities.models import VulnerabilitySeverity +from vulnerabilities.models import Weakness +from vulnerabilities.models import get_purl_query_lookups +from vulnerabilities.throttling import StaffUserRateThrottle + + +class VulnerabilitySeveritySerializer(serializers.ModelSerializer): + class Meta: + model = VulnerabilitySeverity + fields = ["value", "scoring_system", "scoring_elements"] + + +class VulnerabilityReferenceSerializer(serializers.ModelSerializer): + scores = VulnerabilitySeveritySerializer(many=True, source="vulnerabilityseverity_set") + reference_url = serializers.CharField(source="url") + + class Meta: + model = VulnerabilityReference + fields = ["reference_url", "reference_id", "scores", "url"] + + +class BaseResourceSerializer(serializers.HyperlinkedModelSerializer): + """ + Base serializer containing common methods. + """ + + def get_fields(self): + fields = super().get_fields() + fields["resource_url"] = serializers.SerializerMethodField(method_name="get_resource_url") + return fields + + def get_resource_url(self, instance): + """ + Return the instance fully qualified URL including the schema and domain. + + Usage: + resource_url = serializers.SerializerMethodField() + """ + resource_url = instance.get_absolute_url() + + if request := self.context.get("request", None): + return request.build_absolute_uri(location=resource_url) + + return resource_url + + +class MinimalPackageSerializer(BaseResourceSerializer): + """ + Used for nesting inside vulnerability focused APIs. + """ + + def get_affected_vulnerabilities(self, package): + parent_affected_vulnerabilities = package.fixed_package_details.get("vulnerabilities") or [] + + affected_vulnerabilities = [ + self.get_vulnerability(vuln) for vuln in parent_affected_vulnerabilities + ] + + return affected_vulnerabilities + + def get_vulnerability(self, vuln): + affected_vulnerability = {} + + vulnerability = vuln.get("vulnerability") + if vulnerability: + affected_vulnerability["vulnerability"] = vulnerability.vulnerability_id + return affected_vulnerability + + affected_by_vulnerabilities = serializers.SerializerMethodField("get_affected_vulnerabilities") + + purl = serializers.CharField(source="package_url") + + class Meta: + model = Package + fields = ["url", "purl", "is_vulnerable", "affected_by_vulnerabilities"] + + +class MinimalVulnerabilitySerializer(BaseResourceSerializer): + """ + Lookup vulnerabilities by aliases (such as a CVE). + """ + + class Meta: + model = Vulnerability + fields = ["url", "vulnerability_id"] + + +class AliasSerializer(serializers.HyperlinkedModelSerializer): + """ + Used for nesting inside package focused APIs. + """ + + class Meta: + model = Alias + fields = ["alias"] + + +class VulnSerializerRefsAndSummary(BaseResourceSerializer): + """ + Lookup vulnerabilities references by aliases (such as a CVE). + """ + + def to_representation(self, instance): + data = super().to_representation(instance) + aliases = [alias["alias"] for alias in data["aliases"]] + data["aliases"] = aliases + return data + + fixed_packages = MinimalPackageSerializer( + many=True, source="filtered_fixed_packages", read_only=True + ) + + references = VulnerabilityReferenceSerializer(many=True, source="vulnerabilityreference_set") + aliases = AliasSerializer(many=True, source="alias") + + class Meta: + model = Vulnerability + fields = ["url", "vulnerability_id", "summary", "references", "fixed_packages", "aliases"] + + +class WeaknessSerializer(serializers.HyperlinkedModelSerializer): + """ + Used for nesting inside weakness focused APIs. + """ + + class Meta: + model = Weakness + fields = ["cwe_id", "name", "description"] + + def to_representation(self, instance): + """ + Override to include 'weakness' only if it is not None. + """ + representation = super().to_representation(instance) + if instance.weakness is None: + return None + return representation + + +class VulnerabilitySerializer(BaseResourceSerializer): + fixed_packages = MinimalPackageSerializer( + many=True, source="filtered_fixed_packages", read_only=True + ) + affected_packages = MinimalPackageSerializer(many=True, read_only=True) + + references = VulnerabilityReferenceSerializer(many=True, source="vulnerabilityreference_set") + aliases = AliasSerializer(many=True, source="alias") + weaknesses = WeaknessSerializer(many=True) + + def to_representation(self, instance): + data = super().to_representation(instance) + + weaknesses = data.get("weaknesses", []) + data["weaknesses"] = [weakness for weakness in weaknesses if weakness is not None] + alias_queryset = instance.aliases.all() + data["advisory"] = [] + for itr in alias_queryset: + advisory_objects = Advisory.objects.filter(aliases=[itr.alias]) + for i in advisory_objects: + data["advisory"].append({"unique_content_id" : i.unique_content_id,"url" : i.url,"summary" : i.summary}) + return data + + class Meta: + model = Vulnerability + fields = [ + "url", + "vulnerability_id", + "summary", + "aliases", + "fixed_packages", + "affected_packages", + "references", + "weaknesses", + ] + + +class PackageSerializer(BaseResourceSerializer): + """ + Lookup software package using Package URLs + """ + + def to_representation(self, instance): + data = super().to_representation(instance) + data["qualifiers"] = normalize_qualifiers(data["qualifiers"], encode=False) + + return data + + next_non_vulnerable_version = serializers.SerializerMethodField("get_next_non_vulnerable") + + def get_next_non_vulnerable(self, package): + next_non_vulnerable = package.fixed_package_details.get("next_non_vulnerable", None) + if next_non_vulnerable: + return next_non_vulnerable.version + + latest_non_vulnerable_version = serializers.SerializerMethodField("get_latest_non_vulnerable") + + def get_latest_non_vulnerable(self, package): + latest_non_vulnerable = package.fixed_package_details.get("latest_non_vulnerable", None) + if latest_non_vulnerable: + return latest_non_vulnerable.version + + purl = serializers.CharField(source="package_url") + + affected_by_vulnerabilities = serializers.SerializerMethodField("get_affected_vulnerabilities") + + fixing_vulnerabilities = serializers.SerializerMethodField("get_fixed_vulnerabilities") + + def get_fixed_packages(self, package): + """ + Return a queryset of all packages that fix a vulnerability with + same type, namespace, name, subpath and qualifiers of the `package` + """ + return Package.objects.filter( + name=package.name, + namespace=package.namespace, + type=package.type, + qualifiers=package.qualifiers, + subpath=package.subpath, + packagerelatedvulnerability__fix=True, + ).distinct() + + def get_vulnerabilities_for_a_package(self, package, fix) -> dict: + """ + Return a mapping of vulnerabilities data related to the given `package`. + Return vulnerabilities that affect the `package` if given `fix` flag is False, + otherwise return vulnerabilities fixed by the `package`. + """ + fixed_packages = self.get_fixed_packages(package=package) + qs = package.vulnerabilities.filter(packagerelatedvulnerability__fix=fix) + qs = qs.prefetch_related( + Prefetch( + "packages", + queryset=fixed_packages, + to_attr="filtered_fixed_packages", + ) + ) + return VulnSerializerRefsAndSummary( + instance=qs, + many=True, + context={"request": self.context["request"]}, + ).data + + def get_fixed_vulnerabilities(self, package) -> dict: + """ + Return a mapping of vulnerabilities fixed in the given `package`. + """ + return self.get_vulnerabilities_for_a_package(package=package, fix=True) + + def get_affected_vulnerabilities(self, package) -> dict: + """ + Return a mapping of vulnerabilities that affect the given `package` (including packages that + fix each vulnerability and whose version is greater than the `package` version). + """ + excluded_purls = [] + package_vulnerabilities = self.get_vulnerabilities_for_a_package(package=package, fix=False) + + for vuln in package_vulnerabilities: + for pkg in vuln["fixed_packages"]: + real_purl = PackageURL.from_string(pkg["purl"]) + if package.version_class(real_purl.version) <= package.current_version: + excluded_purls.append(pkg) + + vuln["fixed_packages"] = [ + pkg for pkg in vuln["fixed_packages"] if pkg not in excluded_purls + ] + + return package_vulnerabilities + + class Meta: + model = Package + fields = [ + "url", + "purl", + "type", + "namespace", + "name", + "version", + "qualifiers", + "subpath", + "next_non_vulnerable_version", + "latest_non_vulnerable_version", + "affected_by_vulnerabilities", + "fixing_vulnerabilities", + ] + + +class PackageFilterSet(filters.FilterSet): + purl = filters.CharFilter(method="filter_purl") + + class Meta: + model = Package + fields = [ + "type", + "namespace", + "name", + "version", + "qualifiers", + "subpath", + "purl", + "packagerelatedvulnerability__fix", + ] + + def filter_purl(self, queryset, name, value): + purl = unquote(value) + try: + purl = PackageURL.from_string(purl) + + except ValueError as ve: + raise serializers.ValidationError( + detail={"error": f'"{purl}" is not a valid Package URL: {ve}'}, + ) + + lookups = get_purl_query_lookups(purl) + return self.queryset.filter(**lookups) + + +class PackageurlListSerializer(serializers.Serializer): + purls = serializers.ListField( + child=serializers.CharField(), + allow_empty=False, + help_text="List of PackageURL strings in canonical form.", + ) + + +class PackageBulkSearchRequestSerializer(PackageurlListSerializer): + purl_only = serializers.BooleanField(required=False, default=False) + plain_purl = serializers.BooleanField(required=False, default=False) + + +class LookupRequestSerializer(serializers.Serializer): + purl = serializers.CharField( + required=True, + help_text="PackageURL strings in canonical form.", + ) + + +class PackageViewSet(viewsets.ReadOnlyModelViewSet): + """ + Lookup for vulnerable packages by Package URL. + """ + + queryset = Package.objects.all() + serializer_class = PackageSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = PackageFilterSet + throttle_classes = [StaffUserRateThrottle, AnonRateThrottle] + + @extend_schema( + request=PackageBulkSearchRequestSerializer, + responses={200: PackageSerializer(many=True)}, + ) + @action( + detail=False, + methods=["post"], + serializer_class=PackageBulkSearchRequestSerializer, + filter_backends=[], + pagination_class=None, + ) + def bulk_search(self, request): + """ + Lookup for vulnerable packages using many Package URLs at once. + """ + serializer = self.serializer_class(data=request.data) + if not serializer.is_valid(): + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + "error": serializer.errors, + "message": "A non-empty 'purls' list of PURLs is required.", + }, + ) + validated_data = serializer.validated_data + purls = validated_data.get("purls") + purl_only = validated_data.get("purl_only", False) + plain_purl = validated_data.get("plain_purl", False) + + if plain_purl: + purl_objects = [PackageURL.from_string(purl) for purl in purls] + plain_purl_objects = [ + PackageURL( + type=purl.type, + namespace=purl.namespace, + name=purl.name, + version=purl.version, + ) + for purl in purl_objects + ] + plain_purls = [str(purl) for purl in plain_purl_objects] + + query = ( + Package.objects.filter(plain_package_url__in=plain_purls) + .order_by("plain_package_url") + .distinct("plain_package_url") + ) + + if not purl_only: + return Response( + PackageSerializer(query, many=True, context={"request": request}).data + ) + + # using order by and distinct because there will be + # many fully qualified purl for a single plain purl + vulnerable_purls = query.vulnerable().only("plain_package_url") + vulnerable_purls = [str(package.plain_package_url) for package in vulnerable_purls] + return Response(data=vulnerable_purls) + + query = Package.objects.filter(package_url__in=purls).distinct() + + if not purl_only: + return Response(PackageSerializer(query, many=True, context={"request": request}).data) + + vulnerable_purls = query.vulnerable().only("package_url") + vulnerable_purls = [str(package.package_url) for package in vulnerable_purls] + return Response(data=vulnerable_purls) + + @action(detail=False, methods=["get"]) + def all(self, request): + """ + Return the Package URLs of all packages known to be vulnerable. + """ + vulnerable_packages = Package.objects.vulnerable().only("package_url").distinct() + vulnerable_purls = [str(package.package_url) for package in vulnerable_packages] + return Response(vulnerable_purls) + + @extend_schema( + request=LookupRequestSerializer, + responses={200: PackageSerializer(many=True)}, + ) + @action( + detail=False, + methods=["post"], + serializer_class=LookupRequestSerializer, + filter_backends=[], + pagination_class=None, + ) + def lookup(self, request): + """ + Return the response for exact PackageURL requested for. + """ + serializer = self.serializer_class(data=request.data) + if not serializer.is_valid(): + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + "error": serializer.errors, + "message": "A 'purl' is required.", + }, + ) + validated_data = serializer.validated_data + purl = validated_data.get("purl") + + return Response( + PackageSerializer( + Package.objects.for_purls([purl]), many=True, context={"request": request} + ).data + ) + + @extend_schema( + request=PackageurlListSerializer, + responses={200: PackageSerializer(many=True)}, + ) + @action( + detail=False, + methods=["post"], + serializer_class=PackageurlListSerializer, + filter_backends=[], + pagination_class=None, + ) + def bulk_lookup(self, request): + """ + Return the response for exact PackageURLs requested for. + """ + serializer = self.serializer_class(data=request.data) + if not serializer.is_valid(): + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + "error": serializer.errors, + "message": "A non-empty 'purls' list of PURLs is required.", + }, + ) + validated_data = serializer.validated_data + purls = validated_data.get("purls") + + return Response( + PackageSerializer( + Package.objects.for_purls(purls), + many=True, + context={"request": request}, + ).data + ) + + +class VulnerabilityFilterSet(filters.FilterSet): + class Meta: + model = Vulnerability + fields = ["vulnerability_id"] + + +class VulnerabilityViewSet(viewsets.ReadOnlyModelViewSet): + """ + Lookup for vulnerabilities affecting packages. + """ + + def get_fixed_packages_qs(self): + """ + Filter the packages that fixes a vulnerability + on fields like name, namespace and type. + """ + package_filter_data = {"packagerelatedvulnerability__fix": True} + + query_params = self.request.query_params + for field_name in ["name", "namespace", "type"]: + value = query_params.get(field_name) + if value: + package_filter_data[field_name] = value + + return PackageFilterSet(package_filter_data).qs + + def get_queryset(self): + """ + Assign filtered packages queryset from `get_fixed_packages_qs` + to a custom attribute `filtered_fixed_packages` + """ + return Vulnerability.objects.prefetch_related( + "weaknesses", + Prefetch( + "packages", + queryset=self.get_fixed_packages_qs(), + to_attr="filtered_fixed_packages", + ), + ) + + serializer_class = VulnerabilitySerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = VulnerabilityFilterSet + throttle_classes = [StaffUserRateThrottle, AnonRateThrottle] + + +class CPEFilterSet(filters.FilterSet): + cpe = filters.CharFilter(method="filter_cpe") + + def filter_cpe(self, queryset, name, value): + cpe = unquote(value) + return self.queryset.filter(vulnerabilityreference__reference_id__startswith=cpe).distinct() + + +class CPEViewSet(viewsets.ReadOnlyModelViewSet): + """ + Lookup for vulnerabilities by CPE (https://nvd.nist.gov/products/cpe) + """ + + queryset = Vulnerability.objects.filter( + vulnerabilityreference__reference_id__startswith="cpe" + ).distinct() + serializer_class = VulnerabilitySerializer + filter_backends = (filters.DjangoFilterBackend,) + throttle_classes = [StaffUserRateThrottle, AnonRateThrottle] + filterset_class = CPEFilterSet + + @action(detail=False, methods=["post"]) + def bulk_search(self, request): + """ + Lookup for vulnerabilities using many CPEs at once. + """ + cpes = request.data.get("cpes", []) or [] + if not cpes or not isinstance(cpes, list): + return Response( + status=400, + data={"Error": "A non-empty 'cpes' list of CPEs is required."}, + ) + for cpe in cpes: + if not cpe.startswith("cpe"): + return Response(status=400, data={"Error": f"Invalid CPE: {cpe}"}) + vulnerabilitiesResponse = Vulnerability.objects.filter( + vulnerabilityreference__reference_id__in=cpes + ).distinct() + return Response( + VulnerabilitySerializer( + vulnerabilitiesResponse, many=True, context={"request": request} + ).data + ) + + +class AliasFilterSet(filters.FilterSet): + alias = filters.CharFilter(method="filter_alias") + + def filter_alias(self, queryset, name, value): + alias = unquote(value) + return self.queryset.filter(aliases__alias__icontains=alias) + + +class AliasViewSet(viewsets.ReadOnlyModelViewSet): + """ + Lookup for vulnerabilities by vulnerability aliases such as a CVE + (https://nvd.nist.gov/general/cve-process). + """ + + queryset = Vulnerability.objects.all() + serializer_class = VulnerabilitySerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = AliasFilterSet + throttle_classes = [StaffUserRateThrottle, AnonRateThrottle] diff --git a/vulnerabilities/tests/test_api.py b/vulnerabilities/tests/test_api.py index 2f0550571..c2ce5ec12 100644 --- a/vulnerabilities/tests/test_api.py +++ b/vulnerabilities/tests/test_api.py @@ -1,969 +1,969 @@ -# -# Copyright (c) nexB Inc. and others. All rights reserved. -# VulnerableCode is a trademark of nexB Inc. -# SPDX-License-Identifier: Apache-2.0 -# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/vulnerablecode for support or download. -# See https://aboutcode.org for more information about nexB OSS projects. -# - -import json -import os -from collections import OrderedDict -from urllib.parse import quote - -from django.test import TestCase -from django.test import TransactionTestCase -from django.test.client import RequestFactory -from packageurl import PackageURL -from rest_framework import status -from rest_framework.test import APIClient - -from vulnerabilities.api import MinimalPackageSerializer -from vulnerabilities.api import PackageSerializer -from vulnerabilities.models import Alias -from vulnerabilities.models import ApiUser -from vulnerabilities.models import Package -from vulnerabilities.models import PackageRelatedVulnerability -from vulnerabilities.models import Vulnerability -from vulnerabilities.models import Advisory -from vulnerabilities.models import VulnerabilityReference -from vulnerabilities.models import VulnerabilityRelatedReference -from vulnerabilities.models import Weakness - -from datetime import datetime - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -TEST_DATA = os.path.join(BASE_DIR, "test_data") - - -def cleaned_response(response): - """ - Return a cleaned response suitable for comparison in tests in particular: - - sort lists with a stable order - """ - cleaned_response = [] - response_copy = sorted(response, key=lambda x: x.get("purl", "")) - for package_data in response_copy: - package_data["unresolved_vulnerabilities"] = sorted( - package_data["unresolved_vulnerabilities"], key=lambda x: x["vulnerability_id"] - ) - for index, vulnerability in enumerate(package_data["unresolved_vulnerabilities"]): - package_data["unresolved_vulnerabilities"][index]["references"] = sorted( - vulnerability["references"], key=lambda x: (x["reference_id"], x["url"]) - ) - for index2, reference in enumerate( - package_data["unresolved_vulnerabilities"][index]["references"] - ): - reference["scores"] = sorted( - reference["scores"], key=lambda x: (x["value"], x["scoring_system"]) - ) - package_data["unresolved_vulnerabilities"][index]["references"][index2][ - "scores" - ] = reference["scores"] - - package_data["resolved_vulnerabilities"] = sorted( - package_data["resolved_vulnerabilities"], key=lambda x: x["vulnerability_id"] - ) - for index, vulnerability in enumerate(package_data["resolved_vulnerabilities"]): - package_data["resolved_vulnerabilities"][index]["references"] = sorted( - vulnerability["references"], key=lambda x: (x["reference_id"], x["url"]) - ) - for index2, reference in enumerate( - package_data["resolved_vulnerabilities"][index]["references"] - ): - reference["scores"] = sorted( - reference["scores"], key=lambda x: (x["value"], x["scoring_system"]) - ) - package_data["resolved_vulnerabilities"][index]["references"][index2][ - "scores" - ] = reference["scores"] - - cleaned_response.append(package_data) - - return cleaned_response - - -class TestDebianResponse(TransactionTestCase): - def setUp(self): - # create one non-debian package called "mimetex" to verify filtering - Package.objects.create(name="mimetex", version="1.50-1.1", type="deb", namespace="ubuntu") - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.client = APIClient(enforce_csrf_checks=True) - self.client.credentials(HTTP_AUTHORIZATION=self.auth) - - def test_query_qualifier_filtering(self): - # packages to check filtering with single/multiple and unordered qualifier filtering - pk_multi_qf = Package.objects.create( - name="vlc", version="1.50-1.1", type="deb", qualifiers={"foo": "bar", "tar": "ball"} - ) - pk_single_qf = Package.objects.create( - name="vlc", version="1.50-1.1", type="deb", qualifiers={"foo": "bar"} - ) - - # check filtering when qualifiers are not normalized - test_purl = quote("pkg:deb/vlc@1.50-1.1?foo=bar&tar=ball") - response = self.client.get(f"/api/packages/?purl={test_purl}", format="json").data - - self.assertEqual(2, response["count"]) - - test_purl = quote("pkg:deb/vlc@1.50-1.1?tar=ball&foo=bar") - response = self.client.get(f"/api/packages/?purl={test_purl}", format="json").data - - self.assertEqual(2, response["count"]) - - # check filtering when there is intersection of qualifiers between packages - test_purl = quote("pkg:deb/vlc@1.50-1.1?foo=bar") - response = self.client.get(f"/api/packages/?purl={test_purl}", format="json").data - - self.assertEqual(2, response["count"]) - - def test_query_by_name(self): - response = self.client.get("/api/packages/?name=mimetex", format="json").data - - self.assertEqual(1, response["count"]) - - first_result = response["results"][0] - self.assertEqual("mimetex", first_result["name"]) - - versions = {r["version"] for r in response["results"]} - self.assertIn("1.50-1.1", versions) - - purls = {r["purl"] for r in response["results"]} - self.assertIn("pkg:deb/ubuntu/mimetex@1.50-1.1", purls) - - def test_query_by_invalid_package_url(self): - url = "/api/packages/?purl=invalid_purl" - response = self.client.get(url, format="json") - - self.assertEqual(400, response.status_code) - self.assertIn("error", response.data) - error = response.data["error"] - self.assertIn("invalid_purl", error) - - def test_query_by_package_url_without_namespace(self): - url = "/api/packages/?purl=pkg:deb/mimetex@1.50-1.1" - response = self.client.get(url, format="json").data - - self.assertEqual(1, response["count"]) - - first_result = response["results"][0] - self.assertEqual("mimetex", first_result["name"]) - - purls = {r["purl"] for r in response["results"]} - self.assertIn("pkg:deb/ubuntu/mimetex@1.50-1.1", purls) - - -class TestSerializers(TransactionTestCase): - def setUp(self): - Package.objects.create( - name="mimetex", - version="1.50-1.1", - type="deb", - namespace="ubuntu", - qualifiers={"distro": "jessie"}, - ) - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.client = APIClient(enforce_csrf_checks=True) - self.client.credentials(HTTP_AUTHORIZATION=self.auth) - - def test_package_serializer(self): - pk = Package.objects.filter(name="mimetex") - mock_request = RequestFactory().get("/api") - response = PackageSerializer(pk, many=True, context={"request": mock_request}).data - self.assertEqual(1, len(response)) - - first_result = response[0] - self.assertEqual("mimetex", first_result["name"]) - - versions = {r["version"] for r in response} - self.assertIn("1.50-1.1", versions) - - purls = {r["purl"] for r in response} - self.assertIn("pkg:deb/ubuntu/mimetex@1.50-1.1?distro=jessie", purls) - - -class APITestCaseVulnerability(TransactionTestCase): - def setUp(self): - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - for i in range(0, 200): - Vulnerability.objects.create( - summary=str(i), - ) - self.vulnerability = Vulnerability.objects.create(summary="test") - self.alias = Alias.objects.create(alias='CORE-2010-0121',vulnerability=self.vulnerability) - self.pkg1 = Package.objects.create(name="flask", type="pypi", version="0.1.2") - self.pkg2 = Package.objects.create(name="flask", type="deb", version="0.1.2") - for pkg in [self.pkg1, self.pkg2]: - PackageRelatedVulnerability.objects.create( - package=pkg, vulnerability=self.vulnerability, fix=True - ) - self.weaknesses = Weakness.objects.create(cwe_id=119) - self.advisory = Advisory.objects.create(unique_content_id='6b7d417a552b19f26a5c2267ba7876c2',aliases=['CORE-2010-0121'],summary='Vulnerabilities with Windows 8.3 filename pseudonyms',affected_packages=[],url= 'https://nginx.org/en/security_advisories.html',date_collected=datetime.now(),created_by='vulnerabilities.importers.nginx.NginxImporter') - self.weaknesses.vulnerabilities.add(self.vulnerability) - self.invalid_weaknesses = Weakness.objects.create( - cwe_id=10000 - ) # cwe not present in weaknesses_db - self.invalid_weaknesses.vulnerabilities.add(self.vulnerability) - - def test_api_status(self): - response = self.csrf_client.get("/api/vulnerabilities/") - self.assertEqual(status.HTTP_200_OK, response.status_code) - - def test_api_response(self): - response = self.csrf_client.get("/api/vulnerabilities/").data - self.assertEqual(response["count"], 201) - - def test_api_with_single_vulnerability(self): - response = self.csrf_client.get( - f"/api/vulnerabilities/{self.vulnerability.id}", format="json" - ).data - - assert response == { - "url": f"http://testserver/api/vulnerabilities/{self.vulnerability.id}", - "vulnerability_id": self.vulnerability.vulnerability_id, - "summary": "test", - "aliases": [OrderedDict([('alias', 'CORE-2010-0121')])], - "resource_url": f"http://testserver/vulnerabilities/{self.vulnerability.vulnerability_id}", - "fixed_packages": [ - { - "url": f"http://testserver/api/packages/{self.pkg2.id}", - "purl": "pkg:deb/flask@0.1.2", - "is_vulnerable": False, - "affected_by_vulnerabilities": [], - "resource_url": f"http://testserver/packages/{self.pkg2.purl}", - }, - { - "url": f"http://testserver/api/packages/{self.pkg1.id}", - "purl": "pkg:pypi/flask@0.1.2", - "is_vulnerable": False, - "affected_by_vulnerabilities": [], - "resource_url": f"http://testserver/packages/{self.pkg1.purl}", - }, - ], - "affected_packages": [], - "references": [], - "weaknesses": [ - { - "cwe_id": 119, - "name": "Improper Restriction of Operations within the Bounds of a Memory Buffer", - "description": "The software performs operations on a memory buffer, but it can read from or write to a memory location that is outside of the intended boundary of the buffer.", - }, - ], - "advisory": [ - { - "unique_content_id": "6b7d417a552b19f26a5c2267ba7876c2", - "url": "https://nginx.org/en/security_advisories.html", - "summary": "Vulnerabilities with Windows 8.3 filename pseudonyms" - } - ] - } - - def test_api_with_single_vulnerability_with_filters(self): - response = self.csrf_client.get( - f"/api/vulnerabilities/{self.vulnerability.id}?type=pypi", format="json" - ).data - assert response == { - "url": f"http://testserver/api/vulnerabilities/{self.vulnerability.id}", - "vulnerability_id": self.vulnerability.vulnerability_id, - "summary": "test", - "aliases": [OrderedDict([('alias', 'CORE-2010-0121')])], - "resource_url": f"http://testserver/vulnerabilities/{self.vulnerability.vulnerability_id}", - "fixed_packages": [ - { - "url": f"http://testserver/api/packages/{self.pkg1.id}", - "purl": "pkg:pypi/flask@0.1.2", - "is_vulnerable": False, - "resource_url": f"http://testserver/packages/{self.pkg1.purl}", - "affected_by_vulnerabilities": [], - }, - ], - "affected_packages": [], - "references": [], - "weaknesses": [ - { - "cwe_id": 119, - "name": "Improper Restriction of Operations within the Bounds of a Memory Buffer", - "description": "The software performs operations on a memory buffer, but it can read from or write to a memory location that is outside of the intended boundary of the buffer.", - }, - ], - "advisory": [ - { - "unique_content_id": "6b7d417a552b19f26a5c2267ba7876c2", - "url": "https://nginx.org/en/security_advisories.html", - "summary": "Vulnerabilities with Windows 8.3 filename pseudonyms" - } - ] - } - - -class APITestCasePackage(TestCase): - def setUp(self): - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - - # searched-for pkg - self.package_maven_jackson_databind_2_13_1 = Package.objects.create( - type="maven", - namespace="com.fasterxml.jackson.core", - name="jackson-databind", - version="2.13.1", - qualifiers={}, - subpath="", - ) - - # searched-for pkg's vuln - self.vuln_VCID_2nyb_8rwu_aaag = Vulnerability.objects.create( - summary="This is VCID-2nyb-8rwu-aaag", - vulnerability_id="VCID-2nyb-8rwu-aaag", - ) - - # pkg-vuln affect relationship - PackageRelatedVulnerability.objects.create( - package=self.package_maven_jackson_databind_2_13_1, - vulnerability=self.vuln_VCID_2nyb_8rwu_aaag, - fix=False, - ) - - # vuln aliases - Alias.objects.create(alias="CVE-2020-36518", vulnerability=self.vuln_VCID_2nyb_8rwu_aaag) - Alias.objects.create( - alias="GHSA-57j2-w4cx-62h2", vulnerability=self.vuln_VCID_2nyb_8rwu_aaag - ) - - # pkg (1 of 2 -- this is a lesser version and will be omitted from the API) that fixes searched-for pkg's vuln - self.package_maven_jackson_databind_2_12_6_1 = Package.objects.create( - type="maven", - namespace="com.fasterxml.jackson.core", - name="jackson-databind", - version="2.12.6.1", - qualifiers={}, - subpath="", - ) - - # pkg-vuln fix relationship - PackageRelatedVulnerability.objects.create( - package=self.package_maven_jackson_databind_2_12_6_1, - vulnerability=self.vuln_VCID_2nyb_8rwu_aaag, - fix=True, - ) - - # fixed by pkg own vuln - self.vuln_VCID_gqhw_ngh8_aaap = Vulnerability.objects.create( - summary="This is VCID-gqhw-ngh8-aaap", - vulnerability_id="VCID-gqhw-ngh8-aaap", - ) - - # pkg-vuln affect relationship - PackageRelatedVulnerability.objects.create( - package=self.package_maven_jackson_databind_2_12_6_1, - vulnerability=self.vuln_VCID_gqhw_ngh8_aaap, - fix=False, - ) - - # pkg (2 of 2 -- this is a greater version) that fixes searched-for pkg's vuln - self.package_maven_jackson_databind_2_13_2 = Package.objects.create( - type="maven", - namespace="com.fasterxml.jackson.core", - name="jackson-databind", - version="2.13.2", - qualifiers={}, - subpath="", - ) - - # pkg-vuln fix relationship - PackageRelatedVulnerability.objects.create( - package=self.package_maven_jackson_databind_2_13_2, - vulnerability=self.vuln_VCID_2nyb_8rwu_aaag, - fix=True, - ) - - # pkg-vuln affect relationship - PackageRelatedVulnerability.objects.create( - package=self.package_maven_jackson_databind_2_13_2, - vulnerability=self.vuln_VCID_gqhw_ngh8_aaap, - fix=False, - ) - - # This is the vuln fixed by the searched-for pkg -- and by a lesser version (created below), which WILL be included in the API - self.vuln_VCID_ftmk_wbwx_aaar = Vulnerability.objects.create( - summary="This is VCID-ftmk-wbwx-aaar", - vulnerability_id="VCID-ftmk-wbwx-aaar", - ) - - # searched-for pkg-vuln fix relationship - PackageRelatedVulnerability.objects.create( - package=self.package_maven_jackson_databind_2_13_1, - vulnerability=self.vuln_VCID_ftmk_wbwx_aaar, - fix=True, - ) - - # lesser-version pkg that also fixes the vuln fixed by the searched-for pkg - self.package_maven_jackson_databind_2_12_6 = Package.objects.create( - type="maven", - namespace="com.fasterxml.jackson.core", - name="jackson-databind", - version="2.12.6", - qualifiers={}, - subpath="", - ) - - # lesser-version pkg-vuln fix relationship - PackageRelatedVulnerability.objects.create( - package=self.package_maven_jackson_databind_2_12_6, - vulnerability=self.vuln_VCID_ftmk_wbwx_aaar, - fix=True, - ) - - # aliases for vuln fixed by searched-for pkg - Alias.objects.create(alias="CVE-2021-46877", vulnerability=self.vuln_VCID_ftmk_wbwx_aaar) - Alias.objects.create( - alias="GHSA-3x8x-79m2-3w2w", vulnerability=self.vuln_VCID_ftmk_wbwx_aaar - ) - - # This addresses both next and latest non-vulnerable pkg - self.package_maven_jackson_databind_2_14_0_rc1 = Package.objects.create( - type="maven", - namespace="com.fasterxml.jackson.core", - name="jackson-databind", - version="2.14.0-rc1", - qualifiers={}, - subpath="", - ) - - def test_api_with_package_with_no_vulnerabilities(self): - affected_vulnerabilities = [] - vuln = { - "foo": "bar", - } - - package_with_no_vulnerabilities = MinimalPackageSerializer.get_vulnerability( - self, - vuln, - ) - - assert package_with_no_vulnerabilities is None - - def test_api_with_lesser_and_greater_fixed_by_packages(self): - response = self.csrf_client.get( - f"/api/packages/{self.package_maven_jackson_databind_2_13_1.id}", format="json" - ).data - - expected_response = { - "url": f"http://testserver/api/packages/{self.package_maven_jackson_databind_2_13_1.id}", - "purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.1", - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-databind", - "version": "2.13.1", - "qualifiers": {}, - "subpath": "", - "next_non_vulnerable_version": "2.14.0-rc1", - "latest_non_vulnerable_version": "2.14.0-rc1", - "affected_by_vulnerabilities": [ - OrderedDict( - [ - ( - "url", - f"http://testserver/api/vulnerabilities/{self.vuln_VCID_2nyb_8rwu_aaag.id}", - ), - ("vulnerability_id", "VCID-2nyb-8rwu-aaag"), - ("summary", "This is VCID-2nyb-8rwu-aaag"), - ("references", []), - ( - "fixed_packages", - [ - OrderedDict( - [ - ( - "url", - f"http://testserver/api/packages/{self.package_maven_jackson_databind_2_13_2.id}", - ), - ( - "purl", - "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.2", - ), - ("is_vulnerable", True), - ( - "affected_by_vulnerabilities", - [{"vulnerability": "VCID-gqhw-ngh8-aaap"}], - ), - ( - "resource_url", - "http://testserver/packages/pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.2", - ), - ] - ) - ], - ), - ("aliases", ["CVE-2020-36518", "GHSA-57j2-w4cx-62h2"]), - ("resource_url", "http://testserver/vulnerabilities/VCID-2nyb-8rwu-aaag"), - ] - ) - ], - "fixing_vulnerabilities": [ - OrderedDict( - [ - ( - "url", - f"http://testserver/api/vulnerabilities/{self.vuln_VCID_ftmk_wbwx_aaar.id}", - ), - ("vulnerability_id", "VCID-ftmk-wbwx-aaar"), - ("summary", "This is VCID-ftmk-wbwx-aaar"), - ("references", []), - ( - "fixed_packages", - [ - OrderedDict( - [ - ( - "url", - f"http://testserver/api/packages/{self.package_maven_jackson_databind_2_12_6.id}", - ), - ( - "purl", - "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.12.6", - ), - ("is_vulnerable", False), - ("affected_by_vulnerabilities", []), - ( - "resource_url", - "http://testserver/packages/pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.12.6", - ), - ] - ), - OrderedDict( - [ - ( - "url", - f"http://testserver/api/packages/{self.package_maven_jackson_databind_2_13_1.id}", - ), - ( - "purl", - "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.1", - ), - ("is_vulnerable", True), - ( - "affected_by_vulnerabilities", - [{"vulnerability": "VCID-2nyb-8rwu-aaag"}], - ), - ( - "resource_url", - "http://testserver/packages/pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.1", - ), - ] - ), - ], - ), - ("aliases", ["CVE-2021-46877", "GHSA-3x8x-79m2-3w2w"]), - ("resource_url", "http://testserver/vulnerabilities/VCID-ftmk-wbwx-aaar"), - ] - ) - ], - "resource_url": "http://testserver/packages/pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.1", - } - - assert response == expected_response - - def test_is_vulnerable_attribute(self): - self.assertTrue(self.package_maven_jackson_databind_2_13_1.is_vulnerable) - - def test_api_status(self): - response = self.csrf_client.get("/api/packages/", format="json") - self.assertEqual(status.HTTP_200_OK, response.status_code) - - def test_api_response(self): - response = self.csrf_client.get("/api/packages/", format="json").data - self.assertEqual(response["count"], 5) - - def test_api_with_namespace_filter(self): - response = self.csrf_client.get( - "/api/packages/?namespace=com.fasterxml.jackson.core", format="json" - ).data - self.assertEqual(response["count"], 5) - - def test_api_with_wrong_namespace_filter(self): - response = self.csrf_client.get("/api/packages/?namespace=foo-bar", format="json").data - self.assertEqual(response["count"], 0) - - def test_api_with_all_vulnerable_packages(self): - with self.assertNumQueries(4): - # There are 4 queries: - # 1. SAVEPOINT - # 2. Authenticating user - # 3. Get all vulnerable packages - # 4. RELEASE SAVEPOINT - response = self.csrf_client.get(f"/api/packages/all", format="json").data - - assert len(response) == 3 - assert response == [ - "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.12.6.1", - "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.1", - "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.2", - ] - - def test_api_with_ignorning_qualifiers(self): - response = self.csrf_client.get( - f"/api/packages/?purl=pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.14.0-rc1?foo=bar", - format="json", - ).data - assert response["count"] == 1 - assert ( - response["results"][0]["purl"] - == "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.14.0-rc1" - ) - - -class CPEApi(TestCase): - def setUp(self): - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - self.vulnerability = Vulnerability.objects.create(summary="test") - for i in range(0, 10): - ref, _ = VulnerabilityReference.objects.get_or_create( - reference_id=f"cpe:/a:nginx:{i}", - url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query=cpe:/a:nginx:{i}", - ) - VulnerabilityRelatedReference.objects.create( - reference=ref, vulnerability=self.vulnerability - ) - - def test_api_status(self): - response = self.csrf_client.get("/api/cpes/", format="json") - self.assertEqual(status.HTTP_200_OK, response.status_code) - - def test_api_response(self): - response = self.csrf_client.get("/api/cpes/?cpe=cpe:/a:nginx:9", format="json").data - self.assertEqual(response["count"], 1) - - -class AliasApi(TestCase): - def setUp(self): - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - self.vulnerability = Vulnerability.objects.create(summary="test") - for i in range(0, 10): - Alias.objects.create(alias=f"CVE-{i}", vulnerability=self.vulnerability) - - def test_api_status(self): - response = self.csrf_client.get("/api/aliases/", format="json") - self.assertEqual(status.HTTP_200_OK, response.status_code) - - def test_api_response(self): - response = self.csrf_client.get("/api/aliases?alias=CVE-9", format="json").data - self.assertEqual(response["count"], 1) - - -class BulkSearchAPIPackage(TestCase): - def setUp(self): - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - packages = [ - "pkg:nginx/nginx@0.6.18", - "pkg:nginx/nginx@1.20.0", - "pkg:nginx/nginx@1.21.0", - "pkg:nginx/nginx@1.20.1", - "pkg:nginx/nginx@1.9.5", - "pkg:nginx/nginx@1.17.2", - "pkg:nginx/nginx@1.17.3", - "pkg:nginx/nginx@1.16.1", - "pkg:nginx/nginx@1.15.5", - "pkg:nginx/nginx@1.15.6", - "pkg:nginx/nginx@1.14.1", - "pkg:nginx/nginx@1.0.7", - "pkg:nginx/nginx@1.0.15", - ] - self.packages = packages - for package in packages: - purl = PackageURL.from_string(package) - attrs = {k: v for k, v in purl.to_dict().items() if v} - Package.objects.create(**attrs) - - vulnerable_packages = [ - "pkg:nginx/nginx@1.0.15?foo=bar", - "pkg:nginx/nginx@1.0.15?foo=baz", - ] - - vuln = Vulnerability.objects.create(summary="test") - - for package in vulnerable_packages: - purl = PackageURL.from_string(package) - attrs = {k: v for k, v in purl.to_dict().items() if v} - pkg = Package.objects.create(**attrs) - PackageRelatedVulnerability.objects.create(package=pkg, vulnerability=vuln) - - def test_bulk_api_response(self): - request_body = { - "purls": self.packages, - } - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 13 - - def test_bulk_api_response_with_ignoring_qualifiers(self): - request_body = {"purls": ["pkg:nginx/nginx@1.0.15?qualifiers=dev"], "plain_purl": True} - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" - - def test_bulk_api_response_with_ignoring_subpath(self): - request_body = {"purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], "plain_purl": True} - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" - - def test_bulk_api_with_purl_only_option(self): - request_body = { - "purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], - "purl_only": True, - "plain_purl": True, - } - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - assert response[0] == "pkg:nginx/nginx@1.0.15" - - def test_bulk_api_without_purls_list(self): - request_body = { - "purls": None, - } - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - - expected = { - "error": {"purls": ["This field may not be null."]}, - "message": "A non-empty 'purls' list of PURLs is required.", - } - - self.assertEqual(response, expected) - - def test_bulk_api_without_purls_empty_list(self): - request_body = { - "purls": [], - } - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - - expected = { - "error": {"purls": ["This list may not be empty."]}, - "message": "A non-empty 'purls' list of PURLs is required.", - } - - self.assertEqual(response, expected) - - def test_bulk_api_with_empty_request_body(self): - request_body = {} - response = self.csrf_client.post( - "/api/packages/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - - expected = { - "error": {"purls": ["This field is required."]}, - "message": "A non-empty 'purls' list of PURLs is required.", - } - - self.assertEqual(response, expected) - - -class BulkSearchAPICPE(TestCase): - def setUp(self): - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - self.exclusive_cpes = [ - "cpe:/a:nginx:1.0.7", - "cpe:/a:nginx:1.0.15", - "cpe:/a:nginx:1.14.1", - "cpe:/a:nginx:1.15.5", - "cpe:/a:nginx:1.15.6", - ] - vuln = Vulnerability.objects.create(summary="test") - for cpe in self.exclusive_cpes: - ref = VulnerabilityReference.objects.create( - reference_id=cpe, - url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", - ) - VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=vuln) - second_vuln = Vulnerability.objects.create(summary="test-A") - self.non_exclusive_cpes = [ - "cpe:/a:nginx:1.16.1", - "cpe:/a:nginx:1.17.2", - "cpe:/a:nginx:1.17.3", - "cpe:/a:nginx:1.9.5", - "cpe:/a:nginx:1.20.1", - "cpe:/a:nginx:1.20.0", - "cpe:/a:nginx:1.21.0", - ] - third_vuln = Vulnerability.objects.create(summary="test-B") - for cpe in self.non_exclusive_cpes: - ref = VulnerabilityReference.objects.create( - reference_id=cpe, - url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", - ) - VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=second_vuln) - VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=third_vuln) - - def test_api_response_with_with_exclusive_cpes_associated_with_two_vulnerabilities(self): - request_body = { - "cpes": self.exclusive_cpes, - } - response = self.csrf_client.post( - "/api/cpes/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - assert response[0]["summary"] == "test" - references_in_vuln = response[0]["references"] - cpes = [ref["reference_id"] for ref in references_in_vuln] - assert set(cpes) == set(self.exclusive_cpes) - - def test_api_response_with_no_cpe_associated(self): - request_body = { - "cpes": ["cpe:/a:nginx:1.10.7"], - } - response = self.csrf_client.post( - "/api/cpes/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 0 - - def test_api_response_with_with_non_exclusive_cpes_associated_with_two_vulnerabilities(self): - request_body = { - "cpes": self.non_exclusive_cpes, - } - response = self.csrf_client.post( - "/api/cpes/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 2 - - def test_with_empty_list(self): - request_body = { - "cpes": [], - } - response = self.csrf_client.post( - "/api/cpes/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert response == {"Error": "A non-empty 'cpes' list of CPEs is required."} - - def test_with_invalid_cpes(self): - request_body = {"cpes": ["CVE-2022-2022"]} - response = self.csrf_client.post( - "/api/cpes/bulk_search", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert response == {"Error": "Invalid CPE: CVE-2022-2022"} - - -class TesBanUserAgent(TestCase): - def test_ban_request_with_bytedance_user_agent(self): - response = self.client.get(f"/api/packages", format="json", HTTP_USER_AGENT="bytedance") - assert 404 == response.status_code - - -class TestLookup(TestCase): - def setUp(self): - Package.objects.create( - type="pypi", namespace="", name="microweber/microweber", version="1.2" - ) - self.user = ApiUser.objects.create_api_user(username="e@mail.com") - self.auth = f"Token {self.user.auth_token.key}" - self.csrf_client = APIClient(enforce_csrf_checks=True) - self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) - - def test_lookup_endpoint_failure(self): - request_body = {"purl": None} - response = self.csrf_client.post( - "/api/packages/lookup", - data=json.dumps(request_body), - content_type="application/json", - ).json() - - expected = { - "error": {"purl": ["This field may not be null."]}, - "message": "A 'purl' is required.", - } - - self.assertEqual(response, expected) - - def test_lookup_endpoint(self): - request_body = {"purl": "pkg:pypi/microweber/microweber@1.2"} - response = self.csrf_client.post( - "/api/packages/lookup", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - assert response[0]["purl"] == "pkg:pypi/microweber/microweber@1.2" - - def test_bulk_lookup_endpoint(self): - request_body = { - "purls": [ - "pkg:pypi/microweber/microweber@1.2?foo=bar", - "pkg:pypi/microweber/microweber@1.2", - "pkg:pypi/foo/bar@1.0", - ], - } - response = self.csrf_client.post( - "/api/packages/bulk_lookup", - data=json.dumps(request_body), - content_type="application/json", - ).json() - assert len(response) == 1 - - def test_bulk_lookup_endpoint_failure(self): - request_body = {"purls": None} - response = self.csrf_client.post( - "/api/packages/bulk_lookup", - data=json.dumps(request_body), - content_type="application/json", - ).json() - - expected = { - "error": {"purls": ["This field may not be null."]}, - "message": "A non-empty 'purls' list of PURLs is required.", - } - - self.assertEqual(response, expected) +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +import json +import os +from collections import OrderedDict +from urllib.parse import quote + +from django.test import TestCase +from django.test import TransactionTestCase +from django.test.client import RequestFactory +from packageurl import PackageURL +from rest_framework import status +from rest_framework.test import APIClient + +from vulnerabilities.api import MinimalPackageSerializer +from vulnerabilities.api import PackageSerializer +from vulnerabilities.models import Alias +from vulnerabilities.models import ApiUser +from vulnerabilities.models import Package +from vulnerabilities.models import PackageRelatedVulnerability +from vulnerabilities.models import Vulnerability +from vulnerabilities.models import Advisory +from vulnerabilities.models import VulnerabilityReference +from vulnerabilities.models import VulnerabilityRelatedReference +from vulnerabilities.models import Weakness + +from datetime import datetime + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +TEST_DATA = os.path.join(BASE_DIR, "test_data") + + +def cleaned_response(response): + """ + Return a cleaned response suitable for comparison in tests in particular: + - sort lists with a stable order + """ + cleaned_response = [] + response_copy = sorted(response, key=lambda x: x.get("purl", "")) + for package_data in response_copy: + package_data["unresolved_vulnerabilities"] = sorted( + package_data["unresolved_vulnerabilities"], key=lambda x: x["vulnerability_id"] + ) + for index, vulnerability in enumerate(package_data["unresolved_vulnerabilities"]): + package_data["unresolved_vulnerabilities"][index]["references"] = sorted( + vulnerability["references"], key=lambda x: (x["reference_id"], x["url"]) + ) + for index2, reference in enumerate( + package_data["unresolved_vulnerabilities"][index]["references"] + ): + reference["scores"] = sorted( + reference["scores"], key=lambda x: (x["value"], x["scoring_system"]) + ) + package_data["unresolved_vulnerabilities"][index]["references"][index2][ + "scores" + ] = reference["scores"] + + package_data["resolved_vulnerabilities"] = sorted( + package_data["resolved_vulnerabilities"], key=lambda x: x["vulnerability_id"] + ) + for index, vulnerability in enumerate(package_data["resolved_vulnerabilities"]): + package_data["resolved_vulnerabilities"][index]["references"] = sorted( + vulnerability["references"], key=lambda x: (x["reference_id"], x["url"]) + ) + for index2, reference in enumerate( + package_data["resolved_vulnerabilities"][index]["references"] + ): + reference["scores"] = sorted( + reference["scores"], key=lambda x: (x["value"], x["scoring_system"]) + ) + package_data["resolved_vulnerabilities"][index]["references"][index2][ + "scores" + ] = reference["scores"] + + cleaned_response.append(package_data) + + return cleaned_response + + +class TestDebianResponse(TransactionTestCase): + def setUp(self): + # create one non-debian package called "mimetex" to verify filtering + Package.objects.create(name="mimetex", version="1.50-1.1", type="deb", namespace="ubuntu") + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.client = APIClient(enforce_csrf_checks=True) + self.client.credentials(HTTP_AUTHORIZATION=self.auth) + + def test_query_qualifier_filtering(self): + # packages to check filtering with single/multiple and unordered qualifier filtering + pk_multi_qf = Package.objects.create( + name="vlc", version="1.50-1.1", type="deb", qualifiers={"foo": "bar", "tar": "ball"} + ) + pk_single_qf = Package.objects.create( + name="vlc", version="1.50-1.1", type="deb", qualifiers={"foo": "bar"} + ) + + # check filtering when qualifiers are not normalized + test_purl = quote("pkg:deb/vlc@1.50-1.1?foo=bar&tar=ball") + response = self.client.get(f"/api/packages/?purl={test_purl}", format="json").data + + self.assertEqual(2, response["count"]) + + test_purl = quote("pkg:deb/vlc@1.50-1.1?tar=ball&foo=bar") + response = self.client.get(f"/api/packages/?purl={test_purl}", format="json").data + + self.assertEqual(2, response["count"]) + + # check filtering when there is intersection of qualifiers between packages + test_purl = quote("pkg:deb/vlc@1.50-1.1?foo=bar") + response = self.client.get(f"/api/packages/?purl={test_purl}", format="json").data + + self.assertEqual(2, response["count"]) + + def test_query_by_name(self): + response = self.client.get("/api/packages/?name=mimetex", format="json").data + + self.assertEqual(1, response["count"]) + + first_result = response["results"][0] + self.assertEqual("mimetex", first_result["name"]) + + versions = {r["version"] for r in response["results"]} + self.assertIn("1.50-1.1", versions) + + purls = {r["purl"] for r in response["results"]} + self.assertIn("pkg:deb/ubuntu/mimetex@1.50-1.1", purls) + + def test_query_by_invalid_package_url(self): + url = "/api/packages/?purl=invalid_purl" + response = self.client.get(url, format="json") + + self.assertEqual(400, response.status_code) + self.assertIn("error", response.data) + error = response.data["error"] + self.assertIn("invalid_purl", error) + + def test_query_by_package_url_without_namespace(self): + url = "/api/packages/?purl=pkg:deb/mimetex@1.50-1.1" + response = self.client.get(url, format="json").data + + self.assertEqual(1, response["count"]) + + first_result = response["results"][0] + self.assertEqual("mimetex", first_result["name"]) + + purls = {r["purl"] for r in response["results"]} + self.assertIn("pkg:deb/ubuntu/mimetex@1.50-1.1", purls) + + +class TestSerializers(TransactionTestCase): + def setUp(self): + Package.objects.create( + name="mimetex", + version="1.50-1.1", + type="deb", + namespace="ubuntu", + qualifiers={"distro": "jessie"}, + ) + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.client = APIClient(enforce_csrf_checks=True) + self.client.credentials(HTTP_AUTHORIZATION=self.auth) + + def test_package_serializer(self): + pk = Package.objects.filter(name="mimetex") + mock_request = RequestFactory().get("/api") + response = PackageSerializer(pk, many=True, context={"request": mock_request}).data + self.assertEqual(1, len(response)) + + first_result = response[0] + self.assertEqual("mimetex", first_result["name"]) + + versions = {r["version"] for r in response} + self.assertIn("1.50-1.1", versions) + + purls = {r["purl"] for r in response} + self.assertIn("pkg:deb/ubuntu/mimetex@1.50-1.1?distro=jessie", purls) + + +class APITestCaseVulnerability(TransactionTestCase): + def setUp(self): + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + for i in range(0, 200): + Vulnerability.objects.create( + summary=str(i), + ) + self.vulnerability = Vulnerability.objects.create(summary="test") + self.alias = Alias.objects.create(alias='CORE-2010-0121',vulnerability=self.vulnerability) + self.pkg1 = Package.objects.create(name="flask", type="pypi", version="0.1.2") + self.pkg2 = Package.objects.create(name="flask", type="deb", version="0.1.2") + for pkg in [self.pkg1, self.pkg2]: + PackageRelatedVulnerability.objects.create( + package=pkg, vulnerability=self.vulnerability, fix=True + ) + self.weaknesses = Weakness.objects.create(cwe_id=119) + self.advisory = Advisory.objects.create(unique_content_id='6b7d417a552b19f26a5c2267ba7876c2',aliases=['CORE-2010-0121'],summary='Vulnerabilities with Windows 8.3 filename pseudonyms',affected_packages=[],url= 'https://nginx.org/en/security_advisories.html',date_collected=datetime.now(),created_by='vulnerabilities.importers.nginx.NginxImporter') + self.weaknesses.vulnerabilities.add(self.vulnerability) + self.invalid_weaknesses = Weakness.objects.create( + cwe_id=10000 + ) # cwe not present in weaknesses_db + self.invalid_weaknesses.vulnerabilities.add(self.vulnerability) + + def test_api_status(self): + response = self.csrf_client.get("/api/vulnerabilities/") + self.assertEqual(status.HTTP_200_OK, response.status_code) + + def test_api_response(self): + response = self.csrf_client.get("/api/vulnerabilities/").data + self.assertEqual(response["count"], 201) + + def test_api_with_single_vulnerability(self): + response = self.csrf_client.get( + f"/api/vulnerabilities/{self.vulnerability.id}", format="json" + ).data + + assert response == { + "url": f"http://testserver/api/vulnerabilities/{self.vulnerability.id}", + "vulnerability_id": self.vulnerability.vulnerability_id, + "summary": "test", + "aliases": [OrderedDict([('alias', 'CORE-2010-0121')])], + "resource_url": f"http://testserver/vulnerabilities/{self.vulnerability.vulnerability_id}", + "fixed_packages": [ + { + "url": f"http://testserver/api/packages/{self.pkg2.id}", + "purl": "pkg:deb/flask@0.1.2", + "is_vulnerable": False, + "affected_by_vulnerabilities": [], + "resource_url": f"http://testserver/packages/{self.pkg2.purl}", + }, + { + "url": f"http://testserver/api/packages/{self.pkg1.id}", + "purl": "pkg:pypi/flask@0.1.2", + "is_vulnerable": False, + "affected_by_vulnerabilities": [], + "resource_url": f"http://testserver/packages/{self.pkg1.purl}", + }, + ], + "affected_packages": [], + "references": [], + "weaknesses": [ + { + "cwe_id": 119, + "name": "Improper Restriction of Operations within the Bounds of a Memory Buffer", + "description": "The software performs operations on a memory buffer, but it can read from or write to a memory location that is outside of the intended boundary of the buffer.", + }, + ], + "advisory": [ + { + "unique_content_id": "6b7d417a552b19f26a5c2267ba7876c2", + "url": "https://nginx.org/en/security_advisories.html", + "summary": "Vulnerabilities with Windows 8.3 filename pseudonyms" + } + ] + } + + def test_api_with_single_vulnerability_with_filters(self): + response = self.csrf_client.get( + f"/api/vulnerabilities/{self.vulnerability.id}?type=pypi", format="json" + ).data + assert response == { + "url": f"http://testserver/api/vulnerabilities/{self.vulnerability.id}", + "vulnerability_id": self.vulnerability.vulnerability_id, + "summary": "test", + "aliases": [OrderedDict([('alias', 'CORE-2010-0121')])], + "resource_url": f"http://testserver/vulnerabilities/{self.vulnerability.vulnerability_id}", + "fixed_packages": [ + { + "url": f"http://testserver/api/packages/{self.pkg1.id}", + "purl": "pkg:pypi/flask@0.1.2", + "is_vulnerable": False, + "resource_url": f"http://testserver/packages/{self.pkg1.purl}", + "affected_by_vulnerabilities": [], + }, + ], + "affected_packages": [], + "references": [], + "weaknesses": [ + { + "cwe_id": 119, + "name": "Improper Restriction of Operations within the Bounds of a Memory Buffer", + "description": "The software performs operations on a memory buffer, but it can read from or write to a memory location that is outside of the intended boundary of the buffer.", + }, + ], + "advisory": [ + { + "unique_content_id": "6b7d417a552b19f26a5c2267ba7876c2", + "url": "https://nginx.org/en/security_advisories.html", + "summary": "Vulnerabilities with Windows 8.3 filename pseudonyms" + } + ] + } + + +class APITestCasePackage(TestCase): + def setUp(self): + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + + # searched-for pkg + self.package_maven_jackson_databind_2_13_1 = Package.objects.create( + type="maven", + namespace="com.fasterxml.jackson.core", + name="jackson-databind", + version="2.13.1", + qualifiers={}, + subpath="", + ) + + # searched-for pkg's vuln + self.vuln_VCID_2nyb_8rwu_aaag = Vulnerability.objects.create( + summary="This is VCID-2nyb-8rwu-aaag", + vulnerability_id="VCID-2nyb-8rwu-aaag", + ) + + # pkg-vuln affect relationship + PackageRelatedVulnerability.objects.create( + package=self.package_maven_jackson_databind_2_13_1, + vulnerability=self.vuln_VCID_2nyb_8rwu_aaag, + fix=False, + ) + + # vuln aliases + Alias.objects.create(alias="CVE-2020-36518", vulnerability=self.vuln_VCID_2nyb_8rwu_aaag) + Alias.objects.create( + alias="GHSA-57j2-w4cx-62h2", vulnerability=self.vuln_VCID_2nyb_8rwu_aaag + ) + + # pkg (1 of 2 -- this is a lesser version and will be omitted from the API) that fixes searched-for pkg's vuln + self.package_maven_jackson_databind_2_12_6_1 = Package.objects.create( + type="maven", + namespace="com.fasterxml.jackson.core", + name="jackson-databind", + version="2.12.6.1", + qualifiers={}, + subpath="", + ) + + # pkg-vuln fix relationship + PackageRelatedVulnerability.objects.create( + package=self.package_maven_jackson_databind_2_12_6_1, + vulnerability=self.vuln_VCID_2nyb_8rwu_aaag, + fix=True, + ) + + # fixed by pkg own vuln + self.vuln_VCID_gqhw_ngh8_aaap = Vulnerability.objects.create( + summary="This is VCID-gqhw-ngh8-aaap", + vulnerability_id="VCID-gqhw-ngh8-aaap", + ) + + # pkg-vuln affect relationship + PackageRelatedVulnerability.objects.create( + package=self.package_maven_jackson_databind_2_12_6_1, + vulnerability=self.vuln_VCID_gqhw_ngh8_aaap, + fix=False, + ) + + # pkg (2 of 2 -- this is a greater version) that fixes searched-for pkg's vuln + self.package_maven_jackson_databind_2_13_2 = Package.objects.create( + type="maven", + namespace="com.fasterxml.jackson.core", + name="jackson-databind", + version="2.13.2", + qualifiers={}, + subpath="", + ) + + # pkg-vuln fix relationship + PackageRelatedVulnerability.objects.create( + package=self.package_maven_jackson_databind_2_13_2, + vulnerability=self.vuln_VCID_2nyb_8rwu_aaag, + fix=True, + ) + + # pkg-vuln affect relationship + PackageRelatedVulnerability.objects.create( + package=self.package_maven_jackson_databind_2_13_2, + vulnerability=self.vuln_VCID_gqhw_ngh8_aaap, + fix=False, + ) + + # This is the vuln fixed by the searched-for pkg -- and by a lesser version (created below), which WILL be included in the API + self.vuln_VCID_ftmk_wbwx_aaar = Vulnerability.objects.create( + summary="This is VCID-ftmk-wbwx-aaar", + vulnerability_id="VCID-ftmk-wbwx-aaar", + ) + + # searched-for pkg-vuln fix relationship + PackageRelatedVulnerability.objects.create( + package=self.package_maven_jackson_databind_2_13_1, + vulnerability=self.vuln_VCID_ftmk_wbwx_aaar, + fix=True, + ) + + # lesser-version pkg that also fixes the vuln fixed by the searched-for pkg + self.package_maven_jackson_databind_2_12_6 = Package.objects.create( + type="maven", + namespace="com.fasterxml.jackson.core", + name="jackson-databind", + version="2.12.6", + qualifiers={}, + subpath="", + ) + + # lesser-version pkg-vuln fix relationship + PackageRelatedVulnerability.objects.create( + package=self.package_maven_jackson_databind_2_12_6, + vulnerability=self.vuln_VCID_ftmk_wbwx_aaar, + fix=True, + ) + + # aliases for vuln fixed by searched-for pkg + Alias.objects.create(alias="CVE-2021-46877", vulnerability=self.vuln_VCID_ftmk_wbwx_aaar) + Alias.objects.create( + alias="GHSA-3x8x-79m2-3w2w", vulnerability=self.vuln_VCID_ftmk_wbwx_aaar + ) + + # This addresses both next and latest non-vulnerable pkg + self.package_maven_jackson_databind_2_14_0_rc1 = Package.objects.create( + type="maven", + namespace="com.fasterxml.jackson.core", + name="jackson-databind", + version="2.14.0-rc1", + qualifiers={}, + subpath="", + ) + + def test_api_with_package_with_no_vulnerabilities(self): + affected_vulnerabilities = [] + vuln = { + "foo": "bar", + } + + package_with_no_vulnerabilities = MinimalPackageSerializer.get_vulnerability( + self, + vuln, + ) + + assert package_with_no_vulnerabilities is None + + def test_api_with_lesser_and_greater_fixed_by_packages(self): + response = self.csrf_client.get( + f"/api/packages/{self.package_maven_jackson_databind_2_13_1.id}", format="json" + ).data + + expected_response = { + "url": f"http://testserver/api/packages/{self.package_maven_jackson_databind_2_13_1.id}", + "purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.1", + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-databind", + "version": "2.13.1", + "qualifiers": {}, + "subpath": "", + "next_non_vulnerable_version": "2.14.0-rc1", + "latest_non_vulnerable_version": "2.14.0-rc1", + "affected_by_vulnerabilities": [ + OrderedDict( + [ + ( + "url", + f"http://testserver/api/vulnerabilities/{self.vuln_VCID_2nyb_8rwu_aaag.id}", + ), + ("vulnerability_id", "VCID-2nyb-8rwu-aaag"), + ("summary", "This is VCID-2nyb-8rwu-aaag"), + ("references", []), + ( + "fixed_packages", + [ + OrderedDict( + [ + ( + "url", + f"http://testserver/api/packages/{self.package_maven_jackson_databind_2_13_2.id}", + ), + ( + "purl", + "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.2", + ), + ("is_vulnerable", True), + ( + "affected_by_vulnerabilities", + [{"vulnerability": "VCID-gqhw-ngh8-aaap"}], + ), + ( + "resource_url", + "http://testserver/packages/pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.2", + ), + ] + ) + ], + ), + ("aliases", ["CVE-2020-36518", "GHSA-57j2-w4cx-62h2"]), + ("resource_url", "http://testserver/vulnerabilities/VCID-2nyb-8rwu-aaag"), + ] + ) + ], + "fixing_vulnerabilities": [ + OrderedDict( + [ + ( + "url", + f"http://testserver/api/vulnerabilities/{self.vuln_VCID_ftmk_wbwx_aaar.id}", + ), + ("vulnerability_id", "VCID-ftmk-wbwx-aaar"), + ("summary", "This is VCID-ftmk-wbwx-aaar"), + ("references", []), + ( + "fixed_packages", + [ + OrderedDict( + [ + ( + "url", + f"http://testserver/api/packages/{self.package_maven_jackson_databind_2_12_6.id}", + ), + ( + "purl", + "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.12.6", + ), + ("is_vulnerable", False), + ("affected_by_vulnerabilities", []), + ( + "resource_url", + "http://testserver/packages/pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.12.6", + ), + ] + ), + OrderedDict( + [ + ( + "url", + f"http://testserver/api/packages/{self.package_maven_jackson_databind_2_13_1.id}", + ), + ( + "purl", + "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.1", + ), + ("is_vulnerable", True), + ( + "affected_by_vulnerabilities", + [{"vulnerability": "VCID-2nyb-8rwu-aaag"}], + ), + ( + "resource_url", + "http://testserver/packages/pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.1", + ), + ] + ), + ], + ), + ("aliases", ["CVE-2021-46877", "GHSA-3x8x-79m2-3w2w"]), + ("resource_url", "http://testserver/vulnerabilities/VCID-ftmk-wbwx-aaar"), + ] + ) + ], + "resource_url": "http://testserver/packages/pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.1", + } + + assert response == expected_response + + def test_is_vulnerable_attribute(self): + self.assertTrue(self.package_maven_jackson_databind_2_13_1.is_vulnerable) + + def test_api_status(self): + response = self.csrf_client.get("/api/packages/", format="json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + + def test_api_response(self): + response = self.csrf_client.get("/api/packages/", format="json").data + self.assertEqual(response["count"], 5) + + def test_api_with_namespace_filter(self): + response = self.csrf_client.get( + "/api/packages/?namespace=com.fasterxml.jackson.core", format="json" + ).data + self.assertEqual(response["count"], 5) + + def test_api_with_wrong_namespace_filter(self): + response = self.csrf_client.get("/api/packages/?namespace=foo-bar", format="json").data + self.assertEqual(response["count"], 0) + + def test_api_with_all_vulnerable_packages(self): + with self.assertNumQueries(4): + # There are 4 queries: + # 1. SAVEPOINT + # 2. Authenticating user + # 3. Get all vulnerable packages + # 4. RELEASE SAVEPOINT + response = self.csrf_client.get(f"/api/packages/all", format="json").data + + assert len(response) == 3 + assert response == [ + "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.12.6.1", + "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.1", + "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.2", + ] + + def test_api_with_ignorning_qualifiers(self): + response = self.csrf_client.get( + f"/api/packages/?purl=pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.14.0-rc1?foo=bar", + format="json", + ).data + assert response["count"] == 1 + assert ( + response["results"][0]["purl"] + == "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.14.0-rc1" + ) + + +class CPEApi(TestCase): + def setUp(self): + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + self.vulnerability = Vulnerability.objects.create(summary="test") + for i in range(0, 10): + ref, _ = VulnerabilityReference.objects.get_or_create( + reference_id=f"cpe:/a:nginx:{i}", + url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query=cpe:/a:nginx:{i}", + ) + VulnerabilityRelatedReference.objects.create( + reference=ref, vulnerability=self.vulnerability + ) + + def test_api_status(self): + response = self.csrf_client.get("/api/cpes/", format="json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + + def test_api_response(self): + response = self.csrf_client.get("/api/cpes/?cpe=cpe:/a:nginx:9", format="json").data + self.assertEqual(response["count"], 1) + + +class AliasApi(TestCase): + def setUp(self): + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + self.vulnerability = Vulnerability.objects.create(summary="test") + for i in range(0, 10): + Alias.objects.create(alias=f"CVE-{i}", vulnerability=self.vulnerability) + + def test_api_status(self): + response = self.csrf_client.get("/api/aliases/", format="json") + self.assertEqual(status.HTTP_200_OK, response.status_code) + + def test_api_response(self): + response = self.csrf_client.get("/api/aliases?alias=CVE-9", format="json").data + self.assertEqual(response["count"], 1) + + +class BulkSearchAPIPackage(TestCase): + def setUp(self): + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + packages = [ + "pkg:nginx/nginx@0.6.18", + "pkg:nginx/nginx@1.20.0", + "pkg:nginx/nginx@1.21.0", + "pkg:nginx/nginx@1.20.1", + "pkg:nginx/nginx@1.9.5", + "pkg:nginx/nginx@1.17.2", + "pkg:nginx/nginx@1.17.3", + "pkg:nginx/nginx@1.16.1", + "pkg:nginx/nginx@1.15.5", + "pkg:nginx/nginx@1.15.6", + "pkg:nginx/nginx@1.14.1", + "pkg:nginx/nginx@1.0.7", + "pkg:nginx/nginx@1.0.15", + ] + self.packages = packages + for package in packages: + purl = PackageURL.from_string(package) + attrs = {k: v for k, v in purl.to_dict().items() if v} + Package.objects.create(**attrs) + + vulnerable_packages = [ + "pkg:nginx/nginx@1.0.15?foo=bar", + "pkg:nginx/nginx@1.0.15?foo=baz", + ] + + vuln = Vulnerability.objects.create(summary="test") + + for package in vulnerable_packages: + purl = PackageURL.from_string(package) + attrs = {k: v for k, v in purl.to_dict().items() if v} + pkg = Package.objects.create(**attrs) + PackageRelatedVulnerability.objects.create(package=pkg, vulnerability=vuln) + + def test_bulk_api_response(self): + request_body = { + "purls": self.packages, + } + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 13 + + def test_bulk_api_response_with_ignoring_qualifiers(self): + request_body = {"purls": ["pkg:nginx/nginx@1.0.15?qualifiers=dev"], "plain_purl": True} + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" + + def test_bulk_api_response_with_ignoring_subpath(self): + request_body = {"purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], "plain_purl": True} + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + assert response[0]["purl"] == "pkg:nginx/nginx@1.0.15" + + def test_bulk_api_with_purl_only_option(self): + request_body = { + "purls": ["pkg:nginx/nginx@1.0.15#dev/subpath"], + "purl_only": True, + "plain_purl": True, + } + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + assert response[0] == "pkg:nginx/nginx@1.0.15" + + def test_bulk_api_without_purls_list(self): + request_body = { + "purls": None, + } + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + + expected = { + "error": {"purls": ["This field may not be null."]}, + "message": "A non-empty 'purls' list of PURLs is required.", + } + + self.assertEqual(response, expected) + + def test_bulk_api_without_purls_empty_list(self): + request_body = { + "purls": [], + } + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + + expected = { + "error": {"purls": ["This list may not be empty."]}, + "message": "A non-empty 'purls' list of PURLs is required.", + } + + self.assertEqual(response, expected) + + def test_bulk_api_with_empty_request_body(self): + request_body = {} + response = self.csrf_client.post( + "/api/packages/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + + expected = { + "error": {"purls": ["This field is required."]}, + "message": "A non-empty 'purls' list of PURLs is required.", + } + + self.assertEqual(response, expected) + + +class BulkSearchAPICPE(TestCase): + def setUp(self): + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + self.exclusive_cpes = [ + "cpe:/a:nginx:1.0.7", + "cpe:/a:nginx:1.0.15", + "cpe:/a:nginx:1.14.1", + "cpe:/a:nginx:1.15.5", + "cpe:/a:nginx:1.15.6", + ] + vuln = Vulnerability.objects.create(summary="test") + for cpe in self.exclusive_cpes: + ref = VulnerabilityReference.objects.create( + reference_id=cpe, + url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", + ) + VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=vuln) + second_vuln = Vulnerability.objects.create(summary="test-A") + self.non_exclusive_cpes = [ + "cpe:/a:nginx:1.16.1", + "cpe:/a:nginx:1.17.2", + "cpe:/a:nginx:1.17.3", + "cpe:/a:nginx:1.9.5", + "cpe:/a:nginx:1.20.1", + "cpe:/a:nginx:1.20.0", + "cpe:/a:nginx:1.21.0", + ] + third_vuln = Vulnerability.objects.create(summary="test-B") + for cpe in self.non_exclusive_cpes: + ref = VulnerabilityReference.objects.create( + reference_id=cpe, + url=f"https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query={cpe}", + ) + VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=second_vuln) + VulnerabilityRelatedReference.objects.create(reference=ref, vulnerability=third_vuln) + + def test_api_response_with_with_exclusive_cpes_associated_with_two_vulnerabilities(self): + request_body = { + "cpes": self.exclusive_cpes, + } + response = self.csrf_client.post( + "/api/cpes/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + assert response[0]["summary"] == "test" + references_in_vuln = response[0]["references"] + cpes = [ref["reference_id"] for ref in references_in_vuln] + assert set(cpes) == set(self.exclusive_cpes) + + def test_api_response_with_no_cpe_associated(self): + request_body = { + "cpes": ["cpe:/a:nginx:1.10.7"], + } + response = self.csrf_client.post( + "/api/cpes/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 0 + + def test_api_response_with_with_non_exclusive_cpes_associated_with_two_vulnerabilities(self): + request_body = { + "cpes": self.non_exclusive_cpes, + } + response = self.csrf_client.post( + "/api/cpes/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 2 + + def test_with_empty_list(self): + request_body = { + "cpes": [], + } + response = self.csrf_client.post( + "/api/cpes/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert response == {"Error": "A non-empty 'cpes' list of CPEs is required."} + + def test_with_invalid_cpes(self): + request_body = {"cpes": ["CVE-2022-2022"]} + response = self.csrf_client.post( + "/api/cpes/bulk_search", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert response == {"Error": "Invalid CPE: CVE-2022-2022"} + + +class TesBanUserAgent(TestCase): + def test_ban_request_with_bytedance_user_agent(self): + response = self.client.get(f"/api/packages", format="json", HTTP_USER_AGENT="bytedance") + assert 404 == response.status_code + + +class TestLookup(TestCase): + def setUp(self): + Package.objects.create( + type="pypi", namespace="", name="microweber/microweber", version="1.2" + ) + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.csrf_client = APIClient(enforce_csrf_checks=True) + self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) + + def test_lookup_endpoint_failure(self): + request_body = {"purl": None} + response = self.csrf_client.post( + "/api/packages/lookup", + data=json.dumps(request_body), + content_type="application/json", + ).json() + + expected = { + "error": {"purl": ["This field may not be null."]}, + "message": "A 'purl' is required.", + } + + self.assertEqual(response, expected) + + def test_lookup_endpoint(self): + request_body = {"purl": "pkg:pypi/microweber/microweber@1.2"} + response = self.csrf_client.post( + "/api/packages/lookup", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + assert response[0]["purl"] == "pkg:pypi/microweber/microweber@1.2" + + def test_bulk_lookup_endpoint(self): + request_body = { + "purls": [ + "pkg:pypi/microweber/microweber@1.2?foo=bar", + "pkg:pypi/microweber/microweber@1.2", + "pkg:pypi/foo/bar@1.0", + ], + } + response = self.csrf_client.post( + "/api/packages/bulk_lookup", + data=json.dumps(request_body), + content_type="application/json", + ).json() + assert len(response) == 1 + + def test_bulk_lookup_endpoint_failure(self): + request_body = {"purls": None} + response = self.csrf_client.post( + "/api/packages/bulk_lookup", + data=json.dumps(request_body), + content_type="application/json", + ).json() + + expected = { + "error": {"purls": ["This field may not be null."]}, + "message": "A non-empty 'purls' list of PURLs is required.", + } + + self.assertEqual(response, expected)