From 6942c18199c0ae7b83865ea5338f5151a11c6e4b Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sat, 7 Mar 2026 23:05:07 +0100 Subject: [PATCH 1/2] feat: sort endpoints by active findings count Add ability to sort the endpoint list (and host list) by the number of active findings, for both the UI and API, including the V3 Locations code path. --- dojo/api_v2/serializers.py | 1 + dojo/api_v2/views.py | 9 ++++++- dojo/endpoint/views.py | 10 ++++++-- dojo/filters.py | 8 ++++++ dojo/location/api/endpoint_compat.py | 16 +++++++++++- dojo/templates/dojo/endpoints.html | 4 +-- dojo/templates/dojo/url/list.html | 2 +- dojo/url/filters.py | 1 + tests/endpoint_extended_test.py | 37 ++++++++++++++++++++++++++++ 9 files changed, 81 insertions(+), 7 deletions(-) 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..238a718f123 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -12,6 +12,7 @@ from django.contrib.auth.models import Permission from django.core.exceptions import ValidationError from django.db import IntegrityError +from django.db.models import Count, Q from django.db.models.query import QuerySet as DjangoQuerySet from django.http import FileResponse, HttpResponse from django.shortcuts import get_object_or_404 @@ -345,7 +346,13 @@ class EndPointViewSet( ) def get_queryset(self): - return get_authorized_endpoints(Permissions.Location_View).distinct() + return get_authorized_endpoints(Permissions.Location_View).annotate( + active_finding_count=Count( + "findings", + filter=Q(findings__active=True), + distinct=True, + ), + ).distinct() @extend_schema( request=serializers.ReportGenerateOptionSerializer, diff --git a/dojo/endpoint/views.py b/dojo/endpoint/views.py index caa48f02757..207a6b5f351 100644 --- a/dojo/endpoint/views.py +++ b/dojo/endpoint/views.py @@ -8,7 +8,7 @@ from django.contrib.admin.utils import NestedObjects from django.core.exceptions import PermissionDenied from django.db import DEFAULT_DB_ALIAS -from django.db.models import OuterRef, QuerySet, Value +from django.db.models import Count, OuterRef, Q, QuerySet, Value from django.db.models.functions import Coalesce from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render @@ -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() + endpoints = endpoints.prefetch_related("product", "product__tags", "tags").annotate( + active_finding_count=Count( + "findings", + filter=Q(findings__active=True), + distinct=True, + ), + ).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..d4057de168c 100644 --- a/dojo/location/api/endpoint_compat.py +++ b/dojo/location/api/endpoint_compat.py @@ -6,6 +6,7 @@ """ import datetime +from django.db.models import Count, Q from django_filters import BooleanFilter, CharFilter, NumberFilter from django_filters.rest_framework import DjangoFilterBackend, FilterSet from drf_spectacular.utils import extend_schema @@ -101,7 +102,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 +123,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 +147,15 @@ 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() + return get_authorized_location_product_reference(Permissions.Location_View).filter( + location__location_type=URL.LOCATION_TYPE, + ).annotate( + active_finding_count=Count( + "location__findings", + filter=Q(location__findings__status=FindingLocationStatus.Active), + distinct=True, + ), + ).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")) From 939b5b5ababcf631166d6857831dded9d393be68 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 8 Mar 2026 10:39:37 +0100 Subject: [PATCH 2/2] fix: use subquery for active_finding_count annotation to preserve queryset ordering --- dojo/api_v2/views.py | 14 ++++++++------ dojo/endpoint/views.py | 12 ++++++------ dojo/location/api/endpoint_compat.py | 17 +++++++++++------ 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 238a718f123..8290d932eef 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -12,7 +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 Count, Q +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 @@ -167,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, @@ -346,12 +348,12 @@ class EndPointViewSet( ) def get_queryset(self): + 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=Count( - "findings", - filter=Q(findings__active=True), - distinct=True, - ), + active_finding_count=Coalesce(active_finding_subquery, Value(0)), ).distinct() @extend_schema( diff --git a/dojo/endpoint/views.py b/dojo/endpoint/views.py index 207a6b5f351..addd0762c4c 100644 --- a/dojo/endpoint/views.py +++ b/dojo/endpoint/views.py @@ -8,7 +8,7 @@ from django.contrib.admin.utils import NestedObjects from django.core.exceptions import PermissionDenied from django.db import DEFAULT_DB_ALIAS -from django.db.models import Count, OuterRef, Q, QuerySet, Value +from django.db.models import OuterRef, QuerySet, Value from django.db.models.functions import Coalesce from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render @@ -59,12 +59,12 @@ def process_endpoints_view(request, *, host_view=False, vulnerable=False): else: endpoints = Endpoint.objects.all() + 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=Count( - "findings", - filter=Q(findings__active=True), - distinct=True, - ), + 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) diff --git a/dojo/location/api/endpoint_compat.py b/dojo/location/api/endpoint_compat.py index d4057de168c..964da4c0d4d 100644 --- a/dojo/location/api/endpoint_compat.py +++ b/dojo/location/api/endpoint_compat.py @@ -6,7 +6,8 @@ """ import datetime -from django.db.models import Count, Q +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 @@ -33,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 ########## @@ -147,14 +149,17 @@ class V3EndpointCompatibleViewSet(PrefetchListMixin, PrefetchRetrieveMixin, view def get_queryset(self): """Get authorized URLs using Endpoint authorization logic.""" + 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=Count( - "location__findings", - filter=Q(location__findings__status=FindingLocationStatus.Active), - distinct=True, - ), + active_finding_count=Coalesce(active_finding_subquery, Value(0)), ).distinct() @extend_schema(