feat(api): add new endpoint for retrieving findings data by region with associated filters and response schema (#9273)

This commit is contained in:
Adrián Jesús Peña Rodríguez
2025-11-21 11:23:31 +01:00
committed by GitHub
parent 6e7266eacf
commit de5aba6d4d
5 changed files with 327 additions and 71 deletions

View File

@@ -21,6 +21,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Support for MongoDB Atlas provider [(#9167)](https://github.com/prowler-cloud/prowler/pull/9167)
- Support Prowler ThreatScore for the K8S provider [(#9235)](https://github.com/prowler-cloud/prowler/pull/9235)
- Enhanced compliance overview endpoint with provider filtering and latest scan aggregation [(#9244)](https://github.com/prowler-cloud/prowler/pull/9244)
- New endpoint `GET /api/v1/overview/regions` to retrieve aggregated findings data by region [(#9273)](https://github.com/prowler-cloud/prowler/pull/9273)
### Changed
- Optimized database write queries for scan related tasks [(#9190)](https://github.com/prowler-cloud/prowler/pull/9190)

View File

@@ -5051,6 +5051,175 @@ paths:
schema:
$ref: '#/components/schemas/OverviewProviderCountResponse'
description: ''
/api/v1/overviews/regions:
get:
operationId: overviews_regions_retrieve
description: Retrieve an aggregated summary of findings grouped by region. The
response includes the total, passed, failed, and muted findings for each region
based on the latest completed scans per provider. Standard overview filters
(inserted_at, provider filters, region filters, etc.) are supported.
summary: Get findings data by region
parameters:
- in: query
name: fields[regions-overview]
schema:
type: array
items:
type: string
enum:
- id
- total
- fail
- muted
- pass
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: query
name: filter[inserted_at]
schema:
type: string
format: date
- in: query
name: filter[inserted_at__date]
schema:
type: string
format: date
- in: query
name: filter[inserted_at__gte]
schema:
type: string
format: date-time
- in: query
name: filter[inserted_at__lte]
schema:
type: string
format: date-time
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
- in: query
name: filter[provider_id__in]
schema:
type: array
items:
type: string
format: uuid
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_type]
schema:
type: string
x-spec-enum-id: eca8c51e6bd28935
enum:
- aws
- azure
- gcp
- github
- iac
- kubernetes
- m365
- mongodbatlas
- oraclecloud
description: |-
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
* `m365` - M365
* `github` - GitHub
* `mongodbatlas` - MongoDB Atlas
* `iac` - IaC
* `oraclecloud` - Oracle Cloud Infrastructure
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
x-spec-enum-id: eca8c51e6bd28935
enum:
- aws
- azure
- gcp
- github
- iac
- kubernetes
- m365
- mongodbatlas
- oraclecloud
description: |-
Multiple values may be separated by commas.
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
* `m365` - M365
* `github` - GitHub
* `mongodbatlas` - MongoDB Atlas
* `iac` - IaC
* `oraclecloud` - Oracle Cloud Infrastructure
explode: false
style: form
- in: query
name: filter[region]
schema:
type: string
- in: query
name: filter[region__icontains]
schema:
type: string
- in: query
name: filter[region__in]
schema:
type: array
items:
type: string
description: Multiple values may be separated by commas.
explode: false
style: form
- name: filter[search]
required: false
in: query
description: A search term.
schema:
type: string
- name: sort
required: false
in: query
description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
schema:
type: array
items:
type: string
enum:
- id
- -id
- total
- -total
- fail
- -fail
- muted
- -muted
- pass
- -pass
explode: false
tags:
- Overview
security:
- JWT or API Key: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/OverviewRegionResponse'
description: ''
/api/v1/overviews/services:
get:
operationId: overviews_services_retrieve
@@ -5293,7 +5462,7 @@ paths:
description: Retrieve a specific snapshot by ID. If not provided, returns
latest snapshots.
tags:
- Overviews
- Overview
security:
- JWT or API Key: []
responses:
@@ -13369,6 +13538,47 @@ components:
$ref: '#/components/schemas/OverviewProvider'
required:
- data
OverviewRegion:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
type: string
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
enum:
- regions-overview
id: {}
attributes:
type: object
properties:
id:
type: string
total:
type: integer
fail:
type: integer
muted:
type: integer
pass:
type: integer
required:
- id
- total
- fail
- muted
- pass
OverviewRegionResponse:
type: object
properties:
data:
$ref: '#/components/schemas/OverviewRegion'
required:
- data
OverviewService:
type: object
required:

View File

@@ -6784,6 +6784,32 @@ class TestOverviewViewSet:
# Should return services from latest scans
assert len(response.json()["data"]) == 2
def test_overview_regions_list(self, authenticated_client, scan_summaries_fixture):
response = authenticated_client.get(
reverse("overview-regions"), {"filter[inserted_at]": TODAY}
)
assert response.status_code == status.HTTP_200_OK
# Only two different regions in the fixture (region1, region2)
assert len(response.json()["data"]) == 2
data = response.json()["data"]
regions = {item["id"]: item["attributes"] for item in data}
assert "aws:region1" in regions
assert "aws:region2" in regions
# region1 has 5 findings (2 pass, 0 fail, 3 muted)
assert regions["aws:region1"]["total"] == 5
assert regions["aws:region1"]["pass"] == 2
assert regions["aws:region1"]["fail"] == 0
assert regions["aws:region1"]["muted"] == 3
# region2 has 4 findings (0 pass, 1 fail, 3 muted)
assert regions["aws:region2"]["total"] == 4
assert regions["aws:region2"]["pass"] == 0
assert regions["aws:region2"]["fail"] == 1
assert regions["aws:region2"]["muted"] == 3
def test_overview_services_list(self, authenticated_client, scan_summaries_fixture):
response = authenticated_client.get(
reverse("overview-services"), {"filter[inserted_at]": TODAY}

View File

@@ -2229,6 +2229,30 @@ class OverviewServiceSerializer(serializers.Serializer):
return {"version": "v1"}
class OverviewRegionSerializer(serializers.Serializer):
id = serializers.SerializerMethodField()
provider_type = serializers.CharField()
region = serializers.CharField()
total = serializers.IntegerField()
_pass = serializers.IntegerField()
fail = serializers.IntegerField()
muted = serializers.IntegerField()
class JSONAPIMeta:
resource_name = "regions-overview"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["pass"] = self.fields.pop("_pass")
def get_id(self, obj):
"""Generate unique ID from provider_type and region."""
return f"{obj['provider_type']}:{obj['region']}"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
# Schedules

View File

@@ -204,6 +204,7 @@ from api.v1.serializers import (
OverviewFindingSerializer,
OverviewProviderCountSerializer,
OverviewProviderSerializer,
OverviewRegionSerializer,
OverviewServiceSerializer,
OverviewSeveritySerializer,
ProcessorCreateSerializer,
@@ -2137,7 +2138,7 @@ class ScanViewSet(BaseRLSViewSet):
"scan_id": str(scan.id),
"provider_id": str(scan.provider_id),
# Disabled for now
# checks_to_execute=scan.scanner_args.get("checks_to_execute"),
# checks_to_execute=scan.scanner_args.get("checks_to_execute")
},
)
@@ -4064,6 +4065,15 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
),
filters=True,
),
regions=extend_schema(
summary="Get findings data by region",
description=(
"Retrieve an aggregated summary of findings grouped by region. The response includes the total, passed, "
"failed, and muted findings for each region based on the latest completed scans per provider. "
"Standard overview filters (inserted_at, provider filters, region filters, etc.) are supported."
),
filters=True,
),
)
@method_decorator(CACHE_DECORATOR, name="list")
class OverviewViewSet(BaseRLSViewSet):
@@ -4094,6 +4104,8 @@ class OverviewViewSet(BaseRLSViewSet):
return OverviewSeveritySerializer
elif self.action == "services":
return OverviewServiceSerializer
elif self.action == "regions":
return OverviewRegionSerializer
elif self.action == "threatscore":
return ThreatScoreSnapshotSerializer
return super().get_serializer_class()
@@ -4101,12 +4113,10 @@ class OverviewViewSet(BaseRLSViewSet):
def get_filterset_class(self):
if self.action == "providers":
return None
elif self.action == "findings":
elif self.action in ["findings", "services", "regions"]:
return ScanSummaryFilter
elif self.action == "findings_severity":
return ScanSummarySeverityFilter
elif self.action == "services":
return ScanSummaryFilter
return None
@extend_schema(exclude=True)
@@ -4117,6 +4127,35 @@ class OverviewViewSet(BaseRLSViewSet):
def retrieve(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
def _get_latest_scans_queryset(self):
"""
Get filtered queryset for the latest completed scans per provider.
Returns:
Filtered ScanSummary queryset with latest scan IDs applied.
"""
tenant_id = self.request.tenant_id
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
provider_filter = (
{"provider__in": self.allowed_providers}
if hasattr(self, "allowed_providers")
else {}
)
latest_scan_ids = (
Scan.all_objects.filter(
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
return filtered_queryset.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
@action(detail=False, methods=["get"], url_name="providers")
def providers(self, request):
tenant_id = self.request.tenant_id
@@ -4209,26 +4248,7 @@ class OverviewViewSet(BaseRLSViewSet):
@action(detail=False, methods=["get"], url_name="findings")
def findings(self, request):
tenant_id = self.request.tenant_id
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
provider_filter = (
{"provider__in": self.allowed_providers}
if hasattr(self, "allowed_providers")
else {}
)
latest_scan_ids = (
Scan.all_objects.filter(
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
filtered_queryset = filtered_queryset.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
filtered_queryset = self._get_latest_scans_queryset()
aggregated_totals = filtered_queryset.aggregate(
_pass=Sum("_pass") or 0,
@@ -4255,31 +4275,7 @@ class OverviewViewSet(BaseRLSViewSet):
@action(detail=False, methods=["get"], url_name="findings_severity")
def findings_severity(self, request):
tenant_id = self.request.tenant_id
# Load only required fields
queryset = self.get_queryset().only(
"tenant_id", "scan_id", "severity", "fail", "_pass", "muted"
)
filtered_queryset = self.filter_queryset(queryset)
provider_filter = (
{"provider__in": self.allowed_providers}
if hasattr(self, "allowed_providers")
else {}
)
latest_scan_ids = (
Scan.all_objects.filter(
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
filtered_queryset = filtered_queryset.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
filtered_queryset = self._get_latest_scans_queryset()
# The filter will have added a status_count annotation if any status filter was used
if "status_count" in filtered_queryset.query.annotations:
@@ -4304,26 +4300,7 @@ class OverviewViewSet(BaseRLSViewSet):
@action(detail=False, methods=["get"], url_name="services")
def services(self, request):
tenant_id = self.request.tenant_id
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
provider_filter = (
{"provider__in": self.allowed_providers}
if hasattr(self, "allowed_providers")
else {}
)
latest_scan_ids = (
Scan.all_objects.filter(
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
filtered_queryset = filtered_queryset.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
filtered_queryset = self._get_latest_scans_queryset()
services_data = (
filtered_queryset.values("service")
@@ -4338,13 +4315,31 @@ class OverviewViewSet(BaseRLSViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_name="regions")
def regions(self, request):
filtered_queryset = self._get_latest_scans_queryset()
regions_data = (
filtered_queryset.annotate(provider_type=F("scan__provider__provider"))
.values("provider_type", "region")
.annotate(_pass=Sum("_pass"))
.annotate(fail=Sum("fail"))
.annotate(muted=Sum("muted"))
.annotate(total=Sum("total"))
.order_by("provider_type", "region")
)
serializer = self.get_serializer(regions_data, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@extend_schema(
summary="Get ThreatScore snapshots",
description=(
"Retrieve ThreatScore metrics. By default, returns the latest snapshot for each provider. "
"Use snapshot_id to retrieve a specific historical snapshot."
),
tags=["Overviews"],
tags=["Overview"],
parameters=[
OpenApiParameter(
name="snapshot_id",