Skip to content

Commit 5f76de8

Browse files
authored
Merge pull request #303 from sbs2001/bulk_api_vuln_pkg
Add endpoints for bulk requesting vulnerabilities and packages
2 parents c7578b3 + 9ab1c2f commit 5f76de8

3 files changed

Lines changed: 238 additions & 13 deletions

File tree

vulnerabilities/api.py

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,27 @@
2222
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
2323

2424
from urllib.parse import unquote
25+
from typing import List
2526

27+
from django.db.models import Q
2628
from django.urls import reverse
2729
from django_filters import rest_framework as filters
2830
from packageurl import PackageURL
2931
from rest_framework import serializers
3032
from rest_framework import viewsets
33+
from rest_framework.decorators import action
34+
from rest_framework.response import Response
35+
from drf_spectacular.utils import extend_schema, inline_serializer
36+
from drf_spectacular.types import OpenApiTypes
3137

3238
from vulnerabilities.models import Package
3339
from vulnerabilities.models import Vulnerability
3440
from vulnerabilities.models import VulnerabilityReference
3541

42+
# This serializer is used for the bulk apis, to prevent wrong auto documentation
43+
# TODO: Fix the swagger documentation for bulk apis
44+
placeholder_serializer = inline_serializer(name="Placeholder", fields={})
45+
3646

3747
class VulnerabilityReferenceSerializer(serializers.ModelSerializer):
3848
class Meta:
@@ -60,45 +70,52 @@ class Meta:
6070
fields = ["url", "vulnerability_id"]
6171

6272

63-
class VulnerabilitySerializer(serializers.HyperlinkedModelSerializer):
64-
references = VulnerabilityReferenceSerializer(many=True, source="vulnerabilityreference_set")
73+
class MinimalVulnerabilitySerializer(serializers.HyperlinkedModelSerializer):
74+
6575
resolved_packages = HyperLinkedPackageSerializer(
6676
many=True, source="resolved_to", read_only=True
6777
)
6878
unresolved_packages = HyperLinkedPackageSerializer(
6979
many=True, source="vulnerable_to", read_only=True
7080
)
7181

82+
class Meta:
83+
model = Vulnerability
84+
fields = ["url", "unresolved_packages", "resolved_packages"]
85+
86+
87+
class VulnerabilitySerializer(MinimalVulnerabilitySerializer):
88+
references = VulnerabilityReferenceSerializer(many=True, source="vulnerabilityreference_set")
89+
7290
class Meta:
7391
model = Vulnerability
7492
fields = "__all__"
7593

7694

77-
class PackageSerializer(serializers.HyperlinkedModelSerializer):
95+
class MinimalPackageSerializer(serializers.HyperlinkedModelSerializer):
7896
unresolved_vulnerabilities = HyperLinkedVulnerabilitySerializer(
7997
many=True, source="vulnerable_to", read_only=True
8098
)
8199
resolved_vulnerabilities = HyperLinkedVulnerabilitySerializer(
82100
many=True, source="resolved_to", read_only=True
83101
)
84-
purl = serializers.CharField(source="package_url")
85102

86103
class Meta:
87104
model = Package
88105
fields = [
89-
"url",
90-
"type",
91-
"namespace",
92-
"name",
93-
"version",
94-
"qualifiers",
95-
"subpath",
96-
"purl",
97106
"resolved_vulnerabilities",
98107
"unresolved_vulnerabilities",
99108
]
100109

101110

111+
class PackageSerializer(MinimalPackageSerializer):
112+
purl = serializers.CharField(source="package_url")
113+
114+
class Meta:
115+
model = Package
116+
exclude = ["vulnerabilities"]
117+
118+
102119
class PackageFilterSet(filters.FilterSet):
103120
purl = filters.CharFilter(method="filter_purl")
104121

@@ -126,6 +143,38 @@ class PackageViewSet(viewsets.ReadOnlyModelViewSet):
126143
filter_backends = (filters.DjangoFilterBackend,)
127144
filterset_class = PackageFilterSet
128145

