Skip to content

Commit 9eb7fb7

Browse files
tdruezpombredanne
andauthored
Add vulnerabilities REST API endpoint #104 (#203)
Signed-off-by: tdruez <[email protected]> Signed-off-by: Philippe Ombredanne <[email protected]> Co-authored-by: Philippe Ombredanne <[email protected]> Co-authored-by: Philippe Ombredanne <[email protected]>
1 parent 8312190 commit 9eb7fb7

File tree

15 files changed

+540
-90
lines changed

15 files changed

+540
-90
lines changed

CHANGELOG.rst

+9
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ Release notes
2929
analysed are listed and can be selected for "analysis propagation".
3030
https://github.com/aboutcode-org/dejacode/issues/105
3131

32+
- Add vulnerabilities REST API endpoint that mimics the content and features of the
33+
vulnerabilities list view.
34+
Add `risk_score` and `affected_by_vulnerabilities` fields in Package endpoint.
35+
Add `vulnerability_analyses` field in Product and ProductPackage endpoints.
36+
Add `is_vulnerable` and `affected_by` filters in Product, Package, and ProductPackage
37+
endpoints.
38+
Add `risk_score` filter in Package endpoint.
39+
https://github.com/aboutcode-org/dejacode/issues/104
40+
3241
### Version 5.2.1
3342

3443
- Fix the models documentation navigation.

component_catalog/api.py

+36
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from component_catalog.admin import ComponentAdmin
2626
from component_catalog.admin import PackageAdmin
27+
from component_catalog.filters import IsVulnerableFilter
2728
from component_catalog.fuzzy import FuzzyPackageNameSearch
2829
from component_catalog.license_expression_dje import get_license_objects
2930
from component_catalog.license_expression_dje import normalize_and_validate_expression
@@ -54,6 +55,9 @@
5455
from dje.views import SendAboutFilesMixin
5556
from license_library.models import License
5657
from organization.api import OwnerEmbeddedSerializer
58+
from vulnerabilities.api import VulnerabilitySerializer
59+
from vulnerabilities.filters import RISK_SCORE_RANGES
60+
from vulnerabilities.filters import ScoreRangeFilter
5761

5862

5963
class LicenseSummaryMixin:
@@ -426,6 +430,13 @@ class ComponentFilterSet(DataspacedAPIFilterSet):
426430
name_version = NameVersionFilter(
427431
label="Name:Version",
428432
)
433+
is_vulnerable = IsVulnerableFilter(
434+
field_name="affected_by_vulnerabilities",
435+
)
436+
affected_by = django_filters.CharFilter(
437+
field_name="affected_by_vulnerabilities__vulnerability_id",
438+
label="Affected by (vulnerability_id)",
439+
)
429440

430441
class Meta:
431442
model = Component
@@ -450,6 +461,8 @@ class Meta:
450461
"last_modified_date",
451462
"name_version",
452463
"keywords",
464+
"is_vulnerable",
465+
"affected_by",
453466
)
454467

455468

@@ -610,6 +623,15 @@ class PackageSerializer(
610623
required=False,
611624
allow_null=True,
612625
)
626+
affected_by_vulnerabilities = VulnerabilitySerializer(
627+
read_only=True,
628+
many=True,
629+
fields=[
630+
"vulnerability_id",
631+
"api_url",
632+
"uuid",
633+
],
634+
)
613635

