Skip to content

Commit 591f5a8

Browse files
authored
fix(api): align finding-group latest aggregation (#10419)
1 parent 93b8a7c commit 591f5a8

File tree

3 files changed

+94
-15
lines changed

3 files changed

+94
-15
lines changed

api/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to the **Prowler API** are documented in this file.
44

55
## [1.23.0] (Prowler UNRELEASED)
66

7+
### 🐞 Fixed
8+
9+
- Finding groups latest endpoint now aggregates the latest snapshot per provider before check-level totals, keeping impacted resources aligned across providers [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
10+
- Mute rule creation now triggers finding-group summary re-aggregation after historical muting, keeping stats in sync after mute operations [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
11+
712
### 🔐 Security
813

914
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)

api/src/backend/api/tests/test_views.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
ComplianceRequirementOverview,
4646
DailySeveritySummary,
4747
Finding,
48+
FindingGroupDailySummary,
4849
Integration,
4950
Invitation,
5051
LighthouseProviderConfiguration,
@@ -14689,10 +14690,16 @@ def test_mute_rule_ordering(
1468914690
assert len(data) == 2
1469014691
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
1469114692

14692-
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
14693+
@patch("api.v1.views.chain")
14694+
@patch("api.v1.views.aggregate_finding_group_summaries_task.si")
14695+
@patch("api.v1.views.mute_historical_findings_task.si")
14696+
@patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
1469314697
def test_mute_rules_create_valid(
1469414698
self,
14695-
mock_task,
14699+
_mock_on_commit,
14700+
mock_mute_signature,
14701+
mock_aggregate_signature,
14702+
mock_chain,
1469614703
authenticated_client,
1469714704
findings_fixture,
1469814705
create_test_user,
@@ -14730,8 +14737,14 @@ def test_mute_rules_create_valid(
1473014737
assert finding.muted_at is not None
1473114738
assert finding.muted_reason == "Security exception approved"
1473214739

14733-
# Verify background task was called
14734-
mock_task.assert_called_once()
14740+
# Verify background task chain was called
14741+
mock_mute_signature.assert_called_once()
14742+
mock_aggregate_signature.assert_called_once()
14743+
mock_chain.assert_called_once_with(
14744+
mock_mute_signature.return_value,
14745+
mock_aggregate_signature.return_value,
14746+
)
14747+
mock_chain.return_value.apply_async.assert_called_once()
1473514748

1473614749
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
1473714750
def test_mute_rules_create_converts_finding_ids_to_uids(
@@ -15840,6 +15853,48 @@ def test_finding_groups_latest_provider_id_filter(
1584015853
assert len(data) == 1
1584115854
assert data[0]["id"] == "cloudtrail_enabled"
1584215855

15856+
def test_finding_groups_latest_aggregates_latest_per_provider(
15857+
self, authenticated_client, providers_fixture
15858+
):
15859+
"""Test /latest aggregates latest summary from each provider for the same check."""
15860+
provider1 = providers_fixture[0]
15861+
provider2 = providers_fixture[1]
15862+
15863+
check_id = "cross_provider_latest_resources_total"
15864+
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
15865+
15866+
FindingGroupDailySummary.objects.create(
15867+
tenant_id=provider1.tenant_id,
15868+
provider=provider1,
15869+
check_id=check_id,
15870+
inserted_at=now - timedelta(days=1),
15871+
resources_total=20,
15872+
resources_fail=20,
15873+
fail_count=20,
15874+
)
15875+
FindingGroupDailySummary.objects.create(
15876+
tenant_id=provider2.tenant_id,
15877+
provider=provider2,
15878+
check_id=check_id,
15879+
inserted_at=now,
15880+
resources_total=7,
15881+
resources_fail=7,
15882+
fail_count=7,
15883+
)
15884+
15885+
response = authenticated_client.get(
15886+
reverse("finding-group-latest"),
15887+
{"filter[check_id]": check_id},
15888+
)
15889+
15890+
assert response.status_code == status.HTTP_200_OK
15891+
data = response.json()["data"]
15892+
assert len(data) == 1
15893+
attrs = data[0]["attributes"]
15894+
assert attrs["resources_total"] == 27
15895+
assert attrs["resources_fail"] == 27
15896+
assert attrs["fail_count"] == 27
15897+
1584315898
def test_finding_groups_latest_provider_type_filter(
1584415899
self, authenticated_client, finding_groups_fixture
1584515900
):

api/src/backend/api/v1/views.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
1717
from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView
1818
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
19+
from celery import chain
1920
from celery.result import AsyncResult
2021
from config.custom_logging import BackendLogger
2122
from config.env import env
@@ -81,6 +82,7 @@
8182
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
8283
from tasks.jobs.export import get_s3_client
8384
from tasks.tasks import (
85+
aggregate_finding_group_summaries_task,
8486
backfill_compliance_summaries_task,
8587
backfill_scan_resource_summaries_task,
8688
check_integration_connection_task,
@@ -6725,10 +6727,25 @@ def create(self, request, *args, **kwargs):
67256727
)
67266728

67276729
# Launch background task for historical muting
6728-
with transaction.atomic():
6729-
mute_historical_findings_task.apply_async(
6730-
kwargs={"tenant_id": tenant_id, "mute_rule_id": str(mute_rule.id)}
6731-
)
6730+
latest_scan_id = (
6731+
Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
6732+
.order_by("-completed_at", "-inserted_at")
6733+
.values_list("id", flat=True)
6734+
.first()
6735+
)
6736+
6737+
transaction.on_commit(
6738+
lambda: chain(
6739+
mute_historical_findings_task.si(
6740+
tenant_id=tenant_id,
6741+
mute_rule_id=str(mute_rule.id),
6742+
),
6743+
aggregate_finding_group_summaries_task.si(
6744+
tenant_id=tenant_id,
6745+
scan_id=str(latest_scan_id),
6746+
),
6747+
).apply_async()
6748+
)
67326749

67336750
# Return the created mute rule
67346751
serializer = self.get_serializer(mute_rule)
@@ -7210,13 +7227,15 @@ def latest(self, request):
72107227
raise ValidationError(filterset.errors)
72117228
filtered_queryset = filterset.qs
72127229

7213-
# Keep only rows from the latest inserted_at date per check_id
7214-
latest_per_check = filtered_queryset.annotate(
7215-
latest_inserted_at=Window(
7216-
expression=Max("inserted_at"),
7217-
partition_by=[F("check_id")],
7218-
)
7219-
).filter(inserted_at=F("latest_inserted_at"))
7230+
# Keep only the latest row per (check_id, provider), then aggregate by check_id.
7231+
latest_per_check_ids = (
7232+
filtered_queryset.order_by("check_id", "provider_id", "-inserted_at")
7233+
.distinct("check_id", "provider_id")
7234+
.values("id")
7235+
)
7236+
latest_per_check = filtered_queryset.filter(
7237+
id__in=Subquery(latest_per_check_ids)
7238+
)
72207239

72217240
# Re-aggregate daily summaries
72227241
aggregated_queryset = self._aggregate_daily_summaries(latest_per_check)

0 commit comments

Comments
 (0)