Skip to content

Commit 4d90a41

Browse files
committed
Add all packages endpoint functionality in V2
Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent deca049 commit 4d90a41

File tree

2 files changed

+242
-5
lines changed

2 files changed

+242
-5
lines changed

vulnerabilities/api_v2.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@
88
#
99

1010

11+
from drf_spectacular.utils import OpenApiParameter
12+
from drf_spectacular.utils import extend_schema
13+
from drf_spectacular.utils import extend_schema_view
14+
from packageurl import PackageURL
1115
from rest_framework import serializers
16+
from rest_framework import status
1217
from rest_framework import viewsets
18+
from rest_framework.decorators import action
1319
from rest_framework.response import Response
1420
from rest_framework.reverse import reverse
1521

@@ -19,8 +25,7 @@
1925
from vulnerabilities.models import VulnerabilityReference
2026
from vulnerabilities.models import VulnerabilitySeverity
2127
from vulnerabilities.models import Weakness
22-
from drf_spectacular.utils import extend_schema_view, extend_schema, OpenApiParameter
23-
from rest_framework.decorators import action
28+
2429

2530
class WeaknessV2Serializer(serializers.ModelSerializer):
2631
cwe_id = serializers.CharField()
@@ -81,6 +86,7 @@ def get_url(self, obj):
8186
request=request,
8287
)
8388