146+
# TODO: Fix the swagger documentation for this endpoint
147+
@extend_schema(request=placeholder_serializer, responses=placeholder_serializer)
148+
@action(detail=False, methods=["post"])
149+
def bulk_search(self, request):
150+
"""
151+
See https://github.com/nexB/vulnerablecode/pull/303#issuecomment-761801639 for docs
152+
"""
153+
filter_list = Q()
154+
response = {}
155+
if not isinstance(request.data.get("packages"), list):
156+
return Response(
157+
status=400,
158+
data={
159+
"Error": "Request needs to contain a key 'packages' which has the value of a list of package urls" # nopep8
160+
},
161+
)
162+
for purl in request.data["packages"]:
163+
try:
164+
filter_list |= Q(
165+
**{k: v for k, v in PackageURL.from_string(purl).to_dict().items() if v}
166+
)
167+
except ValueError as ve:
168+
return Response(status=400, data={"Error": str(ve)})
169+
170+
# This handles the case when the said purl doesnt exist in db
171+
response[purl] = {}
172+
res = Package.objects.filter(filter_list)
173+
for p in res:
174+
response[p.package_url] = MinimalPackageSerializer(p, context={"request": request}).data
175+
176+
return Response(response)
177+
129178

130179
class VulnerabilityFilterSet(filters.FilterSet):
131180
vulnerability_id = filters.CharFilter(field_name="cve_id")
@@ -141,3 +190,31 @@ class VulnerabilityViewSet(viewsets.ReadOnlyModelViewSet):
141190
paginate_by = 50
142191
filter_backends = (filters.DjangoFilterBackend,)
143192
filterset_class = VulnerabilityFilterSet
193+
194+
# TODO: Fix the swagger documentation for this endpoint
195+
@extend_schema(request=placeholder_serializer, responses=placeholder_serializer)
196+
@action(detail=False, methods=["post"])
197+
def bulk_search(self, request):
198+
"""
199+
See https://github.com/nexB/vulnerablecode/pull/303#issuecomment-761801619 for docs
200+
"""
201+
filter_list = []
202+
response = {}
203+
if not isinstance(request.data.get("vulnerabilities"), list):
204+
return Response(
205+
status=400,
206+
data={
207+
"Error": "Request needs to contain a key 'vulnerabilities' which has the value of a list of vulnerability ids" # nopep8
208+
},
209+
)
210+
211+
for cve_id in request.data["vulnerabilities"]:
212+
filter_list.append(cve_id)
213+
# This handles the case when the said cve doesnt exist in db
214+
response[cve_id] = {}
215+
res = Vulnerability.objects.filter(cve_id__in=filter_list)
216+
for vuln in res:
217+
response[vuln.cve_id] = MinimalVulnerabilitySerializer(
218+
vuln, context={"request": request}
219+
).data
220+
return Response(response)

