diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 17acf2155a..e09b601197 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.23.0] (Prowler UNRELEASED) +### 🐞 Fixed + +- 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) +- 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) + ### 🔐 Security - 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) diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 7dd14bf420..b2e82d2ea1 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -45,6 +45,7 @@ from api.models import ( ComplianceRequirementOverview, DailySeveritySummary, Finding, + FindingGroupDailySummary, Integration, Invitation, LighthouseProviderConfiguration, @@ -14689,10 +14690,16 @@ class TestMuteRuleViewSet: assert len(data) == 2 assert data[0]["id"] == str(mute_rules_fixture[first_index].id) - @patch("tasks.tasks.mute_historical_findings_task.apply_async") + @patch("api.v1.views.chain") + @patch("api.v1.views.aggregate_finding_group_summaries_task.si") + @patch("api.v1.views.mute_historical_findings_task.si") + @patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn()) def test_mute_rules_create_valid( self, - mock_task, + _mock_on_commit, + mock_mute_signature, + mock_aggregate_signature, + mock_chain, authenticated_client, findings_fixture, create_test_user, @@ -14730,8 +14737,14 @@ class TestMuteRuleViewSet: assert finding.muted_at is not None assert finding.muted_reason == "Security exception approved" - # Verify background task was called - mock_task.assert_called_once() + # Verify background task chain was called + mock_mute_signature.assert_called_once() + mock_aggregate_signature.assert_called_once() + mock_chain.assert_called_once_with( + mock_mute_signature.return_value, + mock_aggregate_signature.return_value, + ) + mock_chain.return_value.apply_async.assert_called_once() @patch("tasks.tasks.mute_historical_findings_task.apply_async") def test_mute_rules_create_converts_finding_ids_to_uids( @@ -15840,6 +15853,48 @@ class TestFindingGroupViewSet: assert len(data) == 1 assert data[0]["id"] == "cloudtrail_enabled" + def test_finding_groups_latest_aggregates_latest_per_provider( + self, authenticated_client, providers_fixture + ): + """Test /latest aggregates latest summary from each provider for the same check.""" + provider1 = providers_fixture[0] + provider2 = providers_fixture[1] + + check_id = "cross_provider_latest_resources_total" + now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + + FindingGroupDailySummary.objects.create( + tenant_id=provider1.tenant_id, + provider=provider1, + check_id=check_id, + inserted_at=now - timedelta(days=1), + resources_total=20, + resources_fail=20, + fail_count=20, + ) + FindingGroupDailySummary.objects.create( + tenant_id=provider2.tenant_id, + provider=provider2, + check_id=check_id, + inserted_at=now, + resources_total=7, + resources_fail=7, + fail_count=7, + ) + + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[check_id]": check_id}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + attrs = data[0]["attributes"] + assert attrs["resources_total"] == 27 + assert attrs["resources_fail"] == 27 + assert attrs["fail_count"] == 27 + def test_finding_groups_latest_provider_type_filter( self, authenticated_client, finding_groups_fixture ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 7c2de4a41c..85c3062965 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -16,6 +16,7 @@ from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError +from celery import chain from celery.result import AsyncResult from config.custom_logging import BackendLogger from config.env import env @@ -81,6 +82,7 @@ from tasks.beat import schedule_provider_scan from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils from tasks.jobs.export import get_s3_client from tasks.tasks import ( + aggregate_finding_group_summaries_task, backfill_compliance_summaries_task, backfill_scan_resource_summaries_task, check_integration_connection_task, @@ -6725,10 +6727,25 @@ class MuteRuleViewSet(BaseRLSViewSet): ) # Launch background task for historical muting - with transaction.atomic(): - mute_historical_findings_task.apply_async( - kwargs={"tenant_id": tenant_id, "mute_rule_id": str(mute_rule.id)} - ) + latest_scan_id = ( + Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED) + .order_by("-completed_at", "-inserted_at") + .values_list("id", flat=True) + .first() + ) + + transaction.on_commit( + lambda: chain( + mute_historical_findings_task.si( + tenant_id=tenant_id, + mute_rule_id=str(mute_rule.id), + ), + aggregate_finding_group_summaries_task.si( + tenant_id=tenant_id, + scan_id=str(latest_scan_id), + ), + ).apply_async() + ) # Return the created mute rule serializer = self.get_serializer(mute_rule) @@ -7210,13 +7227,15 @@ class FindingGroupViewSet(BaseRLSViewSet): raise ValidationError(filterset.errors) filtered_queryset = filterset.qs - # Keep only rows from the latest inserted_at date per check_id - latest_per_check = filtered_queryset.annotate( - latest_inserted_at=Window( - expression=Max("inserted_at"), - partition_by=[F("check_id")], - ) - ).filter(inserted_at=F("latest_inserted_at")) + # Keep only the latest row per (check_id, provider), then aggregate by check_id. + latest_per_check_ids = ( + filtered_queryset.order_by("check_id", "provider_id", "-inserted_at") + .distinct("check_id", "provider_id") + .values("id") + ) + latest_per_check = filtered_queryset.filter( + id__in=Subquery(latest_per_check_ids) + ) # Re-aggregate daily summaries aggregated_queryset = self._aggregate_daily_summaries(latest_per_check)