89+
8490
@extend_schema_view(
8591
list=extend_schema(
8692
parameters=[
@@ -188,6 +194,13 @@ class PackageBulkSearchRequestSerializer(PackageurlListSerializer):
188194
plain_purl = serializers.BooleanField(required=False, default=False)
189195

190196

197+
class LookupRequestSerializer(serializers.Serializer):
198+
purl = serializers.CharField(
199+
required=True,
200+
help_text="PackageURL strings in canonical form.",
201+
)
202+
203+
191204
class PackageV2ViewSet(viewsets.ReadOnlyModelViewSet):
192205
queryset = Package.objects.all()
193206
serializer_class = PackageV2Serializer
@@ -224,7 +237,7 @@ def list(self, request, *args, **kwargs):
224237
serializer = self.get_serializer(queryset, many=True)
225238
data = serializer.data
226239
return Response({"packages": data})
227-
240+
228241
@extend_schema(
229242
request=PackageurlListSerializer,
230243
responses={200: PackageV2Serializer(many=True)},
@@ -260,7 +273,6 @@ def bulk_lookup(self, request):
260273
).data
261274
)
262275

263-
264276
@extend_schema(
265277
request=PackageBulkSearchRequestSerializer,
266278
responses={200: PackageV2Serializer(many=True)},
@@ -324,8 +336,54 @@ def bulk_search(self, request):
324336
query = Package.objects.filter(package_url__in=purls).distinct().with_is_vulnerable()
325337

326338
if not purl_only:
327-
return Response(PackageV2Serializer(query, many=True, context={"request": request}).data)
339+
return Response(
340+
PackageV2Serializer(query, many=True, context={"request": request}).data
341+
)
328342

329343
vulnerable_purls = query.vulnerable().only("package_url")
330344
vulnerable_purls = [str(package.package_url) for package in vulnerable_purls]
331345
return Response(data=vulnerable_purls)
346+
347+
@action(detail=False, methods=["get"])
348+
def all(self, request):
349+
"""
350+
Return a list of Package URLs of vulnerable packages.
351+
"""
352+
vulnerable_purls = (
353+
Package.objects.vulnerable()
354+
.only("package_url")
355+
.order_by("package_url")
356+
.distinct()
357+
.values_list("package_url", flat=True)
358+
)
359+
return Response(vulnerable_purls)
360+
361+
@extend_schema(
362+
request=LookupRequestSerializer,
363+
responses={200: PackageV2Serializer(many=True)},
364+
)
365+
@action(
366+
detail=False,
367+
methods=["post"],
368+
serializer_class=LookupRequestSerializer,
369+
filter_backends=[],
370+
pagination_class=None,
371+
)
372+
def lookup(self, request):
373+
"""
374+
Return the response for exact PackageURL requested for.
375+
"""
376+
serializer = self.serializer_class(data=request.data)
377+
if not serializer.is_valid():
378+
return Response(
379+
status=status.HTTP_400_BAD_REQUEST,
380+
data={
381+
"error": serializer.errors,
382+
"message": "A 'purl' is required.",
383+
},
384+
)
385+
validated_data = serializer.validated_data
386+
purl = validated_data.get("purl")
387+
388+
qs = self.get_queryset().for_purls([purl]).with_is_vulnerable()
389+
return Response(PackageV2Serializer(qs, many=True, context={"request": request}).data)

vulnerabilities/tests/test_api_v2.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,182 @@ def test_get_fixing_vulnerabilities(self):
305305
serializer = PackageV2Serializer()
306306
vulnerabilities = serializer.get_fixing_vulnerabilities(package)
307307
self.assertEqual(vulnerabilities, ["VCID-5678"])
308+
309+
def test_bulk_lookup_with_valid_purls(self):
310+
"""
311+
Test bulk lookup with valid PURLs.
312+
"""
313+
url = reverse("package-v2-bulk-lookup")
314+
data = {"purls": ["pkg:pypi/django@3.2", "pkg:npm/lodash@4.17.20"]}
315+
response = self.client.post(url, data, format="json")
316+
self.assertEqual(response.status_code, status.HTTP_200_OK)
317+
self.assertEqual(len(response.data), 2)
318+
# Verify that the returned data matches the packages
319+
purls = [package["purl"] for package in response.data]
320+
self.assertIn("pkg:pypi/django@3.2", purls)
321+
self.assertIn("pkg:npm/lodash@4.17.20", purls)
322+
323+
def test_bulk_lookup_with_invalid_purls(self):
324+
"""
325+
Test bulk lookup with invalid PURLs.
326+
"""
327+
url = reverse("package-v2-bulk-lookup")
328+
data = {"purls": ["pkg:pypi/nonexistent@1.0.0", "pkg:npm/unknown@0.0.1"]}
329+
response = self.client.post(url, data, format="json")
330+
self.assertEqual(response.status_code, status.HTTP_200_OK)
331+
# Since the packages don't exist, the response should be empty
332+
self.assertEqual(len(response.data), 0)
333+
334+
def test_bulk_lookup_with_empty_purls(self):
335+
"""
336+
Test bulk lookup with empty purls list.
337+
Should return 400 Bad Request.
338+
"""
339+
url = reverse("package-v2-bulk-lookup")
340+
data = {"purls": []}
341+
response = self.client.post(url, data, format="json")
342+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
343+
self.assertIn("error", response.data)
344+
self.assertIn("message", response.data)
345+
self.assertEqual(response.data["message"], "A non-empty 'purls' list of PURLs is required.")
346+
347+
def test_bulk_search_with_valid_purls(self):
348+
"""
349+
Test bulk search with valid PURLs.
350+
"""
351+
url = reverse("package-v2-bulk-search")
352+
data = {"purls": ["pkg:pypi/django@3.2", "pkg:npm/lodash@4.17.20"]}
353+
response = self.client.post(url, data, format="json")
354+
self.assertEqual(response.status_code, status.HTTP_200_OK)
355+
self.assertEqual(len(response.data), 2)
356+
purls = [package["purl"] for package in response.data]
357+
self.assertIn("pkg:pypi/django@3.2", purls)
358+
self.assertIn("pkg:npm/lodash@4.17.20", purls)
359+
360+
def test_bulk_search_with_purl_only_true(self):
361+
"""
362+
Test bulk search with purl_only set to True.
363+
Should return only the PURLs of vulnerable packages.
364+
"""
365+
url = reverse("package-v2-bulk-search")
366+
data = {"purls": ["pkg:pypi/django@3.2", "pkg:npm/lodash@4.17.20"], "purl_only": True}
367+
response = self.client.post(url, data, format="json")
368+
self.assertEqual(response.status_code, status.HTTP_200_OK)
369+
# Since purl_only=True, response should be a list of PURLs
370+
self.assertIsInstance(response.data, list)
371+
# Only vulnerable packages should be included
372+
self.assertEqual(len(response.data), 1)
373+
self.assertEqual(response.data, ["pkg:pypi/django@3.2"])
374+
375+
def test_bulk_search_with_plain_purl_true(self):
376+
"""
377+
Test bulk search with plain_purl set to True.
378+
"""
379+
url = reverse("package-v2-bulk-search")
380+
data = {"purls": ["pkg:pypi/django@3.2", "pkg:pypi/django@3.1"], "plain_purl": True}
381+
response = self.client.post(url, data, format="json")
382+
self.assertEqual(response.status_code, status.HTTP_200_OK)
383+
# Since plain_purl=True, packages with the same name and version are grouped
384+
self.assertEqual(len(response.data), 1)
385+
purls = [package["purl"] for package in response.data]
386+
self.assertIn("pkg:pypi/django@3.2", purls[0] or "pkg:pypi/django@3.1" in purls[0])
387+
388+
def test_bulk_search_with_purl_only_and_plain_purl_true(self):
389+
"""
390+
Test bulk search with purl_only and plain_purl both set to True.
391+
Should return only the plain PURLs of vulnerable packages.
392+
"""
393+
url = reverse("package-v2-bulk-search")
394+
data = {
395+
"purls": ["pkg:pypi/django@3.2", "pkg:pypi/django@3.1"],
396+
"purl_only": True,
397+
"plain_purl": True,
398+
}
399+
response = self.client.post(url, data, format="json")
400+
self.assertEqual(response.status_code, status.HTTP_200_OK)
401+
# Response should be a list of plain PURLs
402+
self.assertIsInstance(response.data, list)
403+
# Only one plain PURL should be returned for vulnerable packages
404+
self.assertEqual(len(response.data), 1)
405+
self.assertEqual(response.data, ["pkg:pypi/django@3.2"])
406+
407+
def test_bulk_search_with_invalid_purls(self):
408+
"""
409+
Test bulk search with invalid PURLs.
410+
"""
411+
url = reverse("package-v2-bulk-search")
412+
data = {"purls": ["pkg:pypi/nonexistent@1.0.0", "pkg:npm/unknown@0.0.1"]}
413+
response = self.client.post(url, data, format="json")
414+
self.assertEqual(response.status_code, status.HTTP_200_OK)
415+
self.assertEqual(len(response.data), 0)
416+
417+
def test_bulk_search_with_empty_purls(self):
418+
"""
419+
Test bulk search with empty purls list.
420+
Should return 400 Bad Request.
421+
"""
422+
url = reverse("package-v2-bulk-search")
423+
data = {"purls": []}
424+
response = self.client.post(url, data, format="json")
425+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
426+
self.assertIn("error", response.data)
427+
self.assertIn("message", response.data)
428+
self.assertEqual(response.data["message"], "A non-empty 'purls' list of PURLs is required.")
429+
430+
def test_all_vulnerable_packages(self):
431+
"""
432+
Test the 'all' endpoint that returns all vulnerable package URLs.
433+
"""
434+
url = reverse("package-v2-all")
435+
response = self.client.get(url, format="json")
436+
self.assertEqual(response.status_code, status.HTTP_200_OK)
437+
# Since package1 and package3 are vulnerable, they should be returned
438+
expected_purls = ["pkg:pypi/django@3.2"]
439+
self.assertEqual(sorted(response.data), sorted(expected_purls))
440+
441+
def test_lookup_with_valid_purl(self):
442+
"""
443+
Test the 'lookup' endpoint with a valid PURL.
444+
"""
445+
url = reverse("package-v2-lookup")
446+
data = {"purl": "pkg:pypi/django@3.2"}
447+
response = self.client.post(url, data, format="json")
448+
self.assertEqual(response.status_code, status.HTTP_200_OK)
449+
self.assertEqual(len(response.data), 1)
450+
self.assertEqual(response.data[0]["purl"], "pkg:pypi/django@3.2")
451+
self.assertEqual(response.data[0]["affected_by_vulnerabilities"], ["VCID-1234"])
452+
453+
def test_lookup_with_invalid_purl(self):
454+
"""
455+
Test the 'lookup' endpoint with a PURL that does not exist.
456+
Should return an empty list.
457+
"""
458+
url = reverse("package-v2-lookup")
459+
data = {"purl": "pkg:pypi/nonexistent@1.0.0"}
460+
response = self.client.post(url, data, format="json")
461+
self.assertEqual(response.status_code, status.HTTP_200_OK)
462+
# No packages should be returned
463+
self.assertEqual(len(response.data), 0)
464+
465+
def test_lookup_with_missing_purl(self):
466+
"""
467+
Test the 'lookup' endpoint without providing a 'purl'.
468+
Should return 400 Bad Request.
469+
"""
470+
url = reverse("package-v2-lookup")
471+
data = {}
472+
response = self.client.post(url, data, format="json")
473+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
474+
self.assertIn("error", response.data)
475+
self.assertIn("message", response.data)
476+
self.assertEqual(response.data["message"], "A 'purl' is required.")
477+
478+
def test_lookup_with_invalid_purl_format(self):
479+
"""
480+
Test the 'lookup' endpoint with an invalid PURL format.
481+
Should return 400 Bad Request.
482+
"""
483+
url = reverse("package-v2-lookup")
484+
data = {"purl": "invalid_purl_format"}
485+
response = self.client.post(url, data, format="json")
486+
self.assertEqual(response.status_code, status.HTTP_200_OK)

0 commit comments

Comments
 (0)