vulnerabilities/fixtures/debian.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
},
7474
{
7575
"model": "vulnerabilities.packagerelatedvulnerability",
76-
"pk": 1,
76+
"pk": 10,
7777
"fields": {
7878
"vulnerability": 2,
7979
"package": 2,

vulnerabilities/tests/test_api.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
2323

2424
import os
25+
from collections import OrderedDict
2526
from random import choices
2627
from unittest.mock import MagicMock
2728
from urllib.parse import quote
@@ -31,6 +32,8 @@
3132

3233
from vulnerabilities.api import PackageSerializer
3334
from vulnerabilities.models import Package
35+
from rest_framework.test import APIRequestFactory
36+
from rest_framework.test import APIClient
3437

3538

3639
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -188,3 +191,148 @@ def test_package_serializer(self):
188191
purls = {r["purl"] for r in response}
189192
self.assertIn("pkg:deb/debian/mimetex@1.50-1.1?distro=jessie", purls)
190193
self.assertIn("pkg:deb/debian/mimetex@1.74-1?distro=jessie", purls)
194+
195+
196+
class TestBulkAPIResponse(TestCase):
197+
fixtures = ["debian.json"]
198+
199+
def test_bulk_vulnerabilities_api(self):
200+
request_body = {"vulnerabilities": ["CVE-2009-1382", "CVE-2014-8242", "RANDOM-CVE"]}
201+
expected_response = {
202+
"CVE-2009-1382": {
203+
"resolved_packages": [
204+
OrderedDict(
205+
[
206+
("url", "http://testserver/api/packages/2/"),
207+
("purl", "pkg:deb/debian/mimetex@1.74-1?distro=jessie"),
208+
]
209+
),
210+
OrderedDict(
211+
[
212+
("url", "http://testserver/api/packages/3/"),
213+
("purl", "pkg:deb/debian/mimetex@1.50-1.1?distro=jessie"),
214+
]
215+
),
216+
],
217+
"unresolved_packages": [],
218+
"url": "http://testserver/api/vulnerabilities/2/",
219+
},
220+
"CVE-2014-8242": {
221+
"resolved_packages": [],
222+
"unresolved_packages": [
223+
OrderedDict(
224+
[
225+
("url", "http://testserver/api/packages/1/"),
226+
("purl", "pkg:deb/debian/librsync@0.9.7-10?distro=jessie"),
227+
]
228+
)
229+
],
230+
"url": "http://testserver/api/vulnerabilities/1/",
231+
},
232+
"RANDOM-CVE": {},
233+
}
234+
235+
response = self.client.post(
236+
"/api/vulnerabilities/bulk_search/", data=request_body, content_type="application/json"
237+
).data
238+
assert response == expected_response
239+
240+
def test_bulk_packages_api(self):
241+
request_body = {
242+
"packages": [
243+
"pkg:deb/debian/librsync@0.9.7-10?distro=jessie",
244+
"pkg:deb/debian/mimetex@1.50-1.1?distro=jessie",
245+
]
246+
}
247+
response = self.client.post(
248+
"/api/packages/bulk_search/", data=request_body, content_type="application/json"
249+
).data
250+
expected_response = {
251+
"pkg:deb/debian/librsync@0.9.7-10?distro=jessie": {
252+
"resolved_vulnerabilities": [],
253+
"unresolved_vulnerabilities": [
254+
OrderedDict(
255+
[
256+
("url", "http://testserver/api/vulnerabilities/1/"),
257+
("vulnerability_id", "CVE-2014-8242"),
258+
]
259+
)
260+
],
261+
},
262+
"pkg:deb/debian/mimetex@1.50-1.1?distro=jessie": {
263+
"resolved_vulnerabilities": [
264+
OrderedDict(
265+
[
266+
("url", "http://testserver/api/vulnerabilities/2/"),
267+
("vulnerability_id", "CVE-2009-1382"),
268+
]
269+
),
270+
OrderedDict(
271+
[
272+
("url", "http://testserver/api/vulnerabilities/3/"),
273+
("vulnerability_id", "CVE-2009-2459"),
274+
]
275+
),
276+
],
277+
"unresolved_vulnerabilities": [],
278+
},
279+
}
280+
281+
assert response == expected_response
282+
283+
def test_invalid_request_bulk_packages(self):
284+
error_response = {
285+
"Error": "Request needs to contain a key 'packages' which has the value of a list of package urls" # nopep8
286+
}
287+
invalid_key_request_data = {"pkg": []}
288+
response = self.client.post(
289+
"/api/packages/bulk_search/",
290+
data=invalid_key_request_data,
291+
content_type="application/json",
292+
).data
293+
assert response == error_response
294+
295+
valid_key_invalid_datatype_request_data = {"packages": {}}
296+
response = self.client.post(
297+
"/api/packages/bulk_search/",
298+
data=valid_key_invalid_datatype_request_data,
299+
content_type="application/json",
300+
).data
301+
assert response == error_response
302+
303+
invalid_purl_request_data = {
304+
"packages": [
305+
"pkg:deb/debian/librsync@0.9.7-10?distro=jessie",
306+
"pg:deb/debian/mimetex@1.50-1.1?distro=jessie",
307+
]
308+
}
309+
response = self.client.post(
310+
"/api/packages/bulk_search/",
311+
data=invalid_purl_request_data,
312+
content_type="application/json",
313+
).data
314+
purl_error_respones = {
315+
"Error": "purl is missing the required \"pkg\" scheme component: 'pg:deb/debian/mimetex@1.50-1.1?distro=jessie'." # nopep8
316+
}
317+
assert response == purl_error_respones
318+
319+
def test_invalid_request_bulk_vulnerabilities(self):
320+
error_response = {
321+
"Error": "Request needs to contain a key 'vulnerabilities' which has the value of a list of vulnerability ids" # nopep8
322+
}
323+
324+
wrong_key_data = {"xyz": []}
325+
response = self.client.post(
326+
"/api/vulnerabilities/bulk_search/",
327+
data=wrong_key_data,
328+
content_type="application/json",
329+
).data
330+
assert response == error_response
331+
332+
wrong_type_data = {"vulnerabilities": {}}
333+
response = self.client.post(
334+
"/api/vulnerabilities/bulk_search/",
335+
data=wrong_key_data,
336+
content_type="application/json",
337+
).data
338+
assert response == error_response

0 commit comments

Comments
 (0)