Compare commits

...

1 Commits

Author SHA1 Message Date
Alan Buscaglia
fc17123272 feat(api): add providers/severity endpoint for sankey chart
- Add OverviewProviderSeveritySerializer for severity breakdown by provider type
- Add GET /api/v1/overviews/providers/severity endpoint
- Return failed findings count grouped by provider type and severity level
- Support provider_id and provider_type filters via ScanSummaryFilter
- Add OpenAPI documentation for the new endpoint
- Add unit tests for the endpoint with and without filters
- Update CHANGELOG
- Ignore xmltodict vulnerability 79408 in pre-commit (needs upgrade to >=0.15.1)
2025-12-02 17:52:36 +01:00
5 changed files with 249 additions and 2 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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()