614636
class Meta:
615637
model = Package
@@ -669,6 +691,8 @@ class Meta:
669691
"created_date",
670692
"last_modified_date",
671693
"collect_data",
694+
"risk_score",
695+
"affected_by_vulnerabilities",
672696
)
673697
extra_kwargs = {
674698
"api_url": {
@@ -777,6 +801,14 @@ class PackageAPIFilterSet(DataspacedAPIFilterSet):
777801
last_modified_date = LastModifiedDateFilter()
778802
fuzzy = FuzzyPackageNameSearch(widget=HiddenInput)
779803
purl = PackageURLFilter(label="Package URL")
804+
is_vulnerable = IsVulnerableFilter(
805+
field_name="affected_by_vulnerabilities",
806+
)
807+
affected_by = django_filters.CharFilter(
808+
field_name="affected_by_vulnerabilities__vulnerability_id",
809+
label="Affected by (vulnerability_id)",
810+
)
811+
risk_score = ScoreRangeFilter(score_ranges=RISK_SCORE_RANGES)
780812

781813
class Meta:
782814
model = Package
@@ -801,6 +833,9 @@ class Meta:
801833
"last_modified_date",
802834
"fuzzy",
803835
"purl",
836+
"is_vulnerable",
837+
"affected_by",
838+
"risk_score",
804839
)
805840

806841

@@ -877,6 +912,7 @@ def get_queryset(self):
877912
.prefetch_related(
878913
"component_set__owner",
879914
"licenses__category",
915+
"affected_by_vulnerabilities",
880916
external_references_prefetch,
881917
)
882918
)

component_catalog/tests/test_api.py

+40
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from license_library.models import LicenseChoice
4646
from organization.models import Owner
4747
from policy.models import UsagePolicy
48+
from vulnerabilities.tests import make_vulnerability
4849

4950

