diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 29bb2d03cba..c0d30d8a978 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -1264,6 +1264,7 @@ def update(self, instance, validated_data): class EndpointSerializer(serializers.ModelSerializer): tags = TagListSerializerField(required=False) + active_finding_count = serializers.IntegerField(read_only=True) class Meta: model = Endpoint diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index c106d667e77..8290d932eef 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -12,6 +12,8 @@ from django.contrib.auth.models import Permission from django.core.exceptions import ValidationError from django.db import IntegrityError +from django.db.models import OuterRef, Value +from django.db.models.functions import Coalesce from django.db.models.query import QuerySet as DjangoQuerySet from django.http import FileResponse, HttpResponse from django.shortcuts import get_object_or_404 @@ -166,6 +168,7 @@ get_authorized_product_type_members, get_authorized_product_types, ) +from dojo.query_utils import build_count_subquery from dojo.reports.views import ( prefetch_related_findings_for_report, report_url_resolver, @@ -345,7 +348,13 @@ class EndPointViewSet( ) def get_queryset(self): - return get_authorized_endpoints(Permissions.Location_View).distinct() + active_finding_subquery = build_count_subquery( + Finding.objects.filter(endpoints=OuterRef("pk"), active=True), + group_field="endpoints", + ) + return get_authorized_endpoints(Permissions.Location_View).annotate( + active_finding_count=Coalesce(active_finding_subquery, Value(0)), + ).distinct() @extend_schema( request=serializers.ReportGenerateOptionSerializer, diff --git a/dojo/endpoint/views.py b/dojo/endpoint/views.py index caa48f02757..addd0762c4c 100644 --- a/dojo/endpoint/views.py +++ b/dojo/endpoint/views.py @@ -59,7 +59,13 @@ def process_endpoints_view(request, *, host_view=False, vulnerable=False): else: endpoints = Endpoint.objects.all() - endpoints = endpoints.prefetch_related("product", "product__tags", "tags").distinct() + active_finding_subquery = build_count_subquery( + Finding.objects.filter(endpoints=OuterRef("pk"), active=True), + group_field="endpoints", + ) + endpoints = endpoints.prefetch_related("product", "product__tags", "tags").annotate( + active_finding_count=Coalesce(active_finding_subquery, Value(0)), + ).distinct() endpoints = get_authorized_endpoints_for_queryset(Permissions.Location_View, endpoints, request.user) filter_string_matching = get_system_setting("filter_string_matching", False) filter_class = EndpointFilterWithoutObjectLookups if filter_string_matching else EndpointFilter diff --git a/dojo/filters.py b/dojo/filters.py index 3c38ce7246f..610900237dc 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -2866,7 +2866,11 @@ class EndpointFilterHelper(FilterSet): ("product", "product"), ("host", "host"), ("id", "id"), + ("active_finding_count", "active_finding_count"), ), + field_labels={ + "active_finding_count": "Active Findings Count", + }, ) @@ -3108,7 +3112,11 @@ class ApiEndpointFilter(DojoFilter): ("host", "host"), ("product", "product"), ("id", "id"), + ("active_finding_count", "active_finding_count"), ), + field_labels={ + "active_finding_count": "Active Findings Count", + }, ) class Meta: diff --git a/dojo/location/api/endpoint_compat.py b/dojo/location/api/endpoint_compat.py index 1f75a3bfcf5..964da4c0d4d 100644 --- a/dojo/location/api/endpoint_compat.py +++ b/dojo/location/api/endpoint_compat.py @@ -6,6 +6,8 @@ """ import datetime +from django.db.models import OuterRef, Value +from django.db.models.functions import Coalesce from django_filters import BooleanFilter, CharFilter, NumberFilter from django_filters.rest_framework import DjangoFilterBackend, FilterSet from drf_spectacular.utils import extend_schema @@ -32,6 +34,7 @@ from dojo.location.models import LocationFindingReference, LocationProductReference from dojo.location.queries import get_authorized_location_finding_reference, get_authorized_location_product_reference from dojo.location.status import FindingLocationStatus +from dojo.query_utils import build_count_subquery from dojo.url.models import URL ########## @@ -101,7 +104,11 @@ class V3EndpointCompatibleFilterSet(FilterSet): ("location__url__host", "host"), ("product__id", "product"), ("id", "id"), + ("active_finding_count", "active_finding_count"), ), + field_labels={ + "active_finding_count": "Active Findings Count", + }, ) @@ -118,6 +125,7 @@ class V3EndpointCompatibleSerializer(ModelSerializer): fragment = CharField(source="location.url.fragment") tags = TagListSerializerField(source="location.tags") location_id = IntegerField(source="location.id") + active_finding_count = IntegerField(read_only=True) class Meta: model = LocationProductReference @@ -141,7 +149,18 @@ class V3EndpointCompatibleViewSet(PrefetchListMixin, PrefetchRetrieveMixin, view def get_queryset(self): """Get authorized URLs using Endpoint authorization logic.""" - return get_authorized_location_product_reference(Permissions.Location_View).filter(location__location_type=URL.LOCATION_TYPE).distinct() + active_finding_subquery = build_count_subquery( + LocationFindingReference.objects.filter( + location=OuterRef("location"), + status=FindingLocationStatus.Active, + ), + group_field="location", + ) + return get_authorized_location_product_reference(Permissions.Location_View).filter( + location__location_type=URL.LOCATION_TYPE, + ).annotate( + active_finding_count=Coalesce(active_finding_subquery, Value(0)), + ).distinct() @extend_schema( request=serializers.ReportGenerateOptionSerializer, diff --git a/dojo/templates/dojo/endpoints.html b/dojo/templates/dojo/endpoints.html index 82af13baa3a..5bda216c888 100644 --- a/dojo/templates/dojo/endpoints.html +++ b/dojo/templates/dojo/endpoints.html @@ -87,7 +87,7 @@

{% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} {% dojo_sort request 'Product' 'product' 'asc' %} {% endif %} - Active (Verified) Findings + {% dojo_sort request 'Active (Verified) Findings' 'active_finding_count' %} Status @@ -119,7 +119,7 @@

{% if host_view %} {{ e.host_active_findings_count }} ({{ e.host_active_verified_findings_count }}) {% else %} - {{ e.active_findings_count }} + {{ e.active_finding_count }} ({{ e.active_verified_findings_count }}) {% endif %} diff --git a/dojo/templates/dojo/url/list.html b/dojo/templates/dojo/url/list.html index d4511523fb5..f8f1aacc05d 100644 --- a/dojo/templates/dojo/url/list.html +++ b/dojo/templates/dojo/url/list.html @@ -97,7 +97,7 @@

Endpoint {% endif %} {% if not product_tab %}Active (Total) Products{% endif %} - Active (Total) Findings + {% dojo_sort request 'Active (Total) Findings' 'active_findings' %} Overall Status {% for location in locations %} diff --git a/dojo/url/filters.py b/dojo/url/filters.py index 19591464fc4..9c8293ba543 100644 --- a/dojo/url/filters.py +++ b/dojo/url/filters.py @@ -47,6 +47,7 @@ class URLFilter(StaticMethodFilters): "url__fragment", "created_at", "updated_at", + "active_findings", ), ) diff --git a/tests/endpoint_extended_test.py b/tests/endpoint_extended_test.py index 37af751117b..63eebc7b1b1 100644 --- a/tests/endpoint_extended_test.py +++ b/tests/endpoint_extended_test.py @@ -1,3 +1,4 @@ +import os import sys import time import unittest @@ -27,6 +28,38 @@ def test_endpoint_host_list(self): driver.get(self.base_url + "endpoint/host") self.assertTrue(self.is_text_present_on_page(text="All Hosts")) + def _active_findings_sort_field(self): + v3 = os.environ.get("DD_V3_FEATURE_LOCATIONS", "false").lower() == "true" + return "active_findings" if v3 else "active_finding_count" + + @on_exception_html_source_logger + def test_endpoint_list_sort_by_active_findings_asc(self): + driver = self.driver + field = self._active_findings_sort_field() + driver.get(self.base_url + f"endpoint?o={field}") + self.assertTrue(self.is_text_present_on_page(text="Endpoint")) + + @on_exception_html_source_logger + def test_endpoint_list_sort_by_active_findings_desc(self): + driver = self.driver + field = self._active_findings_sort_field() + driver.get(self.base_url + f"endpoint?o=-{field}") + self.assertTrue(self.is_text_present_on_page(text="Endpoint")) + + @on_exception_html_source_logger + def test_endpoint_host_list_sort_by_active_findings_asc(self): + driver = self.driver + field = self._active_findings_sort_field() + driver.get(self.base_url + f"endpoint/host?o={field}") + self.assertTrue(self.is_text_present_on_page(text="Hosts")) + + @on_exception_html_source_logger + def test_endpoint_host_list_sort_by_active_findings_desc(self): + driver = self.driver + field = self._active_findings_sort_field() + driver.get(self.base_url + f"endpoint/host?o=-{field}") + self.assertTrue(self.is_text_present_on_page(text="Hosts")) + @on_exception_html_source_logger def test_add_endpoint_meta_data(self): driver = self.driver @@ -92,6 +125,10 @@ def suite(): suite.addTest(EndpointExtendedTest("test_vulnerable_endpoints_page")) suite.addTest(EndpointExtendedTest("test_vulnerable_endpoint_hosts_page")) suite.addTest(EndpointExtendedTest("test_endpoint_host_list")) + suite.addTest(EndpointExtendedTest("test_endpoint_list_sort_by_active_findings_asc")) + suite.addTest(EndpointExtendedTest("test_endpoint_list_sort_by_active_findings_desc")) + suite.addTest(EndpointExtendedTest("test_endpoint_host_list_sort_by_active_findings_asc")) + suite.addTest(EndpointExtendedTest("test_endpoint_host_list_sort_by_active_findings_desc")) suite.addTest(EndpointExtendedTest("test_add_endpoint_meta_data")) suite.addTest(EndpointExtendedTest("test_edit_endpoint_meta_data")) suite.addTest(ProductTest("test_delete_product"))