diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 0694cba39b..f099949d07 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file. ### Added - Integration with JIRA, enabling sending findings to a JIRA project [(#8622)](https://github.com/prowler-cloud/prowler/pull/8622), [(#8637)](https://github.com/prowler-cloud/prowler/pull/8637) +- `GET /overviews/findings_severity` now supports `filter[status]` and `filter[status__in]` to aggregate by specific statuses (`FAIL`, `PASS`)[(#8186)](https://github.com/prowler-cloud/prowler/pull/8186) --- diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 7418d2daf9..57967e31b9 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -2,7 +2,7 @@ from datetime import date, datetime, timedelta, timezone from dateutil.parser import parse from django.conf import settings -from django.db.models import Q +from django.db.models import F, Q from django_filters.rest_framework import ( BaseInFilter, BooleanFilter, @@ -28,6 +28,7 @@ from api.models import ( Integration, Invitation, Membership, + OverviewStatusChoices, PermissionChoices, Processor, Provider, @@ -750,6 +751,72 @@ class ScanSummaryFilter(FilterSet): } +class ScanSummarySeverityFilter(ScanSummaryFilter): + """Filter for findings_severity ScanSummary endpoint - includes status filters""" + + # Custom status filters - only for severity grouping endpoint + status = ChoiceFilter(method="filter_status", choices=OverviewStatusChoices.choices) + status__in = CharInFilter(method="filter_status_in", lookup_expr="in") + + def filter_status(self, queryset, name, value): + # Validate the status value + if value not in [choice[0] for choice in OverviewStatusChoices.choices]: + raise ValidationError(f"Invalid status value: {value}") + + # Apply the filter by annotating the queryset with the status field + if value == OverviewStatusChoices.FAIL: + return queryset.annotate(status_count=F("fail")) + elif value == OverviewStatusChoices.PASS: + return queryset.annotate(status_count=F("_pass")) + else: + return queryset.annotate(status_count=F("total")) + + def filter_status_in(self, queryset, name, value): + # Validate the status values + valid_statuses = [choice[0] for choice in OverviewStatusChoices.choices] + for status_val in value: + if status_val not in valid_statuses: + raise ValidationError(f"Invalid status value: {status_val}") + + # If all statuses or no valid statuses, use total + if ( + set(value) + >= { + OverviewStatusChoices.FAIL, + OverviewStatusChoices.PASS, + } + or not value + ): + return queryset.annotate(status_count=F("total")) + + # Build the sum expression based on status values + sum_expression = None + for status in value: + if status == OverviewStatusChoices.FAIL: + field_expr = F("fail") + elif status == OverviewStatusChoices.PASS: + field_expr = F("_pass") + else: + continue + + if sum_expression is None: + sum_expression = field_expr + else: + sum_expression = sum_expression + field_expr + + if sum_expression is None: + return queryset.annotate(status_count=F("total")) + + return queryset.annotate(status_count=sum_expression) + + class Meta: + model = ScanSummary + fields = { + "inserted_at": ["date", "gte", "lte"], + "region": ["exact", "icontains", "in"], + } + + class ServiceOverviewFilter(ScanSummaryFilter): def is_valid(self): # Check if at least one of the inserted_at filters is present diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 6c20032dbc..9766afc8d1 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -74,6 +74,15 @@ class StatusChoices(models.TextChoices): MANUAL = "MANUAL", _("Manual") +class OverviewStatusChoices(models.TextChoices): + """ + Status filters allowed in overview/severity endpoints. + """ + + FAIL = "FAIL", _("Fail") + PASS = "PASS", _("Pass") + + class StateChoices(models.TextChoices): AVAILABLE = "available", _("Available") SCHEDULED = "scheduled", _("Scheduled") diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 71a6bf2e7b..9d8880f488 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -3506,6 +3506,25 @@ paths: description: A search term. schema: type: string + - in: query + name: filter[status] + schema: + type: string + enum: + - FAIL + - PASS + description: |- + * `FAIL` - Fail + * `PASS` - Pass + - in: query + name: filter[status__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - name: sort required: false in: query diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 003b2b1110..27f3e324a9 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -89,6 +89,7 @@ from api.filters import ( RoleFilter, ScanFilter, ScanSummaryFilter, + ScanSummarySeverityFilter, ServiceOverviewFilter, TaskFilter, TenantFilter, @@ -3546,8 +3547,10 @@ class OverviewViewSet(BaseRLSViewSet): def get_filterset_class(self): if self.action == "providers": return None - elif self.action in ["findings", "findings_severity"]: + elif self.action == "findings": return ScanSummaryFilter + elif self.action == "findings_severity": + return ScanSummarySeverityFilter elif self.action == "services": return ServiceOverviewFilter return None @@ -3669,7 +3672,12 @@ class OverviewViewSet(BaseRLSViewSet): @action(detail=False, methods=["get"], url_name="findings_severity") def findings_severity(self, request): tenant_id = self.request.tenant_id - queryset = self.get_queryset() + + # Load only required fields + queryset = self.get_queryset().only( + "tenant_id", "scan_id", "severity", "fail", "_pass", "total" + ) + filtered_queryset = self.filter_queryset(queryset) provider_filter = ( {"provider__in": self.allowed_providers} @@ -3689,16 +3697,22 @@ class OverviewViewSet(BaseRLSViewSet): tenant_id=tenant_id, scan_id__in=latest_scan_ids ) + # The filter will have added a status_count annotation if any status filter was used + if "status_count" in filtered_queryset.query.annotations: + sum_expression = Sum("status_count") + else: + sum_expression = Sum("total") + severity_counts = ( filtered_queryset.values("severity") - .annotate(count=Sum("total")) + .annotate(count=sum_expression) .order_by("severity") ) severity_data = {sev[0]: 0 for sev in SeverityChoices} - - for item in severity_counts: - severity_data[item["severity"]] = item["count"] + severity_data.update( + {item["severity"]: item["count"] for item in severity_counts} + ) serializer = self.get_serializer(severity_data) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8892b47a35..0ce1d95c76 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to the **Prowler UI** are documented in this file. - Jira integration[(#8640)](https://github.com/prowler-cloud/prowler/pull/8640) +### 🔄 Changed + +- Overview chart "Findings by Severity" now shows only failing findings (defaults to `status=FAIL`) and chart links open the Findings page pre-filtered to fails per severity [(#8186)](https://github.com/prowler-cloud/prowler/pull/8186) + ## [1.11.1] (Prowler v5.11.1) ### 🐞 Added diff --git a/ui/actions/overview/overview.ts b/ui/actions/overview/overview.ts index 052d0f0322..e90246ea80 100644 --- a/ui/actions/overview/overview.ts +++ b/ui/actions/overview/overview.ts @@ -56,7 +56,13 @@ export const getFindingsByStatus = async ({ // Handle multiple filters, but exclude muted filter as overviews endpoint doesn't support it Object.entries(filters).forEach(([key, value]) => { - if (key !== "filter[search]" && key !== "filter[muted]") { + // The overviews/findings endpoint does not support status or muted filters + // (allowed filters include date, region, provider fields). Exclude unsupported ones. + if ( + key !== "filter[search]" && + key !== "filter[muted]" && + key !== "filter[status]" + ) { url.searchParams.append(key, String(value)); } }); @@ -89,9 +95,14 @@ export const getFindingsBySeverity = async ({ if (query) url.searchParams.append("filter[search]", query); if (sort) url.searchParams.append("sort", sort); - // Handle multiple filters, but exclude muted filter as overviews endpoint doesn't support it + // Handle multiple filters, but exclude unsupported filters + // The overviews/findings_severity endpoint does not support status or muted filters Object.entries(filters).forEach(([key, value]) => { - if (key !== "filter[search]" && key !== "filter[muted]") { + if ( + key !== "filter[search]" && + key !== "filter[muted]" && + key !== "filter[status]" + ) { url.searchParams.append(key, String(value)); } }); diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 9e949983b7..9dbe2477d9 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -107,6 +107,10 @@ const SSRFindingsBySeverity = async ({ }: { searchParams: SearchParamsProps | undefined | null; }) => { + const defaultFilters = { + "filter[status]": "FAIL", + } as const; + const filters = searchParams ? Object.fromEntries( Object.entries(searchParams).filter(([key]) => @@ -115,11 +119,17 @@ const SSRFindingsBySeverity = async ({ ) : {}; - const findingsBySeverity = await getFindingsBySeverity({ filters }); + const combinedFilters = { ...defaultFilters, ...filters }; + + const findingsBySeverity = await getFindingsBySeverity({ + filters: combinedFilters, + }); return ( <> -