diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index a1f73e07003..213ee215de8 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.25.3] (Prowler v5.24.3) + +### 🐞 Fixed + +- Finding groups aggregated `status` now treats muted findings as resolved: a group is `FAIL` only while at least one non-muted FAIL remains, otherwise it is `PASS` (including fully-muted groups). The `filter[status]` filter and the `sort=status` ordering share the same semantics, keeping `status` consistent with `fail_count` and the orthogonal `muted` flag [(#10825)](https://github.com/prowler-cloud/prowler/pull/10825) + +--- + ## [1.25.2] (Prowler v5.24.2) ### 🔄 Changed diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 3d2107a03e4..b2e5271bda0 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -15446,15 +15446,15 @@ def test_finding_groups_status_pass_when_no_fail( # iam_password_policy has only PASS findings assert data[0]["attributes"]["status"] == "PASS" - def test_finding_groups_fully_muted_group_reflects_underlying_status( + def test_finding_groups_fully_muted_group_is_pass( self, authenticated_client, finding_groups_fixture ): - """A fully-muted group still surfaces its underlying status (no MUTED). + """A fully-muted group reports status=PASS and muted=True. - rds_encryption has 2 muted FAIL findings, so the group must report - status=FAIL (the orthogonal `muted` boolean signals it isn't actionable). - The status×muted breakdown lets clients answer 'how many failing - findings are muted in this group'. + rds_encryption has 2 muted FAIL findings. Muted findings are treated + as resolved/accepted, so the group is no longer actionable and its + status must be PASS. The `muted` flag is True because every finding + in the group is muted. """ response = authenticated_client.get( reverse("finding-group-list"), @@ -15464,7 +15464,7 @@ def test_finding_groups_fully_muted_group_reflects_underlying_status( data = response.json()["data"] assert len(data) == 1 attrs = data[0]["attributes"] - assert attrs["status"] == "FAIL" + assert attrs["status"] == "PASS" assert attrs["muted"] is True assert attrs["fail_count"] == 0 assert attrs["fail_muted_count"] == 2 @@ -15479,6 +15479,83 @@ def test_finding_groups_fully_muted_group_reflects_underlying_status( == attrs["muted_count"] ) + def test_finding_groups_status_ignores_muted_failures( + self, + authenticated_client, + tenants_fixture, + scans_fixture, + resources_fixture, + ): + """Muted FAIL findings must not drive the aggregated status. + + When a group mixes one non-muted PASS with one muted FAIL, the + actionable outcome is PASS: there are no unmuted failures left. The + aggregated `status` must reflect that (not FAIL), while `muted` + stays False because the group still has a non-muted finding. + """ + tenant = tenants_fixture[0] + scan1, *_ = scans_fixture + resource1, *_ = resources_fixture + + pass_finding = Finding.objects.create( + tenant_id=tenant.id, + uid="fg_mixed_muted_pass", + scan=scan1, + delta=None, + status=Status.PASS, + severity=Severity.low, + impact=Severity.low, + check_id="mixed_muted_check", + check_metadata={ + "CheckId": "mixed_muted_check", + "checktitle": "Mixed muted check", + "Description": "Fixture for muted status aggregation.", + }, + first_seen_at="2024-01-11T00:00:00Z", + muted=False, + ) + pass_finding.add_resources([resource1]) + + fail_muted_finding = Finding.objects.create( + tenant_id=tenant.id, + uid="fg_mixed_muted_fail", + scan=scan1, + delta=None, + status=Status.FAIL, + severity=Severity.high, + impact=Severity.high, + check_id="mixed_muted_check", + check_metadata={ + "CheckId": "mixed_muted_check", + "checktitle": "Mixed muted check", + "Description": "Fixture for muted status aggregation.", + }, + first_seen_at="2024-01-12T00:00:00Z", + muted=True, + ) + fail_muted_finding.add_resources([resource1]) + + # filter[region] forces finding-level aggregation so we exercise the + # raw-findings path without touching the daily summary fixture. + response = authenticated_client.get( + reverse("finding-group-list"), + { + "filter[inserted_at]": TODAY, + "filter[check_id]": "mixed_muted_check", + "filter[region]": "us-east-1", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + attrs = data[0]["attributes"] + assert attrs["status"] == "PASS" + assert attrs["muted"] is False + assert attrs["pass_count"] == 1 + assert attrs["fail_count"] == 0 + assert attrs["fail_muted_count"] == 1 + assert attrs["muted_count"] == 1 + def test_finding_groups_status_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 5a2c790655b..2f74d0283d7 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -7281,14 +7281,18 @@ def _post_process_aggregation(self, aggregated_data): # finding-level aggregation path. row.pop("nonmuted_count", None) - # Compute aggregated status from non-muted counts first, then - # fall back to muted counts so fully-muted groups still reflect - # the underlying check outcome. - total_fail = row.get("fail_count", 0) + row.get("fail_muted_count", 0) - total_pass = row.get("pass_count", 0) + row.get("pass_muted_count", 0) - if total_fail > 0: + # Muted findings are treated as resolved/accepted, so they do not + # contribute to a failing status. A group is FAIL only when there + # is at least one non-muted FAIL; otherwise any pass (muted or + # not) or any muted fail makes the group PASS. Only groups whose + # findings are exclusively MANUAL fall through to MANUAL. + if row.get("fail_count", 0) > 0: row["status"] = "FAIL" - elif total_pass > 0: + elif ( + row.get("pass_count", 0) > 0 + or row.get("pass_muted_count", 0) > 0 + or row.get("fail_muted_count", 0) > 0 + ): row["status"] = "PASS" else: row["status"] = "MANUAL" @@ -7388,12 +7392,11 @@ def _apply_aggregated_computed_filters(self, queryset, computed_params: QueryDic if computed_params.get("status") or computed_params.getlist("status__in"): queryset = queryset.annotate( - total_fail=F("fail_count") + F("fail_muted_count"), - total_pass=F("pass_count") + F("pass_muted_count"), - ).annotate( aggregated_status=Case( - When(total_fail__gt=0, then=Value("FAIL")), - When(total_pass__gt=0, then=Value("PASS")), + When(fail_count__gt=0, then=Value("FAIL")), + When(pass_count__gt=0, then=Value("PASS")), + When(pass_muted_count__gt=0, then=Value("PASS")), + When(fail_muted_count__gt=0, then=Value("PASS")), default=Value("MANUAL"), output_field=CharField(), ) @@ -7798,12 +7801,11 @@ def _sorted_paginated_response( if ordering: if any(field.lstrip("-") == "status_order" for field in ordering): aggregated_queryset = aggregated_queryset.annotate( - total_fail_for_sort=F("fail_count") + F("fail_muted_count"), - total_pass_for_sort=F("pass_count") + F("pass_muted_count"), - ).annotate( status_order=Case( - When(total_fail_for_sort__gt=0, then=Value(3)), - When(total_pass_for_sort__gt=0, then=Value(2)), + When(fail_count__gt=0, then=Value(3)), + When(pass_count__gt=0, then=Value(2)), + When(pass_muted_count__gt=0, then=Value(2)), + When(fail_muted_count__gt=0, then=Value(2)), default=Value(1), output_field=IntegerField(), )