diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index fbff5294..5f19d8b3 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -29,6 +29,15 @@ Release notes
analysed are listed and can be selected for "analysis propagation".
https://github.com/aboutcode-org/dejacode/issues/105
+- Add vulnerabilities REST API endpoint that mimics the content and features of the
+ vulnerabilities list view.
+ Add `risk_score` and `affected_by_vulnerabilities` fields in Package endpoint.
+ Add `vulnerability_analyses` field in Product and ProductPackage endpoints.
+ Add `is_vulnerable` and `affected_by` filters in Product, Package, and ProductPackage
+ endpoints.
+ Add `risk_score` filter in Package endpoint.
+ https://github.com/aboutcode-org/dejacode/issues/104
+
### Version 5.2.1
- Fix the models documentation navigation.
diff --git a/component_catalog/api.py b/component_catalog/api.py
index e39d56e0..f90977c3 100644
--- a/component_catalog/api.py
+++ b/component_catalog/api.py
@@ -24,6 +24,7 @@
from component_catalog.admin import ComponentAdmin
from component_catalog.admin import PackageAdmin
+from component_catalog.filters import IsVulnerableFilter
from component_catalog.fuzzy import FuzzyPackageNameSearch
from component_catalog.license_expression_dje import get_license_objects
from component_catalog.license_expression_dje import normalize_and_validate_expression
@@ -54,6 +55,9 @@
from dje.views import SendAboutFilesMixin
from license_library.models import License
from organization.api import OwnerEmbeddedSerializer
+from vulnerabilities.api import VulnerabilitySerializer
+from vulnerabilities.filters import RISK_SCORE_RANGES
+from vulnerabilities.filters import ScoreRangeFilter
class LicenseSummaryMixin:
@@ -426,6 +430,13 @@ class ComponentFilterSet(DataspacedAPIFilterSet):
name_version = NameVersionFilter(
label="Name:Version",
)
+ is_vulnerable = IsVulnerableFilter(
+ field_name="affected_by_vulnerabilities",
+ )
+ affected_by = django_filters.CharFilter(
+ field_name="affected_by_vulnerabilities__vulnerability_id",
+ label="Affected by (vulnerability_id)",
+ )
class Meta:
model = Component
@@ -450,6 +461,8 @@ class Meta:
"last_modified_date",
"name_version",
"keywords",
+ "is_vulnerable",
+ "affected_by",
)
@@ -610,6 +623,15 @@ class PackageSerializer(
required=False,
allow_null=True,
)
+ affected_by_vulnerabilities = VulnerabilitySerializer(
+ read_only=True,
+ many=True,
+ fields=[
+ "vulnerability_id",
+ "api_url",
+ "uuid",
+ ],
+ )
class Meta:
model = Package
@@ -669,6 +691,8 @@ class Meta:
"created_date",
"last_modified_date",
"collect_data",
+ "risk_score",
+ "affected_by_vulnerabilities",
)
extra_kwargs = {
"api_url": {
@@ -777,6 +801,14 @@ class PackageAPIFilterSet(DataspacedAPIFilterSet):
last_modified_date = LastModifiedDateFilter()
fuzzy = FuzzyPackageNameSearch(widget=HiddenInput)
purl = PackageURLFilter(label="Package URL")
+ is_vulnerable = IsVulnerableFilter(
+ field_name="affected_by_vulnerabilities",
+ )
+ affected_by = django_filters.CharFilter(
+ field_name="affected_by_vulnerabilities__vulnerability_id",
+ label="Affected by (vulnerability_id)",
+ )
+ risk_score = ScoreRangeFilter(score_ranges=RISK_SCORE_RANGES)
class Meta:
model = Package
@@ -801,6 +833,9 @@ class Meta:
"last_modified_date",
"fuzzy",
"purl",
+ "is_vulnerable",
+ "affected_by",
+ "risk_score",
)
@@ -877,6 +912,7 @@ def get_queryset(self):
.prefetch_related(
"component_set__owner",
"licenses__category",
+ "affected_by_vulnerabilities",
external_references_prefetch,
)
)
diff --git a/component_catalog/tests/test_api.py b/component_catalog/tests/test_api.py
index 7ea34d3e..fdfea27f 100644
--- a/component_catalog/tests/test_api.py
+++ b/component_catalog/tests/test_api.py
@@ -45,6 +45,7 @@
from license_library.models import LicenseChoice
from organization.models import Owner
from policy.models import UsagePolicy
+from vulnerabilities.tests import make_vulnerability
@override_settings(
@@ -1034,6 +1035,14 @@ def test_api_package_list_endpoint_filters(self):
self.assertContains(response, self.package1_detail_url)
self.assertNotContains(response, self.package2_detail_url)
+ self.package1.risk_score = 9.0
+ self.package1.save()
+ data = {"risk_score": "critical"}
+ response = self.client.get(self.package_list_url, data)
+ self.assertEqual(1, response.data["count"])
+ self.assertContains(response, self.package1_detail_url)
+ self.assertNotContains(response, self.package2_detail_url)
+
def test_api_package_list_endpoint_multiple_char_filters(self):
self.client.login(username="super_user", password="secret")
filters = "?md5={}&md5={}".format(self.package1.md5, self.package2.md5)
@@ -1325,6 +1334,37 @@ def test_api_package_endpoint_update_put(self):
self.assertEqual(self.base_user, self.package1.created_by)
self.assertEqual(self.super_user, self.package1.last_modified_by)
+ def test_api_package_endpoint_vulnerabilities_features(self):
+ self.client.login(username="super_user", password="secret")
+ vulnerability1 = make_vulnerability(self.dataspace, affecting=self.package1)
+ vulnerability2 = make_vulnerability(self.dataspace)
+ self.package1.update(risk_score=9.0)
+
+ data = {"is_vulnerable": "yes"}
+ response = self.client.get(self.package_list_url, data)
+ self.assertEqual(1, response.data["count"])
+ self.assertContains(response, self.package1_detail_url)
+ self.assertNotContains(response, self.package2_detail_url)
+
+ results = response.data["results"]
+ self.assertEqual("9.0", results[0]["risk_score"])
+ self.assertEqual(
+ vulnerability1.vulnerability_id,
+ results[0]["affected_by_vulnerabilities"][0]["vulnerability_id"],
+ )
+
+ data = {"affected_by": vulnerability1.vulnerability_id}
+ response = self.client.get(self.package_list_url, data)
+ self.assertEqual(1, response.data["count"])
+ self.assertContains(response, self.package1_detail_url)
+ self.assertNotContains(response, self.package2_detail_url)
+
+ data = {"affected_by": vulnerability2.vulnerability_id}
+ response = self.client.get(self.package_list_url, data)
+ self.assertEqual(0, response.data["count"])
+ self.assertNotContains(response, self.package1_detail_url)
+ self.assertNotContains(response, self.package2_detail_url)
+
def test_api_package_license_choices_fields(self):
self.client.login(username="super_user", password="secret")
diff --git a/dejacode/urls.py b/dejacode/urls.py
index e7f862e3..047dfb9c 100644
--- a/dejacode/urls.py
+++ b/dejacode/urls.py
@@ -52,6 +52,7 @@
from product_portfolio.api import ProductPackageViewSet
from product_portfolio.api import ProductViewSet
from reporting.api import ReportViewSet
+from vulnerabilities.api import VulnerabilityViewSet
from workflow.api import RequestTemplateViewSet
from workflow.api import RequestViewSet
@@ -78,6 +79,7 @@
api_router.register("reports", ReportViewSet)
api_router.register("external_references", ExternalReferenceViewSet)
api_router.register("usage_policies", UsagePolicyViewSet)
+api_router.register("vulnerabilities", VulnerabilityViewSet)
urlpatterns = [
diff --git a/dje/api.py b/dje/api.py
index b5b959f7..09b7d751 100644
--- a/dje/api.py
+++ b/dje/api.py
@@ -226,7 +226,32 @@ def get_permissions(self):
return permission_classes + extra_permission
-class DataspacedSerializer(serializers.HyperlinkedModelSerializer):
+class DynamicFieldsSerializerMixin:
+ """
+ A Serializer mixin that takes an additional `fields` or `exclude_fields`
+ arguments to customize the field selection.
+
+ Inspired by https://www.django-rest-framework.org/api-guide/serializers/#example
+ """
+
+ def __init__(self, *args, **kwargs):
+ fields = kwargs.pop("fields", [])
+ exclude_fields = kwargs.pop("exclude_fields", [])
+
+ super().__init__(*args, **kwargs)
+
+ if fields:
+ self.fields = {
+ field_name: field
+ for field_name, field in self.fields.items()
+ if field_name in fields
+ }
+
+ for field_name in exclude_fields:
+ self.fields.pop(field_name)
+
+
+class DataspacedSerializer(DynamicFieldsSerializerMixin, serializers.HyperlinkedModelSerializer):
def __init__(self, *args, **kwargs):
"""
Add the `dataspace` attribute from the request User Dataspace.
diff --git a/dje/tests/test_outputs.py b/dje/tests/test_outputs.py
index 1848cbd5..331984a6 100644
--- a/dje/tests/test_outputs.py
+++ b/dje/tests/test_outputs.py
@@ -20,8 +20,8 @@
from dje.tests import create_user
from product_portfolio.models import Product
from product_portfolio.tests import make_product_package
-from vulnerabilities.models import VulnerabilityAnalysis
from vulnerabilities.tests import make_vulnerability
+from vulnerabilities.tests import make_vulnerability_analysis
class OutputsTestCase(TestCase):
@@ -117,21 +117,19 @@ def test_outputs_get_cyclonedx_bom_include_vex(self):
self.assertEqual(vulnerability1.vulnerability_id, bom.vulnerabilities[0].id)
self.assertIsNone(bom.vulnerabilities[0].analysis)
- VulnerabilityAnalysis.objects.create(
- product_package=product_package1,
- vulnerability=vulnerability1,
- state=VulnerabilityAnalysis.State.RESOLVED,
- justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT,
- detail="detail",
- dataspace=self.dataspace,
- )
+ analysis1 = make_vulnerability_analysis(product_package1, vulnerability1)
bom = outputs.get_cyclonedx_bom(
instance=self.product1,
user=self.super_user,
include_vex=True,
)
analysis = bom.vulnerabilities[0].analysis
- expected = {"detail": "detail", "justification": "code_not_present", "state": "resolved"}
+ expected = {
+ "detail": analysis1.detail,
+ "justification": str(analysis1.justification),
+ "response": [str(response) for response in analysis1.responses],
+ "state": str(analysis1.state),
+ }
self.assertEqual(expected, json.loads(analysis.as_json()))
def test_outputs_get_cyclonedx_bom_json(self):
diff --git a/product_portfolio/api.py b/product_portfolio/api.py
index a8482fee..c5e21fda 100644
--- a/product_portfolio/api.py
+++ b/product_portfolio/api.py
@@ -19,6 +19,7 @@
from component_catalog.api import KeywordsField
from component_catalog.api import PackageEmbeddedSerializer
from component_catalog.api import ValidateLicenseExpressionMixin
+from component_catalog.filters import IsVulnerableFilter
from component_catalog.license_expression_dje import clean_related_expression
from dje.api import AboutCodeFilesActionMixin
from dje.api import CreateRetrieveUpdateListViewSet
@@ -47,6 +48,7 @@
from product_portfolio.models import ProductComponent
from product_portfolio.models import ProductDependency
from product_portfolio.models import ProductPackage
+from vulnerabilities.api import VulnerabilityAnalysisSerializer
base_extra_kwargs = {
"licenses": {
@@ -103,6 +105,10 @@ class ProductSerializer(ValidateLicenseExpressionMixin, DataspacedSerializer):
keywords = KeywordsField(
required=False,
)
+ vulnerability_analyses = VulnerabilityAnalysisSerializer(
+ read_only=True,
+ many=True,
+ )
class Meta:
model = Product
@@ -119,6 +125,7 @@ class Meta:
"licenses",
"components",
"packages",
+ "vulnerability_analyses",
"keywords",
"release_date",
"description",
@@ -184,6 +191,13 @@ class ProductFilterSet(DataspacedAPIFilterSet):
help_text="Keyword label contains (case-insensitive)",
)
last_modified_date = LastModifiedDateFilter()
+ is_vulnerable = IsVulnerableFilter(
+ field_name="packages__affected_by_vulnerabilities",
+ )
+ affected_by = django_filters.CharFilter(
+ field_name="packages__affected_by_vulnerabilities__vulnerability_id",
+ label="Affected by (vulnerability_id)",
+ )
class Meta:
model = Product
@@ -200,6 +214,8 @@ class Meta:
"configuration_status",
"license_expression",
"last_modified_date",
+ "is_vulnerable",
+ "affected_by",
)
@@ -320,6 +336,8 @@ def get_queryset(self):
"components",
"packages",
"licenses",
+ "vulnerability_analyses__vulnerability",
+ "vulnerability_analyses__product_package",
)
)
@@ -528,6 +546,9 @@ class ProductComponentFilterSet(DataspacedAPIFilterSet):
help_text='Supported values: "catalog", "custom".',
)
last_modified_date = LastModifiedDateFilter()
+ is_vulnerable = IsVulnerableFilter(
+ field_name="component__affected_by_vulnerabilities",
+ )
class Meta:
model = ProductComponent
@@ -538,8 +559,11 @@ class Meta:
"review_status",
"purpose",
"feature",
+ "is_deployed",
+ "is_modified",
"completeness",
"last_modified_date",
+ "is_vulnerable",
)
@@ -606,6 +630,11 @@ class ProductPackageSerializer(BaseProductRelationSerializer):
source="package",
read_only=True,
)
+ vulnerability_analyses = VulnerabilityAnalysisSerializer(
+ many=True,
+ read_only=True,
+ exclude_fields=["product_package"],
+ )
class Meta:
model = ProductPackage
@@ -628,6 +657,7 @@ class Meta:
"package_paths",
"reference_notes",
"issue_ref",
+ "vulnerability_analyses",
"created_date",
"last_modified_date",
)
@@ -664,6 +694,13 @@ class ProductPackageFilterSet(DataspacedAPIFilterSet):
help_text="Exact feature label.",
)
last_modified_date = LastModifiedDateFilter()
+ is_vulnerable = IsVulnerableFilter(
+ field_name="package__affected_by_vulnerabilities",
+ )
+ affected_by = django_filters.CharFilter(
+ field_name="package__affected_by_vulnerabilities__vulnerability_id",
+ label="Affected by (vulnerability_id)",
+ )
class Meta:
model = ProductPackage
@@ -675,6 +712,8 @@ class Meta:
"purpose",
"feature",
"last_modified_date",
+ "is_vulnerable",
+ "affected_by",
)
@@ -692,6 +731,15 @@ class ProductPackageViewSet(ProductRelationViewSet):
"last_modified_date",
)
+ def get_queryset(self):
+ return (
+ super()
+ .get_queryset()
+ .prefetch_related(
+ "vulnerability_analyses__vulnerability",
+ )
+ )
+
class CodebaseResourceSerializer(DataspacedSerializer):
product = NameVersionHyperlinkedRelatedField(
diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py
index 7a9785bc..de7566b4 100644
--- a/product_portfolio/filters.py
+++ b/product_portfolio/filters.py
@@ -14,6 +14,7 @@
import django_filters
from packageurl.contrib.django.utils import purl_to_lookups
+from component_catalog.filters import IsVulnerableFilter
from component_catalog.models import ComponentKeyword
from component_catalog.programming_languages import PROGRAMMING_LANGUAGES
from dje.filters import BooleanChoiceFilter
@@ -23,6 +24,7 @@
from dje.filters import MatchOrderedSearchFilter
from dje.filters import SearchFilter
from dje.widgets import BootstrapSelectMultipleWidget
+from dje.widgets import DropDownRightWidget
from dje.widgets import DropDownWidget
from license_library.models import License
from product_portfolio.models import CodebaseResource
@@ -105,6 +107,10 @@ class ProductFilterSet(DataspacedFilterSet):
search_placeholder="Search keywords",
),
)
+ is_vulnerable = IsVulnerableFilter(
+ field_name="packages__affected_by_vulnerabilities",
+ widget=DropDownRightWidget(link_content=''),
+ )
affected_by = django_filters.CharFilter(
field_name="packages__affected_by_vulnerabilities__vulnerability_id",
label=_("Affected by"),
@@ -223,13 +229,8 @@ class ProductComponentFilterSet(BaseProductRelationFilterSet):
"is_modified",
],
)
- is_vulnerable = HasRelationFilter(
- label=_("Is Vulnerable"),
+ is_vulnerable = IsVulnerableFilter(
field_name="component__affected_by_vulnerabilities",
- choices=(
- ("yes", _("Affected by vulnerabilities")),
- ("no", _("No vulnerabilities found")),
- ),
widget=DropDownWidget(
anchor="#inventory", right_align=True, link_content=''
),
@@ -275,13 +276,8 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet):
"is_modified",
],
)
- is_vulnerable = HasRelationFilter(
- label=_("Is Vulnerable"),
+ is_vulnerable = IsVulnerableFilter(
field_name="package__affected_by_vulnerabilities",
- choices=(
- ("yes", _("Affected by vulnerabilities")),
- ("no", _("No vulnerabilities found")),
- ),
widget=DropDownWidget(
anchor="#inventory", right_align=True, link_content=''
),
diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py
index 3bf50597..de5599eb 100644
--- a/product_portfolio/tests/test_api.py
+++ b/product_portfolio/tests/test_api.py
@@ -46,6 +46,8 @@
from product_portfolio.models import ProductRelationStatus
from product_portfolio.models import ProductStatus
from product_portfolio.models import ScanCodeProject
+from vulnerabilities.tests import make_vulnerability
+from vulnerabilities.tests import make_vulnerability_analysis
class ProductAPITestCase(MaxQueryMixin, TestCase):
@@ -1109,6 +1111,74 @@ def test_api_productpackage_endpoint_create_permissions(self):
response = self.client.post(self.productpackage_list_url, data)
self.assertEqual(status.HTTP_201_CREATED, response.status_code)
+ def test_api_productpackage_endpoint_vulnerabilities_features(self):
+ self.client.login(username="super_user", password="secret")
+ vulnerability1 = make_vulnerability(self.dataspace, affecting=self.package1)
+ vulnerability2 = make_vulnerability(self.dataspace)
+ analysis1 = make_vulnerability_analysis(self.pp1, vulnerability1)
+
+ response = self.client.get(self.pp1_detail_url)
+ response_analysis = response.data["vulnerability_analyses"][0]
+ self.assertEqual(vulnerability1.vulnerability_id, response_analysis["vulnerability_id"])
+ self.assertEqual(analysis1.state, response_analysis["state"])
+ self.assertEqual(analysis1.justification, response_analysis["justification"])
+
+ data = {"is_vulnerable": "no"}
+ response = self.client.get(self.productpackage_list_url, data)
+ self.assertEqual(0, response.data["count"])
+ self.assertNotContains(response, self.pp1_detail_url)
+
+ data = {"is_vulnerable": "yes"}
+ response = self.client.get(self.productpackage_list_url, data)
+ self.assertEqual(1, response.data["count"])
+ self.assertContains(response, self.pp1_detail_url)
+
+ data = {"affected_by": vulnerability1.vulnerability_id}
+ response = self.client.get(self.productpackage_list_url, data)
+ self.assertEqual(1, response.data["count"])
+ self.assertContains(response, self.pp1_detail_url)
+
+ data = {"affected_by": vulnerability2.vulnerability_id}
+ response = self.client.get(self.productpackage_list_url, data)
+ self.assertEqual(0, response.data["count"])
+ self.assertNotContains(response, self.pp1_detail_url)
+
+ def test_api_product_endpoint_vulnerabilities_features(self):
+ self.client.login(username="super_user", password="secret")
+ vulnerability1 = make_vulnerability(self.dataspace, affecting=self.package1)
+ vulnerability2 = make_vulnerability(self.dataspace)
+ analysis1 = make_vulnerability_analysis(self.pp1, vulnerability1)
+
+ response = self.client.get(self.product1_detail_url)
+ response_analysis = response.data["vulnerability_analyses"][0]
+ self.assertEqual(vulnerability1.vulnerability_id, response_analysis["vulnerability_id"])
+ self.assertEqual(analysis1.state, response_analysis["state"])
+ self.assertEqual(analysis1.justification, response_analysis["justification"])
+
+ data = {"is_vulnerable": "no"}
+ response = self.client.get(self.product_list_url, data)
+ self.assertEqual(1, response.data["count"])
+ self.assertNotContains(response, self.product1_detail_url)
+ self.assertContains(response, self.product2_detail_url)
+
+ data = {"is_vulnerable": "yes"}
+ response = self.client.get(self.product_list_url, data)
+ self.assertEqual(1, response.data["count"])
+ self.assertContains(response, self.product1_detail_url)
+ self.assertNotContains(response, self.product2_detail_url)
+
+ data = {"affected_by": vulnerability1.vulnerability_id}
+ response = self.client.get(self.product_list_url, data)
+ self.assertEqual(1, response.data["count"])
+ self.assertContains(response, self.product1_detail_url)
+ self.assertNotContains(response, self.product2_detail_url)
+
+ data = {"affected_by": vulnerability2.vulnerability_id}
+ response = self.client.get(self.product_list_url, data)
+ self.assertEqual(0, response.data["count"])
+ self.assertNotContains(response, self.product1_detail_url)
+ self.assertNotContains(response, self.product2_detail_url)
+
def test_api_codebaseresource_list_endpoint_results(self):
self.client.login(username="super_user", password="secret")
response = self.client.get(self.codebase_resource_list_url)
diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py
index e998f25b..7d136da2 100644
--- a/product_portfolio/tests/test_views.py
+++ b/product_portfolio/tests/test_views.py
@@ -61,6 +61,7 @@
from product_portfolio.views import ManageComponentGridView
from vulnerabilities.models import VulnerabilityAnalysis
from vulnerabilities.tests import make_vulnerability
+from vulnerabilities.tests import make_vulnerability_analysis
from workflow.models import Request
from workflow.models import RequestTemplate
@@ -347,24 +348,9 @@ def test_product_portfolio_tab_vulnerability_view_queries(self):
product_package1 = make_product_package(product1, package=p1)
product_package2 = make_product_package(product1, package=p2)
- VulnerabilityAnalysis.objects.create(
- product_package=product_package1,
- vulnerability=vulnerability1,
- state=VulnerabilityAnalysis.State.RESOLVED,
- dataspace=self.dataspace,
- )
- VulnerabilityAnalysis.objects.create(
- product_package=product_package2,
- vulnerability=vulnerability1,
- state=VulnerabilityAnalysis.State.RESOLVED,
- dataspace=self.dataspace,
- )
- VulnerabilityAnalysis.objects.create(
- product_package=product_package2,
- vulnerability=vulnerability2,
- state=VulnerabilityAnalysis.State.RESOLVED,
- dataspace=self.dataspace,
- )
+ make_vulnerability_analysis(product_package1, vulnerability1)
+ make_vulnerability_analysis(product_package2, vulnerability1)
+ make_vulnerability_analysis(product_package2, vulnerability2)
url = product1.get_url("tab_vulnerabilities")
with self.assertNumQueries(9):
@@ -379,19 +365,7 @@ def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self):
product1 = make_product(self.dataspace)
product_package1 = make_product_package(product1, package=p1)
make_product_package(product1, package=p2)
-
- analysis = VulnerabilityAnalysis.objects.create(
- product_package=product_package1,
- vulnerability=vulnerability1,
- state=VulnerabilityAnalysis.State.RESOLVED,
- justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT,
- responses=[
- VulnerabilityAnalysis.Response.CAN_NOT_FIX,
- VulnerabilityAnalysis.Response.ROLLBACK,
- ],
- detail="detail",
- dataspace=self.dataspace,
- )
+ analysis1 = make_vulnerability_analysis(product_package1, vulnerability1)
url = product1.get_url("tab_vulnerabilities")
response = self.client.get(url)
@@ -404,7 +378,7 @@ def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self):
for package in vulnerability.affected_packages.all()
}
self.assertTrue(hasattr(packages.get(p1.uuid), "vulnerability_analysis"))
- self.assertEqual(analysis, packages.get(p1.uuid).vulnerability_analysis)
+ self.assertEqual(analysis1, packages.get(p1.uuid).vulnerability_analysis)
self.assertFalse(hasattr(packages.get(p2.uuid), "vulnerability_analysis"))
expected = """
diff --git a/product_portfolio/views.py b/product_portfolio/views.py
index 69d602bc..99e8614a 100644
--- a/product_portfolio/views.py
+++ b/product_portfolio/views.py
@@ -161,7 +161,7 @@ class ProductListView(
put_results_in_session = False
group_name_version = True
table_headers = (
- Header("name", "Product name"),
+ Header("name", "Product name", filter="is_vulnerable"),
Header("version", "Version"),
Header("license_expression", "License", filter="licenses"),
Header("primary_language", "Language", filter="primary_language"),
diff --git a/vulnerabilities/api.py b/vulnerabilities/api.py
new file mode 100644
index 00000000..3d5dbce6
--- /dev/null
+++ b/vulnerabilities/api.py
@@ -0,0 +1,155 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+
+from django.db.models import Prefetch
+
+from rest_framework import serializers
+from rest_framework import viewsets
+
+from component_catalog.models import Package
+from dje.api import DataspacedAPIFilterSet
+from dje.api import DataspacedSerializer
+from dje.api import ExtraPermissionsViewSetMixin
+from dje.api_custom import TabPermission
+from dje.filters import LastModifiedDateFilter
+from dje.filters import MultipleUUIDFilter
+from vulnerabilities.filters import RISK_SCORE_RANGES
+from vulnerabilities.filters import ScoreRangeFilter
+from vulnerabilities.models import Vulnerability
+from vulnerabilities.models import VulnerabilityAnalysis
+
+
+class VulnerabilitySerializer(DataspacedSerializer):
+ # Using SerializerMethodField to workaround circular imports
+ affected_packages = serializers.SerializerMethodField()
+ affected_products = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Vulnerability
+ fields = (
+ "api_url",
+ "uuid",
+ "vulnerability_id",
+ "resource_url",
+ "summary",
+ "aliases",
+ "references",
+ "exploitability",
+ "weighted_severity",
+ "risk_score",
+ "affected_packages",
+ "affected_products",
+ )
+ extra_kwargs = {
+ "api_url": {
+ "view_name": "api_v2:vulnerability-detail",
+ "lookup_field": "uuid",
+ },
+ }
+
+ def get_affected_packages(self, obj):
+ from component_catalog.api import PackageSerializer
+
+ packages = obj.affected_packages.all()
+ fields = [
+ "display_name",
+ "api_url",
+ "uuid",
+ ]
+ return PackageSerializer(packages, many=True, context=self.context, fields=fields).data
+
+ def get_affected_products(self, obj):
+ from product_portfolio.api import ProductSerializer
+
+ products = (
+ product_package.product
+ for package in obj.affected_packages.all()
+ for product_package in package.productpackages.all()
+ )
+ fields = [
+ "display_name",
+ "api_url",
+ "uuid",
+ ]
+ return ProductSerializer(products, many=True, context=self.context, fields=fields).data
+
+
+class VulnerabilityFilterSet(DataspacedAPIFilterSet):
+ uuid = MultipleUUIDFilter()
+ last_modified_date = LastModifiedDateFilter()
+ weighted_severity = ScoreRangeFilter(score_ranges=RISK_SCORE_RANGES)
+ risk_score = ScoreRangeFilter(score_ranges=RISK_SCORE_RANGES)
+
+ class Meta:
+ model = Vulnerability
+ fields = (
+ "uuid",
+ "exploitability",
+ "weighted_severity",
+ "risk_score",
+ "created_date",
+ "last_modified_date",
+ )
+
+
+class VulnerabilityViewSet(ExtraPermissionsViewSetMixin, viewsets.ReadOnlyModelViewSet):
+ queryset = Vulnerability.objects.all()
+ serializer_class = VulnerabilitySerializer
+ lookup_field = "uuid"
+ filterset_class = VulnerabilityFilterSet
+ extra_permissions = (TabPermission,)
+ search_fields = ("vulnerability_id", "aliases")
+ ordering_fields = (
+ "exploitability",
+ "weighted_severity",
+ "risk_score",
+ "created_date",
+ "last_modified_date",
+ )
+
+ def get_queryset(self):
+ package_qs = Package.objects.only_rendering_fields()
+
+ return (
+ super()
+ .get_queryset()
+ .scope(self.request.user.dataspace)
+ .prefetch_related(
+ Prefetch("affected_packages", queryset=package_qs),
+ Prefetch("affected_packages__productpackages__product"),
+ )
+ .order_by_risk()
+ )
+
+
+class VulnerabilityAnalysisSerializer(DataspacedSerializer, serializers.ModelSerializer):
+ vulnerability_id = serializers.ReadOnlyField(source="vulnerability.vulnerability_id")
+
+ class Meta:
+ model = VulnerabilityAnalysis
+ fields = (
+ "uuid",
+ "product_package",
+ "vulnerability",
+ "vulnerability_id",
+ "state",
+ "justification",
+ "responses",
+ "detail",
+ )
+ extra_kwargs = {
+ "product_package": {
+ "view_name": "api_v2:productpackage-detail",
+ "lookup_field": "uuid",
+ },
+ "vulnerability": {
+ "view_name": "api_v2:vulnerability-detail",
+ "lookup_field": "uuid",
+ },
+ }
diff --git a/vulnerabilities/tests/__init__.py b/vulnerabilities/tests/__init__.py
index 50af8f8a..855d860b 100644
--- a/vulnerabilities/tests/__init__.py
+++ b/vulnerabilities/tests/__init__.py
@@ -8,6 +8,7 @@
from dje.tests import make_string
from vulnerabilities.models import Vulnerability
+from vulnerabilities.models import VulnerabilityAnalysis
def make_vulnerability(dataspace, affecting=None, **data):
@@ -24,3 +25,19 @@ def make_vulnerability(dataspace, affecting=None, **data):
vulnerability.add_affected(affecting)
return vulnerability
+
+
+def make_vulnerability_analysis(product_package, vulnerability, **data):
+ return VulnerabilityAnalysis.objects.create(
+ dataspace=product_package.dataspace,
+ product_package=product_package,
+ vulnerability=vulnerability,
+ state=VulnerabilityAnalysis.State.RESOLVED,
+ justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT,
+ responses=[
+ VulnerabilityAnalysis.Response.CAN_NOT_FIX,
+ VulnerabilityAnalysis.Response.ROLLBACK,
+ ],
+ detail="detail",
+ **data,
+ )
diff --git a/vulnerabilities/tests/test_api.py b/vulnerabilities/tests/test_api.py
new file mode 100644
index 00000000..96eab99b
--- /dev/null
+++ b/vulnerabilities/tests/test_api.py
@@ -0,0 +1,101 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+
+from django.test import TestCase
+from django.urls import reverse
+
+from component_catalog.tests import make_package
+from dje.models import Dataspace
+from dje.tests import MaxQueryMixin
+from dje.tests import create_superuser
+from product_portfolio.tests import make_product
+from vulnerabilities.tests import make_vulnerability
+
+
+class VulnerabilitiesAPITestCase(MaxQueryMixin, TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.super_user = create_superuser("super_user", self.dataspace)
+
+ self.vulnerabilities_list_url = reverse("api_v2:vulnerability-list")
+
+ self.package1 = make_package(self.dataspace)
+ self.package2 = make_package(self.dataspace)
+ self.product1 = make_product(self.dataspace, inventory=[self.package1, self.package2])
+ self.product2 = make_product(self.dataspace, inventory=[self.package2])
+ self.vulnerability1 = make_vulnerability(
+ dataspace=self.dataspace,
+ affecting=self.package1,
+ risk_score=0.0,
+ )
+ self.vulnerability2 = make_vulnerability(
+ dataspace=self.dataspace,
+ affecting=self.package2,
+ risk_score=5.0,
+ )
+ self.vulnerability3 = make_vulnerability(
+ dataspace=self.dataspace,
+ affecting=[self.package1, self.package2],
+ risk_score=10.0,
+ )
+
+ def test_api_vulnerabilities_list_endpoint_results(self):
+ self.client.login(username="super_user", password="secret")
+
+ with self.assertMaxQueries(9):
+ response = self.client.get(self.vulnerabilities_list_url)
+
+ self.assertEqual(3, response.data["count"])
+ results = response.data["results"]
+
+ # Ordered by risk_score
+ expected = [
+ self.vulnerability3.vulnerability_id,
+ self.vulnerability2.vulnerability_id,
+ self.vulnerability1.vulnerability_id,
+ ]
+ self.assertEqual(expected, [entry["vulnerability_id"] for entry in results])
+
+ self.assertEqual(str(self.package1), results[2]["affected_packages"][0]["display_name"])
+ self.assertEqual(str(self.product1), results[2]["affected_products"][0]["display_name"])
+
+ def test_api_vulnerabilities_list_endpoint_search(self):
+ self.client.login(username="super_user", password="secret")
+
+ data = {"search": self.vulnerability1.vulnerability_id}
+ response = self.client.get(self.vulnerabilities_list_url, data)
+ self.assertEqual(1, response.data["count"])
+ self.assertContains(response, self.vulnerability1.vulnerability_id)
+ self.assertNotContains(response, self.vulnerability2.vulnerability_id)
+ self.assertNotContains(response, self.vulnerability3.vulnerability_id)
+
+ def test_api_vulnerabilities_list_endpoint_filters(self):
+ self.client.login(username="super_user", password="secret")
+
+ data = {"risk_score": "critical"}
+ response = self.client.get(self.vulnerabilities_list_url, data)
+ self.assertEqual(1, response.data["count"])
+ self.assertNotContains(response, self.vulnerability1.vulnerability_id)
+ self.assertNotContains(response, self.vulnerability2.vulnerability_id)
+ self.assertContains(response, self.vulnerability3.vulnerability_id)
+
+ def test_api_vulnerabilities_detail_endpoint(self):
+ detail_url = reverse("api_v2:vulnerability-detail", args=[self.vulnerability1.uuid])
+ self.client.login(username="super_user", password="secret")
+
+ with self.assertNumQueries(8):
+ response = self.client.get(detail_url)
+
+ self.assertContains(response, detail_url)
+ self.assertIn(detail_url, response.data["api_url"])
+ self.assertEqual(self.vulnerability1.vulnerability_id, response.data["vulnerability_id"])
+ self.assertEqual(str(self.vulnerability1.uuid), response.data["uuid"])
+ self.assertEqual("0.0", response.data["risk_score"])
+ self.assertEqual(1, len(response.data["affected_packages"]))
+ self.assertEqual(1, len(response.data["affected_products"]))
diff --git a/vulnerabilities/tests/test_models.py b/vulnerabilities/tests/test_models.py
index ae290801..d60b4378 100644
--- a/vulnerabilities/tests/test_models.py
+++ b/vulnerabilities/tests/test_models.py
@@ -23,6 +23,7 @@
from vulnerabilities.models import Vulnerability
from vulnerabilities.models import VulnerabilityAnalysis
from vulnerabilities.tests import make_vulnerability
+from vulnerabilities.tests import make_vulnerability_analysis
class VulnerabilitiesModelsTestCase(TestCase):
@@ -211,20 +212,9 @@ def test_vulnerability_model_as_cyclonedx(self):
product1 = make_product(self.dataspace)
product_package1 = make_product_package(product1, package=package1)
- analysis = VulnerabilityAnalysis(
- product_package=product_package1,
- vulnerability=vulnerability1,
- state=VulnerabilityAnalysis.State.RESOLVED,
- justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT,
- responses=[
- VulnerabilityAnalysis.Response.CAN_NOT_FIX,
- VulnerabilityAnalysis.Response.ROLLBACK,
- ],
- detail="detail",
- dataspace=self.dataspace,
- )
+ analysis1 = make_vulnerability_analysis(product_package1, vulnerability1)
vulnerability1_as_cdx = vulnerability1.as_cyclonedx(
- affected_instances=[package1], analysis=analysis
+ affected_instances=[package1], analysis=analysis1
)
as_dict = json.loads(vulnerability1_as_cdx.as_json())
expected = {
@@ -263,39 +253,28 @@ def test_vulnerability_model_vulnerability_analysis_save(self):
def test_vulnerability_model_vulnerability_propagate(self):
vulnerability1 = make_vulnerability(dataspace=self.dataspace)
product_package1 = make_product_package(make_product(self.dataspace))
- analysis = VulnerabilityAnalysis.objects.create(
- product_package=product_package1,
- vulnerability=vulnerability1,
- dataspace=self.dataspace,
- state=VulnerabilityAnalysis.State.RESOLVED,
- justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT,
- responses=[
- VulnerabilityAnalysis.Response.CAN_NOT_FIX,
- VulnerabilityAnalysis.Response.ROLLBACK,
- ],
- detail="detail",
- )
+ analysis1 = make_vulnerability_analysis(product_package1, vulnerability1)
product2 = make_product(self.dataspace)
- new_analysis = analysis.propagate(product2.uuid, self.super_user)
+ new_analysis = analysis1.propagate(product2.uuid, self.super_user)
self.assertIsNone(new_analysis)
new_product_package = make_product_package(product2, package=product_package1.package)
- new_analysis = analysis.propagate(product2.uuid, self.super_user)
+ new_analysis = analysis1.propagate(product2.uuid, self.super_user)
self.assertIsNotNone(new_analysis)
- self.assertNotEqual(analysis.pk, new_analysis.pk)
+ self.assertNotEqual(analysis1.pk, new_analysis.pk)
self.assertEqual(vulnerability1, new_analysis.vulnerability)
self.assertEqual(new_product_package, new_analysis.product_package)
self.assertEqual(product2, new_analysis.product)
self.assertEqual(new_product_package.package, new_analysis.package)
self.assertEqual(self.super_user, new_analysis.created_by)
self.assertEqual(self.super_user, new_analysis.last_modified_by)
- self.assertEqual(analysis.state, new_analysis.state)
- self.assertEqual(analysis.justification, new_analysis.justification)
- self.assertEqual(analysis.detail, new_analysis.detail)
- self.assertEqual(analysis.responses, new_analysis.responses)
+ self.assertEqual(analysis1.state, new_analysis.state)
+ self.assertEqual(analysis1.justification, new_analysis.justification)
+ self.assertEqual(analysis1.detail, new_analysis.detail)
+ self.assertEqual(analysis1.responses, new_analysis.responses)
# Update
- analysis.update(state=VulnerabilityAnalysis.State.EXPLOITABLE)
- new_analysis = analysis.propagate(product2.uuid, self.super_user)
+ analysis1.update(state=VulnerabilityAnalysis.State.EXPLOITABLE)
+ new_analysis = analysis1.propagate(product2.uuid, self.super_user)
self.assertEqual(VulnerabilityAnalysis.State.EXPLOITABLE, new_analysis.state)