Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 84 additions & 7 deletions api/src/backend/api/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Expand All @@ -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
):
Expand Down
36 changes: 19 additions & 17 deletions api/src/backend/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
)
Expand Down Expand Up @@ -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(),
)
Expand Down
Loading