diff --git a/vulnerabilities/api.py b/vulnerabilities/api.py index aff7965e5..fce2f2516 100644 --- a/vulnerabilities/api.py +++ b/vulnerabilities/api.py @@ -22,39 +22,45 @@ # Visit https://github.com/nexB/vulnerablecode/ for support and download. from urllib.parse import unquote -from typing import List from django.db.models import Q from django.urls import reverse from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema, inline_serializer from packageurl import PackageURL -from rest_framework import serializers -from rest_framework import viewsets + +from rest_framework import serializers, viewsets from rest_framework.decorators import action from rest_framework.response import Response -from drf_spectacular.utils import extend_schema, inline_serializer -from drf_spectacular.types import OpenApiTypes - from vulnerabilities.models import Package from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityReference +from vulnerabilities.models import VulnerabilitySeverity # This serializer is used for the bulk apis, to prevent wrong auto documentation # TODO: Fix the swagger documentation for bulk apis placeholder_serializer = inline_serializer(name="Placeholder", fields={}) +class VulnerabilitySeveritySerializer(serializers.ModelSerializer): + class Meta: + model = VulnerabilitySeverity + fields = ["value", "scoring_system"] + + class VulnerabilityReferenceSerializer(serializers.ModelSerializer): + scores = VulnerabilitySeveritySerializer(many=True) + class Meta: model = VulnerabilityReference - fields = [ - "source", - "reference_id", - "url", - ] + fields = ["source", "reference_id", "url", "scores"] -class HyperLinkedPackageSerializer(serializers.HyperlinkedModelSerializer): +class MinimalPackageSerializer(serializers.HyperlinkedModelSerializer): + """ + Used for nesting inside vulnerability focused APIs. + """ + purl = serializers.CharField(source="package_url") class Meta: @@ -62,27 +68,25 @@ class Meta: fields = ["url", "purl"] -class HyperLinkedVulnerabilitySerializer(serializers.HyperlinkedModelSerializer): +class MinimalVulnerabilitySerializer(serializers.HyperlinkedModelSerializer): + """ + Used for nesting inside package focused APIs. + """ + + references = VulnerabilityReferenceSerializer(many=True, source="vulnerabilityreference_set") + class Meta: model = Vulnerability - fields = ["url", "vulnerability_id"] + fields = ["url", "vulnerability_id", "references"] -class MinimalVulnerabilitySerializer(serializers.HyperlinkedModelSerializer): +class VulnerabilitySerializer(serializers.HyperlinkedModelSerializer): - resolved_packages = HyperLinkedPackageSerializer( - many=True, source="resolved_to", read_only=True - ) - unresolved_packages = HyperLinkedPackageSerializer( + resolved_packages = MinimalPackageSerializer(many=True, source="resolved_to", read_only=True) + unresolved_packages = MinimalPackageSerializer( many=True, source="vulnerable_to", read_only=True ) - class Meta: - model = Vulnerability - fields = ["url", "unresolved_packages", "resolved_packages"] - - -class VulnerabilitySerializer(MinimalVulnerabilitySerializer): references = VulnerabilityReferenceSerializer(many=True, source="vulnerabilityreference_set") class Meta: @@ -90,23 +94,14 @@ class Meta: fields = "__all__" -class MinimalPackageSerializer(serializers.HyperlinkedModelSerializer): - unresolved_vulnerabilities = HyperLinkedVulnerabilitySerializer( +class PackageSerializer(serializers.HyperlinkedModelSerializer): + + unresolved_vulnerabilities = MinimalVulnerabilitySerializer( many=True, source="vulnerable_to", read_only=True ) - resolved_vulnerabilities = HyperLinkedVulnerabilitySerializer( + resolved_vulnerabilities = MinimalVulnerabilitySerializer( many=True, source="resolved_to", read_only=True ) - - class Meta: - model = Package - fields = [ - "resolved_vulnerabilities", - "unresolved_vulnerabilities", - ] - - -class PackageSerializer(MinimalPackageSerializer): purl = serializers.CharField(source="package_url") class Meta: @@ -148,28 +143,29 @@ def bulk_search(self, request): """ See https://github.com/nexB/vulnerablecode/pull/303#issuecomment-761801639 for docs """ - filter_list = Q() - response = {} - if not isinstance(request.data.get("packages"), list): + response = [] + purls = request.data.get("purls", []) or [] + if not purls or not isinstance(purls, list): return Response( status=400, - data={ - "Error": "Request needs to contain a key 'packages' which has the value of a list of package urls" # nopep8 - }, + data={"Error": "A non-empty 'purls' list of package URLs is required."}, ) - for purl in request.data["packages"]: + for purl in request.data["purls"]: try: - filter_list |= Q( - **{k: v for k, v in PackageURL.from_string(purl).to_dict().items() if v} - ) + purl = PackageURL.from_string(purl).to_dict() except ValueError as ve: - return Response(status=400, data={"Error": str(ve)}) - - # This handles the case when the said purl doesnt exist in db - response[purl] = {} - res = Package.objects.filter(filter_list) - for p in res: - response[p.package_url] = MinimalPackageSerializer(p, context={"request": request}).data + return Response(status=400, data={"Error": f"Invalid Package URL: {purl}"}) + purl_data = Package.objects.filter( + **{key: value for key, value in purl.items() if value} + ) + purl_response = {} + if purl_data: + purl_response = PackageSerializer(purl_data[0], context={"request": request}).data + else: + purl_response = purl + purl_response["unresolved_vulnerabilities"] = [] + purl_response["resolved_vulnerabilities"] = [] + response.append(purl_response) return Response(response) @@ -186,31 +182,3 @@ class VulnerabilityViewSet(viewsets.ReadOnlyModelViewSet): paginate_by = 50 filter_backends = (filters.DjangoFilterBackend,) filterset_class = VulnerabilityFilterSet - - # TODO: Fix the swagger documentation for this endpoint - @extend_schema(request=placeholder_serializer, responses=placeholder_serializer) - @action(detail=False, methods=["post"]) - def bulk_search(self, request): - """ - See https://github.com/nexB/vulnerablecode/pull/303#issuecomment-761801619 for docs - """ - filter_list = [] - response = {} - if not isinstance(request.data.get("vulnerabilities"), list): - return Response( - status=400, - data={ - "Error": "Request needs to contain a key 'vulnerabilities' which has the value of a list of vulnerability ids" # nopep8 - }, - ) - - for vulnerability_id in request.data["vulnerabilities"]: - filter_list.append(vulnerability_id) - # This handles the case when the said cve doesnt exist in db - response[vulnerability_id] = {} - res = Vulnerability.objects.filter(vulnerability_id__in=filter_list) - for vuln in res: - response[vuln.vulnerability_id] = MinimalVulnerabilitySerializer( - vuln, context={"request": request} - ).data - return Response(response) diff --git a/vulnerabilities/fixtures/github.json b/vulnerabilities/fixtures/github.json new file mode 100644 index 000000000..11c9423c2 --- /dev/null +++ b/vulnerabilities/fixtures/github.json @@ -0,0 +1,130 @@ +[ + { + "model": "vulnerabilities.vulnerability", + "pk": 60, + "fields": { + "vulnerability_id": "CVE-2021-21331", + "old_vulnerability_id": null, + "summary": "Local Information Disclosure Vulnerability" + } + }, + { + "model": "vulnerabilities.vulnerabilityreference", + "pk": 136, + "fields": { + "vulnerability": 60, + "source": "", + "reference_id": "GHSA-2cxf-6567-7pp6", + "url": "https://github.com/DataDog/datadog-api-client-java/security/advisories/GHSA-2cxf-6567-7pp6" + } + }, + { + "model": "vulnerabilities.vulnerabilityreference", + "pk": 137, + "fields": { + "vulnerability": 60, + "source": "", + "reference_id": "", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-21331" + } + }, + { + "model": "vulnerabilities.vulnerabilityreference", + "pk": 138, + "fields": { + "vulnerability": 60, + "source": "", + "reference_id": "GHSA-2cxf-6567-7pp6", + "url": "https://github.com/advisories/GHSA-2cxf-6567-7pp6" + } + }, + { + "model": "vulnerabilities.package", + "pk": 3467, + "fields": { + "type": "maven", + "namespace": "com.datadoghq", + "name": "datadog-api-client", + "version": "1.0.0-beta.7", + "subpath": "", + "qualifiers": {} + } + }, + { + "model": "vulnerabilities.package", + "pk": 3468, + "fields": { + "type": "maven", + "namespace": "com.datadoghq", + "name": "datadog-api-client", + "version": "1.0.0-beta.6", + "subpath": "", + "qualifiers": {} + } + }, + { + "model": "vulnerabilities.package", + "pk": 3469, + "fields": { + "type": "maven", + "namespace": "com.datadoghq", + "name": "datadog-api-client", + "version": "1.0.0-beta.9", + "subpath": "", + "qualifiers": {} + } + }, + { + "model": "vulnerabilities.packagerelatedvulnerability", + "pk": 3844, + "fields": { + "package": 3469, + "vulnerability": 60, + "is_vulnerable": false + } + }, + { + "model": "vulnerabilities.packagerelatedvulnerability", + "pk": 3845, + "fields": { + "package": 3467, + "vulnerability": 60, + "is_vulnerable": true + } + }, + { + "model": "vulnerabilities.packagerelatedvulnerability", + "pk": 3846, + "fields": { + "package": 3468, + "vulnerability": 60, + "is_vulnerable": true + } + }, + { + "model": "vulnerabilities.importer", + "pk": 18, + "fields": { + "name": "github", + "license": "", + "last_run": "2021-03-06T09:09:01.523Z", + "data_source": "GitHubAPIDataSource", + "data_source_cfg": { + "endpoint": "https://api.github.com/graphql", + "ecosystems": [ + "MAVEN" + ] + } + } + }, + { + "model": "vulnerabilities.vulnerabilityseverity", + "pk": 57, + "fields": { + "vulnerability": 60, + "value": "LOW", + "scoring_system": "cvssv3.1_qr", + "reference": 136 + } + } +] \ No newline at end of file diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index fcd09a831..f101c98d4 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -106,6 +106,10 @@ class VulnerabilityReference(models.Model): ) url = models.URLField(max_length=1024, help_text="URL of Vulnerability data", blank=True) + @property + def scores(self): + return VulnerabilitySeverity.objects.filter(reference=self.id) + class Meta: unique_together = ("vulnerability", "source", "reference_id", "url") diff --git a/vulnerabilities/tests/test_api.py b/vulnerabilities/tests/test_api.py index e3904423e..f95024e31 100644 --- a/vulnerabilities/tests/test_api.py +++ b/vulnerabilities/tests/test_api.py @@ -40,6 +40,53 @@ 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(TestCase): fixtures = ["debian.json"] @@ -194,95 +241,75 @@ def test_package_serializer(self): class TestBulkAPIResponse(TestCase): - fixtures = ["debian.json"] - - def test_bulk_vulnerabilities_api(self): - request_body = {"vulnerabilities": ["CVE-2009-1382", "CVE-2014-8242", "RANDOM-CVE"]} - expected_response = { - "CVE-2009-1382": { - "resolved_packages": [ - OrderedDict( - [ - ("url", "http://testserver/api/packages/2"), - ("purl", "pkg:deb/debian/mimetex@1.74-1?distro=jessie"), - ] - ), - OrderedDict( - [ - ("url", "http://testserver/api/packages/3"), - ("purl", "pkg:deb/debian/mimetex@1.50-1.1?distro=jessie"), - ] - ), - ], - "unresolved_packages": [], - "url": "http://testserver/api/vulnerabilities/2", - }, - "CVE-2014-8242": { - "resolved_packages": [], - "unresolved_packages": [ - OrderedDict( - [ - ("url", "http://testserver/api/packages/1"), - ("purl", "pkg:deb/debian/librsync@0.9.7-10?distro=jessie"), - ] - ) - ], - "url": "http://testserver/api/vulnerabilities/1", - }, - "RANDOM-CVE": {}, - } - - response = self.client.post( - "/api/vulnerabilities/bulk_search/", data=request_body, content_type="application/json" - ).data - assert response == expected_response + fixtures = ["github.json"] def test_bulk_packages_api(self): request_body = { - "packages": [ - "pkg:deb/debian/librsync@0.9.7-10?distro=jessie", - "pkg:deb/debian/mimetex@1.50-1.1?distro=jessie", + "purls": [ + "pkg:deb/debian/doesnotexist@0.9.7-10?distro=jessie", + "pkg:maven/com.datadoghq/datadog-api-client@1.0.0-beta.7", ] } response = self.client.post( - "/api/packages/bulk_search/", data=request_body, content_type="application/json" - ).data - expected_response = { - "pkg:deb/debian/librsync@0.9.7-10?distro=jessie": { + "/api/packages/bulk_search/", + data=request_body, + content_type="application/json", + ).json() + + expected_response = [ + { + "name": "doesnotexist", + "namespace": "debian", + "qualifiers": {"distro": "jessie"}, "resolved_vulnerabilities": [], - "unresolved_vulnerabilities": [ - OrderedDict( - [ - ("url", "http://testserver/api/vulnerabilities/1"), - ("vulnerability_id", "CVE-2014-8242"), - ] - ) - ], + "subpath": None, + "type": "deb", + "unresolved_vulnerabilities": [], + "version": "0.9.7-10", }, - "pkg:deb/debian/mimetex@1.50-1.1?distro=jessie": { - "resolved_vulnerabilities": [ - OrderedDict( - [ - ("url", "http://testserver/api/vulnerabilities/2"), - ("vulnerability_id", "CVE-2009-1382"), - ] - ), - OrderedDict( - [ - ("url", "http://testserver/api/vulnerabilities/3"), - ("vulnerability_id", "CVE-2009-2459"), - ] - ), + { + "name": "datadog-api-client", + "namespace": "com.datadoghq", + "purl": "pkg:maven/com.datadoghq/datadog-api-client@1.0.0-beta.7", + "qualifiers": {}, + "resolved_vulnerabilities": [], + "subpath": "", + "type": "maven", + "unresolved_vulnerabilities": [ + { + "references": [ + { + "reference_id": "", + "scores": [], + "source": "", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-21331", + }, + { + "reference_id": "GHSA-2cxf-6567-7pp6", + "scores": [{"scoring_system": "cvssv3.1_qr", "value": "LOW"}], + "source": "", + "url": "https://github.com/DataDog/datadog-api-client-java/security/advisories/GHSA-2cxf-6567-7pp6", + }, + { + "reference_id": "GHSA-2cxf-6567-7pp6", + "scores": [], + "source": "", + "url": "https://github.com/advisories/GHSA-2cxf-6567-7pp6", + }, + ], + "url": "http://testserver/api/vulnerabilities/60", + "vulnerability_id": "CVE-2021-21331", + } ], - "unresolved_vulnerabilities": [], + "url": "http://testserver/api/packages/3467", + "version": "1.0.0-beta.7", }, - } - - assert response == expected_response + ] + assert cleaned_response(expected_response) == cleaned_response(response) def test_invalid_request_bulk_packages(self): error_response = { - "Error": "Request needs to contain a key 'packages' which has the value of a list of package urls" # nopep8 + "Error": "A non-empty 'purls' list of package URLs is required." # nopep8 } invalid_key_request_data = {"pkg": []} response = self.client.post( @@ -298,10 +325,11 @@ def test_invalid_request_bulk_packages(self): data=valid_key_invalid_datatype_request_data, content_type="application/json", ).data + assert response == error_response invalid_purl_request_data = { - "packages": [ + "purls": [ "pkg:deb/debian/librsync@0.9.7-10?distro=jessie", "pg:deb/debian/mimetex@1.50-1.1?distro=jessie", ] @@ -312,27 +340,6 @@ def test_invalid_request_bulk_packages(self): content_type="application/json", ).data purl_error_respones = { - "Error": "purl is missing the required \"pkg\" scheme component: 'pg:deb/debian/mimetex@1.50-1.1?distro=jessie'." # nopep8 + "Error": "Invalid Package URL: pg:deb/debian/mimetex@1.50-1.1?distro=jessie" } assert response == purl_error_respones - - def test_invalid_request_bulk_vulnerabilities(self): - error_response = { - "Error": "Request needs to contain a key 'vulnerabilities' which has the value of a list of vulnerability ids" # nopep8 - } - - wrong_key_data = {"xyz": []} - response = self.client.post( - "/api/vulnerabilities/bulk_search/", - data=wrong_key_data, - content_type="application/json", - ).data - assert response == error_response - - wrong_type_data = {"vulnerabilities": {}} - response = self.client.post( - "/api/vulnerabilities/bulk_search/", - data=wrong_key_data, - content_type="application/json", - ).data - assert response == error_response