5051
@override_settings(
@@ -1034,6 +1035,14 @@ def test_api_package_list_endpoint_filters(self):
10341035
self.assertContains(response, self.package1_detail_url)
10351036
self.assertNotContains(response, self.package2_detail_url)
10361037

1038+
self.package1.risk_score = 9.0
1039+
self.package1.save()
1040+
data = {"risk_score": "critical"}
1041+
response = self.client.get(self.package_list_url, data)
1042+
self.assertEqual(1, response.data["count"])
1043+
self.assertContains(response, self.package1_detail_url)
1044+
self.assertNotContains(response, self.package2_detail_url)
1045+
10371046
def test_api_package_list_endpoint_multiple_char_filters(self):
10381047
self.client.login(username="super_user", password="secret")
10391048
filters = "?md5={}&md5={}".format(self.package1.md5, self.package2.md5)
@@ -1325,6 +1334,37 @@ def test_api_package_endpoint_update_put(self):
13251334
self.assertEqual(self.base_user, self.package1.created_by)
13261335
self.assertEqual(self.super_user, self.package1.last_modified_by)
13271336

1337+
def test_api_package_endpoint_vulnerabilities_features(self):
1338+
self.client.login(username="super_user", password="secret")
1339+
vulnerability1 = make_vulnerability(self.dataspace, affecting=self.package1)
1340+
vulnerability2 = make_vulnerability(self.dataspace)
1341+
self.package1.update(risk_score=9.0)
1342+
1343+
data = {"is_vulnerable": "yes"}
1344+
response = self.client.get(self.package_list_url, data)
1345+
self.assertEqual(1, response.data["count"])
1346+
self.assertContains(response, self.package1_detail_url)
1347+
self.assertNotContains(response, self.package2_detail_url)
1348+
1349+
results = response.data["results"]
1350+
self.assertEqual("9.0", results[0]["risk_score"])
1351+
self.assertEqual(
1352+
vulnerability1.vulnerability_id,
1353+
results[0]["affected_by_vulnerabilities"][0]["vulnerability_id"],
1354+
)
1355+
1356+
data = {"affected_by": vulnerability1.vulnerability_id}
1357+
response = self.client.get(self.package_list_url, data)
1358+
self.assertEqual(1, response.data["count"])
1359+
self.assertContains(response, self.package1_detail_url)
1360+
self.assertNotContains(response, self.package2_detail_url)
1361+
1362+
data = {"affected_by": vulnerability2.vulnerability_id}
1363+
response = self.client.get(self.package_list_url, data)
1364+
self.assertEqual(0, response.data["count"])
1365+
self.assertNotContains(response, self.package1_detail_url)
1366+
self.assertNotContains(response, self.package2_detail_url)
1367+
13281368
def test_api_package_license_choices_fields(self):
13291369
self.client.login(username="super_user", password="secret")
13301370

dejacode/urls.py

+2
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from product_portfolio.api import ProductPackageViewSet
5353
from product_portfolio.api import ProductViewSet
5454
from reporting.api import ReportViewSet
55+
from vulnerabilities.api import VulnerabilityViewSet
5556
from workflow.api import RequestTemplateViewSet
5657
from workflow.api import RequestViewSet
5758

@@ -78,6 +79,7 @@
7879
api_router.register("reports", ReportViewSet)
7980
api_router.register("external_references", ExternalReferenceViewSet)
8081
api_router.register("usage_policies", UsagePolicyViewSet)
82+
api_router.register("vulnerabilities", VulnerabilityViewSet)
8183

8284

8385
urlpatterns = [

dje/api.py

+26-1
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,32 @@ def get_permissions(self):
226226
return permission_classes + extra_permission
227227

228228

229-
class DataspacedSerializer(serializers.HyperlinkedModelSerializer):
229+
class DynamicFieldsSerializerMixin:
230+
"""
231+
A Serializer mixin that takes an additional `fields` or `exclude_fields`
232+
arguments to customize the field selection.
233+
234+
Inspired by https://www.django-rest-framework.org/api-guide/serializers/#example
235+
"""
236+
237+
def __init__(self, *args, **kwargs):
238+
fields = kwargs.pop("fields", [])
239+
exclude_fields = kwargs.pop("exclude_fields", [])
240+
241+
super().__init__(*args, **kwargs)
242+
243+
if fields:
244+
self.fields = {
245+
field_name: field
246+
for field_name, field in self.fields.items()
247+
if field_name in fields
248+
}
249+
250+
for field_name in exclude_fields:
251+
self.fields.pop(field_name)
252+
253+
254+
class DataspacedSerializer(DynamicFieldsSerializerMixin, serializers.HyperlinkedModelSerializer):
230255
def __init__(self, *args, **kwargs):
231256
"""
232257
Add the `dataspace` attribute from the request User Dataspace.

dje/tests/test_outputs.py

+8-10
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
from dje.tests import create_user
2121
from product_portfolio.models import Product
2222
from product_portfolio.tests import make_product_package
23-
from vulnerabilities.models import VulnerabilityAnalysis
2423
from vulnerabilities.tests import make_vulnerability
24+
from vulnerabilities.tests import make_vulnerability_analysis
2525

2626

2727
class OutputsTestCase(TestCase):
@@ -117,21 +117,19 @@ def test_outputs_get_cyclonedx_bom_include_vex(self):
117117
self.assertEqual(vulnerability1.vulnerability_id, bom.vulnerabilities[0].id)
118118
self.assertIsNone(bom.vulnerabilities[0].analysis)
119119

120-
VulnerabilityAnalysis.objects.create(
121-
product_package=product_package1,
122-
vulnerability=vulnerability1,
123-
state=VulnerabilityAnalysis.State.RESOLVED,
124-
justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT,
125-
detail="detail",
126-
dataspace=self.dataspace,
127-
)
120+
analysis1 = make_vulnerability_analysis(product_package1, vulnerability1)
128121
bom = outputs.get_cyclonedx_bom(
129122
instance=self.product1,
130123
user=self.super_user,
131124
include_vex=True,
132125
)
133126
analysis = bom.vulnerabilities[0].analysis
134-
expected = {"detail": "detail", "justification": "code_not_present", "state": "resolved"}
127+
expected = {
128+
"detail": analysis1.detail,
129+
"justification": str(analysis1.justification),
130+
"response": [str(response) for response in analysis1.responses],
131+
"state": str(analysis1.state),
132+
}
135133
self.assertEqual(expected, json.loads(analysis.as_json()))
136134

137135
def test_outputs_get_cyclonedx_bom_json(self):

0 commit comments

Comments
 (0)