mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
fix(api): treat muted findings as resolved in finding-groups status (#10826)
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -15446,15 +15446,15 @@ class TestFindingGroupViewSet:
|
||||
# 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 @@ class TestFindingGroupViewSet:
|
||||
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 @@ class TestFindingGroupViewSet:
|
||||
== 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
|
||||
):
|
||||
|
||||
@@ -7281,14 +7281,18 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
# 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 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
|
||||
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 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user