Skip to content

Commit 584455a

Browse files
feat(api): add finding groups summaries (#9961)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
1 parent 5830cb6 commit 584455a

File tree

17 files changed

+3305
-45
lines changed

17 files changed

+3305
-45
lines changed

api/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file.
66

77
### 🚀 Added
88

9+
- Finding group summaries and resources endpoints for hierarchical findings views [(#9961)](https://github.com/prowler-cloud/prowler/pull/9961)
910
- OpenStack provider support [(#10003)](https://github.com/prowler-cloud/prowler/pull/10003)
1011
- PDF report for the CSA CCM compliance framework [(#10088)](https://github.com/prowler-cloud/prowler/pull/10088)
1112

api/src/backend/api/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
SEVERITY_ORDER = {
2+
"critical": 5,
3+
"high": 4,
4+
"medium": 3,
5+
"low": 2,
6+
"informational": 1,
7+
}

api/src/backend/api/filters.py

Lines changed: 279 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@
2323
StatusEnumField,
2424
)
2525
from api.models import (
26+
AttackPathsScan,
2627
AttackSurfaceOverview,
2728
ComplianceRequirementOverview,
2829
DailySeveritySummary,
2930
Finding,
31+
FindingGroupDailySummary,
3032
Integration,
3133
Invitation,
32-
AttackPathsScan,
3334
LighthouseProviderConfiguration,
3435
LighthouseProviderModels,
3536
Membership,
@@ -181,7 +182,7 @@ class CommonFindingFilters(FilterSet):
181182
help_text="If this filter is not provided, muted and non-muted findings will be returned."
182183
)
183184

184-
resources = UUIDInFilter(field_name="resource__id", lookup_expr="in")
185+
resources = UUIDInFilter(field_name="resources__id", lookup_expr="in")
185186

186187
region = CharFilter(method="filter_resource_region")
187188
region__in = CharInFilter(field_name="resource_regions", lookup_expr="overlap")
@@ -469,9 +470,10 @@ class ResourceFilter(ProviderRelationshipFilterSet):
469470
class Meta:
470471
model = Resource
471472
fields = {
473+
"id": ["exact", "in"],
472474
"provider": ["exact", "in"],
473-
"uid": ["exact", "icontains"],
474-
"name": ["exact", "icontains"],
475+
"uid": ["exact", "icontains", "in"],
476+
"name": ["exact", "icontains", "in"],
475477
"region": ["exact", "icontains", "in"],
476478
"service": ["exact", "icontains", "in"],
477479
"type": ["exact", "icontains", "in"],
@@ -554,9 +556,10 @@ class LatestResourceFilter(ProviderRelationshipFilterSet):
554556
class Meta:
555557
model = Resource
556558
fields = {
559+
"id": ["exact", "in"],
557560
"provider": ["exact", "in"],
558-
"uid": ["exact", "icontains"],
559-
"name": ["exact", "icontains"],
561+
"uid": ["exact", "icontains", "in"],
562+
"name": ["exact", "icontains", "in"],
560563
"region": ["exact", "icontains", "in"],
561564
"service": ["exact", "icontains", "in"],
562565
"type": ["exact", "icontains", "in"],
@@ -647,16 +650,15 @@ def filter_queryset(self, queryset):
647650
]
648651
)
649652

650-
gte_date = (
651-
datetime.strptime(self.data.get("inserted_at__gte"), "%Y-%m-%d").date()
652-
if self.data.get("inserted_at__gte")
653-
else datetime.now(timezone.utc).date()
654-
)
655-
lte_date = (
656-
datetime.strptime(self.data.get("inserted_at__lte"), "%Y-%m-%d").date()
657-
if self.data.get("inserted_at__lte")
658-
else datetime.now(timezone.utc).date()
659-
)
653+
cleaned = self.form.cleaned_data
654+
exact_date = cleaned.get("inserted_at") or cleaned.get("inserted_at__date")
655+
gte_date = cleaned.get("inserted_at__gte") or exact_date
656+
lte_date = cleaned.get("inserted_at__lte") or exact_date
657+
658+
if gte_date is None:
659+
gte_date = datetime.now(timezone.utc).date()
660+
if lte_date is None:
661+
lte_date = datetime.now(timezone.utc).date()
660662

661663
if abs(lte_date - gte_date) > timedelta(
662664
days=settings.FINDINGS_MAX_DAYS_IN_RANGE
@@ -779,6 +781,267 @@ class Meta:
779781
}
780782

781783

784+
class FindingGroupFilter(CommonFindingFilters):
785+
"""
786+
Filter for FindingGroup aggregations.
787+
788+
Requires at least one date filter for performance (partition pruning).
789+
Inherits all provider, status, severity, region, service filters from CommonFindingFilters.
790+
"""
791+
792+
inserted_at = DateFilter(method="filter_inserted_at", lookup_expr="date")
793+
inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date")
794+
inserted_at__gte = DateFilter(
795+
method="filter_inserted_at_gte",
796+
help_text=f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
797+
)
798+
inserted_at__lte = DateFilter(
799+
method="filter_inserted_at_lte",
800+
help_text=f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
801+
)
802+
803+
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
804+
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
805+
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
806+
807+
class Meta:
808+
model = Finding
809+
fields = {
810+
"check_id": ["exact", "in", "icontains"],
811+
}
812+
813+
def filter_queryset(self, queryset):
814+
"""Validate that at least one date filter is provided."""
815+
if not (
816+
self.data.get("inserted_at")
817+
or self.data.get("inserted_at__date")
818+
or self.data.get("inserted_at__gte")
819+
or self.data.get("inserted_at__lte")
820+
):
821+
raise ValidationError(
822+
[
823+
{
824+
"detail": "At least one date filter is required: filter[inserted_at], filter[inserted_at.gte], "
825+
"or filter[inserted_at.lte].",
826+
"status": 400,
827+
"source": {"pointer": "/data/attributes/inserted_at"},
828+
"code": "required",
829+
}
830+
]
831+
)
832+
833+
# Validate date range doesn't exceed maximum
834+
cleaned = self.form.cleaned_data
835+
exact_date = cleaned.get("inserted_at") or cleaned.get("inserted_at__date")
836+
gte_date = cleaned.get("inserted_at__gte") or exact_date
837+
lte_date = cleaned.get("inserted_at__lte") or exact_date
838+
839+
if gte_date is None:
840+
gte_date = datetime.now(timezone.utc).date()
841+
if lte_date is None:
842+
lte_date = datetime.now(timezone.utc).date()
843+
844+
if abs(lte_date - gte_date) > timedelta(
845+
days=settings.FINDINGS_MAX_DAYS_IN_RANGE
846+
):
847+
raise ValidationError(
848+
[
849+
{
850+
"detail": f"The date range cannot exceed {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
851+
"status": 400,
852+
"source": {"pointer": "/data/attributes/inserted_at"},
853+
"code": "invalid",
854+
}
855+
]
856+
)
857+
858+
return super().filter_queryset(queryset)
859+
860+
def filter_inserted_at(self, queryset, name, value):
861+
"""Filter by exact date using UUIDv7 partition-aware filtering."""
862+
datetime_value = self._maybe_date_to_datetime(value)
863+
start = uuid7_start(datetime_to_uuid7(datetime_value))
864+
end = uuid7_start(datetime_to_uuid7(datetime_value + timedelta(days=1)))
865+
return queryset.filter(id__gte=start, id__lt=end)
866+
867+
def filter_inserted_at_gte(self, queryset, name, value):
868+
"""Filter by start date using UUIDv7 partition-aware filtering."""
869+
datetime_value = self._maybe_date_to_datetime(value)
870+
start = uuid7_start(datetime_to_uuid7(datetime_value))
871+
return queryset.filter(id__gte=start)
872+
873+
def filter_inserted_at_lte(self, queryset, name, value):
874+
"""Filter by end date using UUIDv7 partition-aware filtering."""
875+
datetime_value = self._maybe_date_to_datetime(value)
876+
end = uuid7_start(datetime_to_uuid7(datetime_value + timedelta(days=1)))
877+
return queryset.filter(id__lt=end)
878+
879+
@staticmethod
880+
def _maybe_date_to_datetime(value):
881+
"""Convert date to datetime if needed."""
882+
dt = value
883+
if isinstance(value, date):
884+
dt = datetime.combine(value, datetime.min.time(), tzinfo=timezone.utc)
885+
return dt
886+
887+
888+
class LatestFindingGroupFilter(CommonFindingFilters):
889+
"""
890+
Filter for FindingGroup resources in /latest endpoint.
891+
892+
Same as FindingGroupFilter but without date validation.
893+
"""
894+
895+
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
896+
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
897+
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
898+
899+
class Meta:
900+
model = Finding
901+
fields = {
902+
"check_id": ["exact", "in", "icontains"],
903+
}
904+
905+
906+
class FindingGroupSummaryFilter(FilterSet):
907+
"""
908+
Filter for FindingGroupDailySummary queries.
909+
910+
Filters the pre-aggregated summary table by date range, check_id, and provider.
911+
Requires at least one date filter for performance.
912+
"""
913+
914+
inserted_at = DateFilter(method="filter_inserted_at", lookup_expr="date")
915+
inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date")
916+
inserted_at__gte = DateFilter(
917+
method="filter_inserted_at_gte",
918+
help_text=f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
919+
)
920+
inserted_at__lte = DateFilter(
921+
method="filter_inserted_at_lte",
922+
help_text=f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
923+
)
924+
925+
# Check ID filters
926+
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
927+
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
928+
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
929+
930+
# Provider filters
931+
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
932+
provider_id__in = UUIDInFilter(field_name="provider_id", lookup_expr="in")
933+
provider_type = ChoiceFilter(
934+
field_name="provider__provider", choices=Provider.ProviderChoices.choices
935+
)
936+
provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in")
937+
938+
class Meta:
939+
model = FindingGroupDailySummary
940+
fields = {
941+
"check_id": ["exact", "in", "icontains"],
942+
"inserted_at": ["date", "gte", "lte"],
943+
"provider_id": ["exact", "in"],
944+
}
945+
946+
def filter_queryset(self, queryset):
947+
if not (
948+
self.data.get("inserted_at")
949+
or self.data.get("inserted_at__date")
950+
or self.data.get("inserted_at__gte")
951+
or self.data.get("inserted_at__lte")
952+
):
953+
raise ValidationError(
954+
[
955+
{
956+
"detail": "At least one date filter is required: filter[inserted_at], filter[inserted_at.gte], "
957+
"or filter[inserted_at.lte].",
958+
"status": 400,
959+
"source": {"pointer": "/data/attributes/inserted_at"},
960+
"code": "required",
961+
}
962+
]
963+
)
964+
965+
cleaned = self.form.cleaned_data
966+
exact_date = cleaned.get("inserted_at") or cleaned.get("inserted_at__date")
967+
gte_date = cleaned.get("inserted_at__gte") or exact_date
968+
lte_date = cleaned.get("inserted_at__lte") or exact_date
969+
970+
if gte_date is None:
971+
gte_date = datetime.now(timezone.utc).date()
972+
if lte_date is None:
973+
lte_date = datetime.now(timezone.utc).date()
974+
975+
if abs(lte_date - gte_date) > timedelta(
976+
days=settings.FINDINGS_MAX_DAYS_IN_RANGE
977+
):
978+
raise ValidationError(
979+
[
980+
{
981+
"detail": f"The date range cannot exceed {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
982+
"status": 400,
983+
"source": {"pointer": "/data/attributes/inserted_at"},
984+
"code": "invalid",
985+
}
986+
]
987+
)
988+
989+
return super().filter_queryset(queryset)
990+
991+
def filter_inserted_at(self, queryset, name, value):
992+
"""Filter by exact inserted_at date."""
993+
datetime_value = self._maybe_date_to_datetime(value)
994+
start = datetime_value
995+
end = datetime_value + timedelta(days=1)
996+
return queryset.filter(inserted_at__gte=start, inserted_at__lt=end)
997+
998+
def filter_inserted_at_gte(self, queryset, name, value):
999+
"""Filter by inserted_at >= value (date boundary)."""
1000+
datetime_value = self._maybe_date_to_datetime(value)
1001+
return queryset.filter(inserted_at__gte=datetime_value)
1002+
1003+
def filter_inserted_at_lte(self, queryset, name, value):
1004+
"""Filter by inserted_at <= value (inclusive date boundary)."""
1005+
datetime_value = self._maybe_date_to_datetime(value)
1006+
return queryset.filter(inserted_at__lt=datetime_value + timedelta(days=1))
1007+
1008+
@staticmethod
1009+
def _maybe_date_to_datetime(value):
1010+
dt = value
1011+
if isinstance(value, date):
1012+
dt = datetime.combine(value, datetime.min.time(), tzinfo=timezone.utc)
1013+
return dt
1014+
1015+
1016+
class LatestFindingGroupSummaryFilter(FilterSet):
1017+
"""
1018+
Filter for FindingGroupDailySummary /latest endpoint.
1019+
1020+
Same as FindingGroupSummaryFilter but without date validation.
1021+
Used when the endpoint automatically determines the date.
1022+
"""
1023+
1024+
# Check ID filters
1025+
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
1026+
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
1027+
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
1028+
1029+
# Provider filters
1030+
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
1031+
provider_id__in = UUIDInFilter(field_name="provider_id", lookup_expr="in")
1032+
provider_type = ChoiceFilter(
1033+
field_name="provider__provider", choices=Provider.ProviderChoices.choices
1034+
)
1035+
provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in")
1036+
1037+
class Meta:
1038+
model = FindingGroupDailySummary
1039+
fields = {
1040+
"check_id": ["exact", "in", "icontains"],
1041+
"provider_id": ["exact", "in"],
1042+
}
1043+
1044+
7821045
class ProviderSecretFilter(FilterSet):
7831046
inserted_at = DateFilter(
7841047
field_name="inserted_at",

0 commit comments

Comments
 (0)