feat(findings): Handle muted findings in API and UI (#7378)

Co-authored-by: Pablo Lara <larabjj@gmail.com>
This commit is contained in:
Víctor Fernández Poyatos
2025-03-31 12:25:58 +02:00
committed by GitHub
parent e06a33de84
commit 08690068fc
21 changed files with 174 additions and 63 deletions
+1
View File
@@ -11,6 +11,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Support for developing new integrations [(#7167)](https://github.com/prowler-cloud/prowler/pull/7167).
- HTTP Security Headers [(#7289)](https://github.com/prowler-cloud/prowler/pull/7289).
- New endpoint to get the compliance overviews metadata [(#7333)](https://github.com/prowler-cloud/prowler/pull/7333).
- Support for muted findings [(#7378)](https://github.com/prowler-cloud/prowler/pull/7378).
---
+3 -8
View File
@@ -287,6 +287,9 @@ class FindingFilter(FilterSet):
status = ChoiceFilter(choices=StatusChoices.choices)
severity = ChoiceFilter(choices=SeverityChoices)
impact = ChoiceFilter(choices=SeverityChoices)
muted = BooleanFilter(
help_text="If this filter is not provided, muted and non-muted findings will be returned."
)
resources = UUIDInFilter(field_name="resource__id", lookup_expr="in")
@@ -614,12 +617,6 @@ class ScanSummaryFilter(FilterSet):
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
)
region = CharFilter(field_name="region")
muted_findings = BooleanFilter(method="filter_muted_findings")
def filter_muted_findings(self, queryset, name, value):
if not value:
return queryset.exclude(muted__gt=0)
return queryset
class Meta:
model = ScanSummary
@@ -630,8 +627,6 @@ class ScanSummaryFilter(FilterSet):
class ServiceOverviewFilter(ScanSummaryFilter):
muted_findings = None
def is_valid(self):
# Check if at least one of the inserted_at filters is present
inserted_at_filters = [
@@ -0,0 +1,26 @@
# Generated by Django 5.1.5 on 2025-03-25 11:29
from django.db import migrations, models
import api.db_utils
class Migration(migrations.Migration):
dependencies = [
("api", "0014_integrations"),
]
operations = [
migrations.AddField(
model_name="finding",
name="muted",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="finding",
name="status",
field=api.db_utils.StatusEnumField(
choices=[("FAIL", "Fail"), ("PASS", "Pass"), ("MANUAL", "Manual")]
),
),
]
+1 -1
View File
@@ -59,7 +59,6 @@ class StatusChoices(models.TextChoices):
FAIL = "FAIL", _("Fail")
PASS = "PASS", _("Pass")
MANUAL = "MANUAL", _("Manual")
MUTED = "MUTED", _("Muted")
class StateChoices(models.TextChoices):
@@ -656,6 +655,7 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
tags = models.JSONField(default=dict, null=True, blank=True)
check_id = models.CharField(max_length=100, blank=False, null=False)
check_metadata = models.JSONField(default=dict, null=False)
muted = models.BooleanField(default=False, null=False)
# Relationships
scan = models.ForeignKey(to=Scan, related_name="findings", on_delete=models.CASCADE)
+22 -16
View File
@@ -294,6 +294,7 @@ paths:
- inserted_at
- updated_at
- first_seen_at
- muted
- url
- scan
- resources
@@ -402,6 +403,12 @@ paths:
type: string
format: date
description: Maximum date range is 7 days.
- in: query
name: filter[muted]
schema:
type: boolean
description: If this filter is not provided, muted and non-muted findings
will be returned.
- in: query
name: filter[provider]
schema:
@@ -633,13 +640,11 @@ paths:
enum:
- FAIL
- MANUAL
- MUTED
- PASS
description: |-
* `FAIL` - Fail
* `PASS` - Pass
* `MANUAL` - Manual
* `MUTED` - Muted
- in: query
name: filter[status__in]
schema:
@@ -756,6 +761,7 @@ paths:
- inserted_at
- updated_at
- first_seen_at
- muted
- url
- scan
- resources
@@ -909,6 +915,12 @@ paths:
type: string
format: date
description: Maximum date range is 7 days.
- in: query
name: filter[muted]
schema:
type: boolean
description: If this filter is not provided, muted and non-muted findings
will be returned.
- in: query
name: filter[provider]
schema:
@@ -1140,13 +1152,11 @@ paths:
enum:
- FAIL
- MANUAL
- MUTED
- PASS
description: |-
* `FAIL` - Fail
* `PASS` - Pass
* `MANUAL` - Manual
* `MUTED` - Muted
- in: query
name: filter[status__in]
schema:
@@ -1338,6 +1348,12 @@ paths:
type: string
format: date
description: Maximum date range is 7 days.
- in: query
name: filter[muted]
schema:
type: boolean
description: If this filter is not provided, muted and non-muted findings
will be returned.
- in: query
name: filter[provider]
schema:
@@ -1569,13 +1585,11 @@ paths:
enum:
- FAIL
- MANUAL
- MUTED
- PASS
description: |-
* `FAIL` - Fail
* `PASS` - Pass
* `MANUAL` - Manual
* `MUTED` - Muted
- in: query
name: filter[status__in]
schema:
@@ -2019,10 +2033,6 @@ paths:
schema:
type: string
format: date-time
- in: query
name: filter[muted_findings]
schema:
type: boolean
- in: query
name: filter[provider_id]
schema:
@@ -2180,10 +2190,6 @@ paths:
schema:
type: string
format: date-time
- in: query
name: filter[muted_findings]
schema:
type: boolean
- in: query
name: filter[provider_id]
schema:
@@ -6238,13 +6244,11 @@ components:
- FAIL
- PASS
- MANUAL
- MUTED
type: string
description: |-
* `FAIL` - Fail
* `PASS` - Pass
* `MANUAL` - Manual
* `MUTED` - Muted
status_extended:
type: string
nullable: true
@@ -6280,6 +6284,8 @@ components:
format: date-time
readOnly: true
nullable: true
muted:
type: boolean
required:
- uid
- status
+2
View File
@@ -2693,6 +2693,8 @@ class TestFindingViewSet:
# ("resource_tags", "key:value", 2),
# ("resource_tags", "not:exists", 0),
# ("resource_tags", "not:exists,key:value", 2),
("muted", True, 1),
("muted", False, 1),
]
),
)
+1
View File
@@ -1091,6 +1091,7 @@ class FindingSerializer(RLSSerializer):
"inserted_at",
"updated_at",
"first_seen_at",
"muted",
"url",
# Relationships
"scan",
+1
View File
@@ -655,6 +655,7 @@ def findings_fixture(scans_fixture, resources_fixture):
"Description": "test description orange juice",
},
first_seen_at="2024-01-02T00:00:00Z",
muted=True,
)
finding2.add_resources([resource2])
+15 -14
View File
@@ -267,6 +267,7 @@ def perform_prowler_scan(
check_id=finding.check_id,
scan=scan_instance,
first_seen_at=last_first_seen_at,
muted=finding.muted,
)
finding_instance.add_resources([resource_instance])
@@ -402,21 +403,21 @@ def aggregate_findings(tenant_id: str, scan_id: str):
).annotate(
fail=Sum(
Case(
When(status="FAIL", then=1),
When(status="FAIL", muted=False, then=1),
default=0,
output_field=IntegerField(),
)
),
_pass=Sum(
Case(
When(status="PASS", then=1),
When(status="PASS", muted=False, then=1),
default=0,
output_field=IntegerField(),
)
),
muted=Sum(
muted_count=Sum(
Case(
When(status="MUTED", then=1),
When(muted=True, then=1),
default=0,
output_field=IntegerField(),
)
@@ -424,63 +425,63 @@ def aggregate_findings(tenant_id: str, scan_id: str):
total=Count("id"),
new=Sum(
Case(
When(delta="new", then=1),
When(delta="new", muted=False, then=1),
default=0,
output_field=IntegerField(),
)
),
changed=Sum(
Case(
When(delta="changed", then=1),
When(delta="changed", muted=False, then=1),
default=0,
output_field=IntegerField(),
)
),
unchanged=Sum(
Case(
When(delta__isnull=True, then=1),
When(delta__isnull=True, muted=False, then=1),
default=0,
output_field=IntegerField(),
)
),
fail_new=Sum(
Case(
When(delta="new", status="FAIL", then=1),
When(delta="new", status="FAIL", muted=False, then=1),
default=0,
output_field=IntegerField(),
)
),
fail_changed=Sum(
Case(
When(delta="changed", status="FAIL", then=1),
When(delta="changed", status="FAIL", muted=False, then=1),
default=0,
output_field=IntegerField(),
)
),
pass_new=Sum(
Case(
When(delta="new", status="PASS", then=1),
When(delta="new", status="PASS", muted=False, then=1),
default=0,
output_field=IntegerField(),
)
),
pass_changed=Sum(
Case(
When(delta="changed", status="PASS", then=1),
When(delta="changed", status="PASS", muted=False, then=1),
default=0,
output_field=IntegerField(),
)
),
muted_new=Sum(
Case(
When(delta="new", status="MUTED", then=1),
When(delta="new", muted=True, then=1),
default=0,
output_field=IntegerField(),
)
),
muted_changed=Sum(
Case(
When(delta="changed", status="MUTED", then=1),
When(delta="changed", muted=True, then=1),
default=0,
output_field=IntegerField(),
)
@@ -498,7 +499,7 @@ def aggregate_findings(tenant_id: str, scan_id: str):
region=agg["resources__region"],
fail=agg["fail"],
_pass=agg["_pass"],
muted=agg["muted"],
muted=agg["muted_count"],
total=agg["total"],
new=agg["new"],
changed=agg["changed"],
+1
View File
@@ -107,6 +107,7 @@ class TestPerformScan:
finding.service_name = "service_name"
finding.resource_type = "resource_type"
finding.resource_tags = {"tag1": "value1", "tag2": "value2"}
finding.muted = False
finding.raw = {}
# Mock the ProwlerScan instance
+1
View File
@@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Added `one-time scan` feature: Adds support for single scan execution. [(#7188)](https://github.com/prowler-cloud/prowler/pull/7188)
- Accepted invitations can no longer be edited. [(#7198)](https://github.com/prowler-cloud/prowler/pull/7198)
- Added download column in scans table to download reports for completed scans. [(#7353)](https://github.com/prowler-cloud/prowler/pull/7353)
- Show muted icon when a finding is muted. [(#7378)](https://github.com/prowler-cloud/prowler/pull/7378)
#### 🔄 Changed
+1 -1
View File
@@ -42,7 +42,7 @@ export const filterFindings = [
{
key: "status__in",
labelCheckboxGroup: "Status",
values: ["PASS", "FAIL", "MANUAL", "MUTED"],
values: ["PASS", "FAIL", "MANUAL"],
},
{
key: "delta__in",
+1
View File
@@ -0,0 +1 @@
export * from "./muted";
+19
View File
@@ -0,0 +1,19 @@
import { Tooltip } from "@nextui-org/react";
import { MutedIcon } from "../icons";
interface MutedProps {
isMuted: boolean;
}
export const Muted = ({ isMuted }: MutedProps) => {
if (isMuted === false) return null;
return (
<Tooltip content={"This finding is muted"} className="text-xs">
<div className="w-fit rounded-full border border-system-severity-critical/40 p-1">
<MutedIcon className="h-4 w-4 text-system-severity-critical" />
</div>
</Tooltip>
);
};
@@ -14,6 +14,8 @@ import {
} from "@/components/ui/table";
import { FindingProps } from "@/types";
import { Muted } from "../muted";
const getFindingsData = (row: { original: FindingProps }) => {
return row.original;
};
@@ -91,10 +93,18 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
),
cell: ({ row }) => {
const { checktitle } = getFindingsMetadata(row);
const {
attributes: { muted },
} = getFindingsData(row);
return (
<p className="max-w-[450px] whitespace-normal break-words text-small">
{checktitle}
</p>
<div className="relative flex max-w-[410px] flex-row items-center gap-2 3xl:max-w-[660px]">
<p className="mr-7 whitespace-normal break-words text-sm">
{checktitle}
</p>
<span className="absolute -right-2 top-1/2 -translate-y-1/2">
<Muted isMuted={muted} />
</span>
</div>
);
},
},
@@ -124,7 +134,7 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
attributes: { status },
} = getFindingsData(row);
return <StatusFindingBadge size="sm" status={status} />;
return <StatusFindingBadge status={status} />;
},
},
{
@@ -169,7 +179,7 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
const region = getResourceData(row, "region");
return (
<div className="w-[80px]">
<div className="w-[80px] text-xs">
{typeof region === "string" ? region : "Invalid region"}
</div>
);
@@ -180,7 +190,7 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
header: "Service",
cell: ({ row }) => {
const { servicename } = getFindingsMetadata(row);
return <p className="max-w-96 truncate text-small">{servicename}</p>;
return <p className="max-w-96 truncate text-xs">{servicename}</p>;
},
},
{
+15 -10
View File
@@ -12,6 +12,8 @@ import {
import { SeverityBadge } from "@/components/ui/table/severity-badge";
import { FindingProps } from "@/types";
import { Muted } from "../muted";
const renderValue = (value: string | null | undefined) => {
return value && value.trim() !== "" ? value : "-";
};
@@ -66,17 +68,20 @@ export const FindingDetail = ({
{renderValue(attributes.check_metadata.checktitle)}
</h2>
</div>
<div className="flex items-center gap-x-4">
<Muted isMuted={attributes.muted} />
<div
className={`rounded-lg px-3 py-1 text-sm font-semibold ${
attributes.status === "PASS"
? "bg-green-100 text-green-600"
: attributes.status === "MANUAL"
? "bg-gray-100 text-gray-600"
: "bg-red-100 text-red-600"
}`}
>
{renderValue(attributes.status)}
<div
className={`rounded-lg px-3 py-1 text-sm font-semibold ${
attributes.status === "PASS"
? "bg-green-100 text-green-600"
: attributes.status === "MANUAL"
? "bg-gray-100 text-gray-600"
: "bg-red-100 text-system-severity-critical"
}`}
>
{renderValue(attributes.status)}
</div>
</div>
</div>
+28
View File
@@ -1025,3 +1025,31 @@ export const GCPIcon: React.FC<IconSvgProps> = ({
</svg>
);
};
export const MutedIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size || height || 24}
width={size || width || 24}
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<path d="M10.268 21a2 2 0 0 0 3.464 0" />
<path d="M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742" />
<path d="m2 2 20 20" />
<path d="M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05" />
</svg>
);
};
@@ -10,6 +10,8 @@ import { TriggerSheet } from "@/components/ui/sheet";
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
import { FindingProps } from "@/types";
import { Muted } from "../../../findings/muted";
const getFindingsData = (row: { original: FindingProps }) => {
return row.original;
};
@@ -71,10 +73,18 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
header: "Finding",
cell: ({ row }) => {
const { checktitle } = getFindingsMetadata(row);
const {
attributes: { muted },
} = getFindingsData(row);
return (
<p className="max-w-[450px] whitespace-normal break-words text-small">
{checktitle}
</p>
<div className="relative flex max-w-[410px] flex-row items-center gap-2 3xl:max-w-[660px]">
<p className="mr-7 whitespace-normal break-words text-sm">
{checktitle}
</p>
<span className="absolute -right-2 top-1/2 -translate-y-1/2">
<Muted isMuted={muted} />
</span>
</div>
);
},
},
+1 -1
View File
@@ -45,7 +45,7 @@ export const SeverityBadge = ({ severity }: { severity: Severity }) => {
color={color}
endContent={getSeverityIcon(severity)}
>
{severity}
<span className="text-xs font-light tracking-wide">{severity}</span>
</Chip>
);
};
@@ -25,13 +25,15 @@ export const StatusFindingBadge = ({
return (
<Chip
className="gap-1 border-none px-2 py-1 capitalize text-default-600"
className="border-none px-2 py-0"
size={size}
variant="flat"
color={color}
{...props}
>
{status}
<span className="text-xs font-light tracking-wide text-default-600">
{status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}
</span>
</Chip>
);
};
+2 -1
View File
@@ -597,10 +597,11 @@ export interface FindingProps {
attributes: {
uid: string;
delta: "new" | "changed" | null;
status: "PASS" | "FAIL" | "MANUAL" | "MUTED";
status: "PASS" | "FAIL" | "MANUAL";
status_extended: string;
severity: "informational" | "low" | "medium" | "high" | "critical";
check_id: string;
muted: boolean;
check_metadata: {
risk: string;
notes: string;