diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea9954f082..4b031fc9a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -117,7 +117,8 @@ repos: name: safety description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities" # TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X - entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745' + # TODO: xmltodict 0.14.2 has vulnerability 79408, upgrade to >=0.15.1 to fix + entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79408' language: system - id: vulture diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 868c4855cb..72e1df2ebc 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 - New endpoint to retrieve an overview of the attack surfaces [(#9309)](https://github.com/prowler-cloud/prowler/pull/9309) +- New endpoint `GET /api/v1/overviews/providers/severity` to retrieve severity breakdown of failed findings grouped by provider type ### Changed - Restore the compliance overview endpoint's mandatory filters [(#9330)](https://github.com/prowler-cloud/prowler/pull/9330) diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index f2b32f23ee..3c464a3b25 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -6857,6 +6857,192 @@ class TestOverviewViewSet: assert combined_attributes["medium"] == 4 assert combined_attributes["critical"] == 3 + def test_overview_providers_severity( + self, authenticated_client, tenants_fixture, providers_fixture + ): + """Test providers/severity endpoint returns fail counts grouped by provider type and severity.""" + tenant = tenants_fixture[0] + provider_aws1, provider_aws2, provider_gcp, *_ = providers_fixture + + # Create scans for different providers + scan_aws1 = Scan.objects.create( + name="aws-scan-one", + provider=provider_aws1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan_aws2 = Scan.objects.create( + name="aws-scan-two", + provider=provider_aws2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan_gcp = Scan.objects.create( + name="gcp-scan", + provider=provider_gcp, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + # AWS provider 1: high=5 fail, medium=3 fail + ScanSummary.objects.create( + tenant=tenant, + scan=scan_aws1, + check_id="aws-check-one", + service="s3", + severity="high", + region="us-east-1", + _pass=2, + fail=5, + muted=1, + total=8, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=scan_aws1, + check_id="aws-check-two", + service="ec2", + severity="medium", + region="us-east-1", + _pass=1, + fail=3, + muted=0, + total=4, + ) + + # AWS provider 2: high=2 fail, critical=1 fail + ScanSummary.objects.create( + tenant=tenant, + scan=scan_aws2, + check_id="aws-check-three", + service="iam", + severity="high", + region="us-west-2", + _pass=0, + fail=2, + muted=0, + total=2, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=scan_aws2, + check_id="aws-check-four", + service="iam", + severity="critical", + region="us-west-2", + _pass=0, + fail=1, + muted=0, + total=1, + ) + + # GCP provider: medium=4 fail + ScanSummary.objects.create( + tenant=tenant, + scan=scan_gcp, + check_id="gcp-check-one", + service="compute", + severity="medium", + region="us-central1", + _pass=2, + fail=4, + muted=0, + total=6, + ) + + # Test without filters - should return all providers aggregated + response = authenticated_client.get(reverse("overview-providers-severity")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + + # Should have 2 provider types: aws and gcp + assert len(data) == 2 + + # Find AWS and GCP in response + aws_data = next((d for d in data if d["id"] == "aws"), None) + gcp_data = next((d for d in data if d["id"] == "gcp"), None) + + assert aws_data is not None + assert gcp_data is not None + + # AWS: critical=1, high=5+2=7, medium=3 + assert aws_data["attributes"]["critical"] == 1 + assert aws_data["attributes"]["high"] == 7 + assert aws_data["attributes"]["medium"] == 3 + assert aws_data["attributes"]["low"] == 0 + assert aws_data["attributes"]["informational"] == 0 + + # GCP: medium=4 + assert gcp_data["attributes"]["critical"] == 0 + assert gcp_data["attributes"]["high"] == 0 + assert gcp_data["attributes"]["medium"] == 4 + assert gcp_data["attributes"]["low"] == 0 + assert gcp_data["attributes"]["informational"] == 0 + + def test_overview_providers_severity_with_provider_filter( + self, authenticated_client, tenants_fixture, providers_fixture + ): + """Test providers/severity endpoint with provider_id filter.""" + tenant = tenants_fixture[0] + provider_aws1, provider_aws2, provider_gcp, *_ = providers_fixture + + scan_aws1 = Scan.objects.create( + name="aws-scan-one", + provider=provider_aws1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan_gcp = Scan.objects.create( + name="gcp-scan", + provider=provider_gcp, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + ScanSummary.objects.create( + tenant=tenant, + scan=scan_aws1, + check_id="aws-check", + service="s3", + severity="high", + region="us-east-1", + _pass=0, + fail=10, + muted=0, + total=10, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=scan_gcp, + check_id="gcp-check", + service="compute", + severity="medium", + region="us-central1", + _pass=0, + fail=5, + muted=0, + total=5, + ) + + # Filter by AWS provider only + response = authenticated_client.get( + reverse("overview-providers-severity"), + {"filter[provider_id__in]": str(provider_aws1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + + # Should only return AWS + assert len(data) == 1 + assert data[0]["id"] == "aws" + assert data[0]["attributes"]["high"] == 10 + assert data[0]["attributes"]["medium"] == 0 + def test_overview_attack_surface_no_data(self, authenticated_client): response = authenticated_client.get(reverse("overview-attack-surface")) assert response.status_code == status.HTTP_200_OK diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index c35d2f91db..16f8ef584c 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -2204,6 +2204,20 @@ class OverviewSeveritySerializer(BaseSerializerV1): resource_name = "findings-severity-overview" +class OverviewProviderSeveritySerializer(BaseSerializerV1): + """Serializer for severity breakdown of failed findings grouped by provider type.""" + + id = serializers.CharField(source="provider_type") + critical = serializers.IntegerField() + high = serializers.IntegerField() + medium = serializers.IntegerField() + low = serializers.IntegerField() + informational = serializers.IntegerField() + + class JSONAPIMeta: + resource_name = "provider-severity-overview" + + class OverviewServiceSerializer(BaseSerializerV1): id = serializers.CharField(source="service") total = serializers.IntegerField() diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 5cd8dab7dc..247d33b0fc 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -206,6 +206,7 @@ from api.v1.serializers import ( OverviewFindingSerializer, OverviewProviderCountSerializer, OverviewProviderSerializer, + OverviewProviderSeveritySerializer, OverviewRegionSerializer, OverviewServiceSerializer, OverviewSeveritySerializer, @@ -3856,6 +3857,16 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): "This endpoint counts every provider in the tenant, including those without completed scans." ), ), + providers_severity=extend_schema( + summary="Get severity breakdown by provider type", + description=( + "Retrieve the count of failed findings grouped by provider type and severity level. " + "The response includes the number of critical, high, medium, low, and informational " + "failed findings for each provider type (aws, azure, gcp, etc.). Only failed findings " + "from the latest completed scans are considered. Supports provider_id and provider_type filters." + ), + filters=True, + ), findings=extend_schema( summary="Get aggregated findings data", description=( @@ -3948,6 +3959,8 @@ class OverviewViewSet(BaseRLSViewSet): return OverviewProviderSerializer elif self.action == "providers_count": return OverviewProviderCountSerializer + elif self.action == "providers_severity": + return OverviewProviderSeveritySerializer elif self.action == "findings": return OverviewFindingSerializer elif self.action == "findings_severity": @@ -3965,7 +3978,7 @@ class OverviewViewSet(BaseRLSViewSet): def get_filterset_class(self): if self.action == "providers": return None - elif self.action in ["findings", "services", "regions"]: + elif self.action in ["findings", "services", "regions", "providers_severity"]: return ScanSummaryFilter elif self.action == "findings_severity": return ScanSummarySeverityFilter @@ -4212,6 +4225,38 @@ class OverviewViewSet(BaseRLSViewSet): serializer = self.get_serializer(severity_data) return Response(serializer.data, status=status.HTTP_200_OK) + @action( + detail=False, + methods=["get"], + url_path="providers/severity", + url_name="providers-severity", + ) + def providers_severity(self, request): + """ + Get severity breakdown of failed findings grouped by provider type. + """ + filtered_queryset = self._get_latest_scans_queryset() + + severity_by_provider = ( + filtered_queryset.annotate(provider_type=F("scan__provider__provider")) + .values("provider_type", "severity") + .annotate(count=Sum("fail")) + .order_by("provider_type", "severity") + ) + + provider_data = {} + for row in severity_by_provider: + provider_type = row["provider_type"] + if provider_type not in provider_data: + provider_data[provider_type] = { + "provider_type": provider_type, + **{sev[0].lower(): 0 for sev in SeverityChoices}, + } + provider_data[provider_type][row["severity"].lower()] = row["count"] or 0 + + serializer = self.get_serializer(list(provider_data.values()), many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + @action(detail=False, methods=["get"], url_name="services") def services(self, request): filtered_queryset = self._get_latest_scans_queryset()