Merge branch 'master' of github.com:prowler-cloud/prowler into changelog-v5.23.0

This commit is contained in:
Pepe Fagoaga
2026-04-10 12:59:20 +02:00
43 changed files with 1794 additions and 699 deletions

View File

@@ -105,6 +105,102 @@ If a query requires no parameters, the form displays a message confirming that t
width="700"
/>
## Writing Custom openCypher Queries
In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalogue.
To write a custom query, select **Custom openCypher query** from the query dropdown. A code editor with syntax highlighting and line numbers appears, ready to receive the query.
### Constraints and Safety Limits
Custom queries are sandboxed to keep the graph database safe and responsive:
- **Read-only:** Only read operations are allowed. Statements that mutate the graph (`CREATE`, `MERGE`, `SET`, `DELETE`, `REMOVE`, `DROP`, `LOAD CSV`, `CALL { ... }` writes, etc.) are rejected before execution.
- **Length limit:** Each query is capped at **10,000 characters**.
- **Scoped to the selected scan:** Results are automatically scoped to the provider and scan selected on the left panel. There is no need to filter by tenant or scan identifier in the query body.
### Example Queries
The following examples are read-only and can be pasted directly into the editor. Each one demonstrates a different graph traversal pattern.
**Internet-exposed EC2 instances with their security group rules:**
```cypher
MATCH (i:EC2Instance)--(sg:EC2SecurityGroup)--(rule:IpPermissionInbound)
WHERE i.exposed_internet = true
RETURN i.instanceid AS instance, sg.name AS security_group,
rule.fromport AS from_port, rule.toport AS to_port
LIMIT 25
```
**EC2 instances that can assume IAM roles:**
```cypher
MATCH (i:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(r:AWSRole)
WHERE i.exposed_internet = true
RETURN i.instanceid AS instance, r.name AS role_name, r.arn AS role_arn
LIMIT 25
```
**IAM principals with wildcard Allow statements:**
```cypher
MATCH (principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND ANY(action IN stmt.action WHERE action = '*')
RETURN principal.arn AS principal, policy.arn AS policy,
stmt.action AS actions, stmt.resource AS resources
LIMIT 25
```
**Critical findings on internet-exposed resources:**
```cypher
MATCH (i:EC2Instance)-[:HAS_FINDING]->(f:ProwlerFinding)
WHERE i.exposed_internet = true AND f.status = 'FAIL'
AND f.severity IN ['critical', 'high']
RETURN i.instanceid AS instance, f.check_id AS check,
f.severity AS severity, f.status AS status
LIMIT 50
```
**Roles trusting an AWS service (building block for PassRole escalation):**
```cypher
MATCH (r:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(p:AWSPrincipal)
WHERE p.arn ENDS WITH '.amazonaws.com'
RETURN r.name AS role_name, r.arn AS role_arn, p.arn AS trusted_service
LIMIT 25
```
### Tips for Writing Queries
- Start small with `LIMIT` to inspect the shape of the data before broadening the pattern.
- Use `RETURN` projections (`RETURN n.name, n.region`) instead of returning whole nodes to keep responses compact.
- Combine resource nodes with `ProwlerFinding` nodes via `HAS_FINDING` to correlate misconfigurations with the affected resources.
- When a query times out or returns no rows, simplify the pattern step by step until the first variant runs successfully, then add constraints back.
### Cartography Schema Reference
Attack Paths graphs are populated by [Cartography](https://github.com/cartography-cncf/cartography), an open-source graph ingestion framework. The node labels, relationship types, and properties available in custom queries follow the upstream Cartography schema for the corresponding provider.
For the complete catalogue of node labels and relationships available in custom queries, refer to the official Cartography schema documentation:
- **AWS:** [Cartography AWS Schema](https://cartography-cncf.github.io/cartography/modules/aws/schema.html)
In addition to the upstream schema, Prowler enriches the graph with:
- **`ProwlerFinding`** nodes representing Prowler check results, linked to affected resources via `HAS_FINDING` relationships.
- **`Internet`** nodes used to model exposure paths from the public internet to internal resources.
<Note>
AI assistants connected through Prowler MCP Server can fetch the exact
Cartography schema for the active scan via the
`prowler_app_get_attack_paths_cartography_schema` tool. This guarantees that
generated queries match the schema version pinned by the running Prowler
release.
</Note>
## Executing a Query
To run the selected query against the scan data, click **Execute Query**. The button displays a loading state while the query processes.

View File

@@ -17,6 +17,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed
- Attack Paths custom openCypher queries now use a code editor with syntax highlighting and line numbers [(#10445)](https://github.com/prowler-cloud/prowler/pull/10445)
- Attack Paths custom openCypher queries now link to the Prowler documentation with examples and how-to guidance instead of the upstream Cartography schema URL
- Filter summary strip: removed redundant "Clear all" link next to pills (use top-bar Clear Filters instead) and switched chip variant from `outline` to `tag` for consistency [(#10481)](https://github.com/prowler-cloud/prowler/pull/10481)
### 🐞 Fixed

View File

@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { DOCS_URLS } from "@/lib/external-urls";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathCartographySchemaAttributes,
type AttackPathQuery,
} from "@/types/attack-paths";
@@ -22,20 +22,9 @@ const presetQuery: AttackPathQuery = {
};
describe("buildAttackPathQueries", () => {
it("prepends a custom query with a schema documentation link", () => {
// Given
const schema: AttackPathCartographySchemaAttributes = {
id: "aws-0.129.0",
provider: "aws",
cartography_version: "0.129.0",
schema_url:
"https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
raw_schema_url:
"https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
};
it("prepends a custom query that links to the Prowler documentation", () => {
// When
const result = buildAttackPathQueries([presetQuery], schema);
const result = buildAttackPathQueries([presetQuery]);
// Then
expect(result[0]).toMatchObject({
@@ -44,8 +33,8 @@ describe("buildAttackPathQueries", () => {
name: "Custom openCypher query",
short_description: "Write and run your own read-only query",
documentation_link: {
text: "Cartography schema used by Prowler for AWS graphs",
link: schema.schema_url,
text: "Learn how to write custom openCypher queries",
link: DOCS_URLS.ATTACK_PATHS_CUSTOM_QUERIES,
},
},
});

View File

@@ -1,7 +1,7 @@
import { DOCS_URLS } from "@/lib/external-urls";
import { MetaDataProps } from "@/types";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathCartographySchemaAttributes,
AttackPathQueriesResponse,
AttackPathQuery,
QUERY_PARAMETER_INPUT_TYPES,
@@ -61,15 +61,12 @@ const CUSTOM_QUERY_PLACEHOLDER = `MATCH (n)
RETURN n
LIMIT 25`;
const formatSchemaDocumentationLinkText = (
schema: AttackPathCartographySchemaAttributes,
): string => {
return `Cartography schema used by Prowler for ${schema.provider.toUpperCase()} graphs`;
};
const CUSTOM_QUERY_DOCUMENTATION_LINK = {
text: "Learn how to write custom openCypher queries",
link: DOCS_URLS.ATTACK_PATHS_CUSTOM_QUERIES,
} as const;
const createCustomQuery = (
schema?: AttackPathCartographySchemaAttributes,
): AttackPathQuery => ({
const createCustomQuery = (): AttackPathQuery => ({
type: "attack-paths-scans",
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
attributes: {
@@ -79,12 +76,7 @@ const createCustomQuery = (
"Run a read-only openCypher query against the selected Attack Paths scan. Results are automatically scoped to the selected provider.",
provider: "custom",
attribution: null,
documentation_link: schema
? {
text: formatSchemaDocumentationLinkText(schema),
link: schema.schema_url,
}
: null,
documentation_link: { ...CUSTOM_QUERY_DOCUMENTATION_LINK },
parameters: [
{
name: "query",
@@ -103,7 +95,6 @@ const createCustomQuery = (
export const buildAttackPathQueries = (
queries: AttackPathQuery[],
schema?: AttackPathCartographySchemaAttributes,
): AttackPathQuery[] => {
return [createCustomQuery(schema), ...queries];
return [createCustomQuery(), ...queries];
};

View File

@@ -78,14 +78,31 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
check_description: null,
severity: "critical",
status: "FAIL",
muted: true,
impacted_providers: ["aws"],
resources_total: 5,
resources_fail: 3,
pass_count: 2,
fail_count: 3,
manual_count: 1,
pass_muted_count: 0,
fail_muted_count: 3,
manual_muted_count: 0,
muted_count: 0,
new_count: 1,
changed_count: 0,
new_fail_count: 0,
new_fail_muted_count: 1,
new_pass_count: 0,
new_pass_muted_count: 0,
new_manual_count: 0,
new_manual_muted_count: 0,
changed_fail_count: 0,
changed_fail_muted_count: 0,
changed_pass_count: 0,
changed_pass_muted_count: 0,
changed_manual_count: 0,
changed_manual_muted_count: 0,
first_seen_at: null,
last_seen_at: "2024-01-01T00:00:00Z",
failing_since: null,
@@ -101,6 +118,9 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
expect(result).toHaveLength(1);
expect(result[0].checkId).toBe("s3_bucket_public_access");
expect(result[0].checkTitle).toBe("S3 Bucket Public Access");
expect(result[0].muted).toBe(true);
expect(result[0].manualCount).toBe(1);
expect(result[0].newFailMutedCount).toBe(1);
});
});
@@ -149,6 +169,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
id: "resource-row-1",
type: "finding-group-resources",
attributes: {
finding_id: "real-finding-uuid",
resource: {
uid: "arn:aws:s3:::my-bucket",
name: "my-bucket",
@@ -163,6 +184,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
alias: "production",
},
status: "FAIL",
muted: true,
delta: "new",
severity: "critical",
first_seen_at: null,
@@ -177,8 +199,10 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
// Then
expect(result).toHaveLength(1);
expect(result[0].findingId).toBe("real-finding-uuid");
expect(result[0].checkId).toBe("s3_check");
expect(result[0].resourceName).toBe("my-bucket");
expect(result[0].delta).toBe("new");
expect(result[0].isMuted).toBe(true);
});
});

View File

@@ -19,15 +19,32 @@ interface FindingGroupAttributes {
check_title: string | null;
check_description: string | null;
severity: string;
status: string; // "FAIL" | "PASS" | "MUTED" (already uppercase)
status: string; // "FAIL" | "PASS" | "MANUAL" (already uppercase)
muted?: boolean;
impacted_providers: string[];
resources_total: number;
resources_fail: number;
pass_count: number;
fail_count: number;
manual_count?: number;
pass_muted_count?: number;
fail_muted_count?: number;
manual_muted_count?: number;
muted_count: number;
new_count: number;
changed_count: number;
new_fail_count?: number;
new_fail_muted_count?: number;
new_pass_count?: number;
new_pass_muted_count?: number;
new_manual_count?: number;
new_manual_muted_count?: number;
changed_fail_count?: number;
changed_fail_muted_count?: number;
changed_pass_count?: number;
changed_pass_muted_count?: number;
changed_manual_count?: number;
changed_manual_muted_count?: number;
first_seen_at: string | null;
last_seen_at: string | null;
failing_since: string | null;
@@ -62,10 +79,33 @@ export function adaptFindingGroupsResponse(
checkTitle: item.attributes.check_title || item.attributes.check_id,
severity: item.attributes.severity as Severity,
status: item.attributes.status as FindingStatus,
muted:
item.attributes.muted ??
(item.attributes.muted_count > 0 &&
(item.attributes.muted_count === item.attributes.resources_fail ||
item.attributes.muted_count === item.attributes.resources_total)),
resourcesTotal: item.attributes.resources_total,
resourcesFail: item.attributes.resources_fail,
passCount: item.attributes.pass_count,
failCount: item.attributes.fail_count,
manualCount: item.attributes.manual_count ?? 0,
passMutedCount: item.attributes.pass_muted_count ?? 0,
failMutedCount: item.attributes.fail_muted_count ?? 0,
manualMutedCount: item.attributes.manual_muted_count ?? 0,
newCount: item.attributes.new_count,
changedCount: item.attributes.changed_count,
newFailCount: item.attributes.new_fail_count ?? 0,
newFailMutedCount: item.attributes.new_fail_muted_count ?? 0,
newPassCount: item.attributes.new_pass_count ?? 0,
newPassMutedCount: item.attributes.new_pass_muted_count ?? 0,
newManualCount: item.attributes.new_manual_count ?? 0,
newManualMutedCount: item.attributes.new_manual_muted_count ?? 0,
changedFailCount: item.attributes.changed_fail_count ?? 0,
changedFailMutedCount: item.attributes.changed_fail_muted_count ?? 0,
changedPassCount: item.attributes.changed_pass_count ?? 0,
changedPassMutedCount: item.attributes.changed_pass_muted_count ?? 0,
changedManualCount: item.attributes.changed_manual_count ?? 0,
changedManualMutedCount: item.attributes.changed_manual_muted_count ?? 0,
mutedCount: item.attributes.muted_count,
providers: (item.attributes.impacted_providers || []) as ProviderType[],
updatedAt: item.attributes.last_seen_at || "",
@@ -95,9 +135,11 @@ interface ProviderInfo {
}
interface FindingGroupResourceAttributes {
finding_id: string;
resource: ResourceInfo;
provider: ProviderInfo;
status: string;
muted?: boolean;
delta?: string | null;
severity: string;
first_seen_at: string | null;
@@ -132,7 +174,7 @@ export function adaptFindingGroupResourcesResponse(
return data.map((item) => ({
id: item.id,
rowType: FINDINGS_ROW_TYPE.RESOURCE,
findingId: item.id,
findingId: item.attributes.finding_id || item.id,
checkId,
providerType: (item.attributes.provider?.type || "aws") as ProviderType,
providerAlias: item.attributes.provider?.alias || "",
@@ -146,7 +188,7 @@ export function adaptFindingGroupResourcesResponse(
severity: (item.attributes.severity || "informational") as Severity,
status: item.attributes.status,
delta: item.attributes.delta || null,
isMuted: item.attributes.status === "MUTED",
isMuted: item.attributes.muted ?? item.attributes.status === "MUTED",
mutedReason: item.attributes.muted_reason || undefined,
firstSeenAt: item.attributes.first_seen_at,
lastSeenAt: item.attributes.last_seen_at,

View File

@@ -187,7 +187,9 @@ describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
// Then — the URL must contain the composite sort
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
});
it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
@@ -223,7 +225,9 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
});
it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
@@ -265,7 +269,9 @@ describe("getFindingGroupResources — triangulation: params coexist", () => {
const url = new URL(calledUrl);
expect(url.searchParams.get("page[number]")).toBe("2");
expect(url.searchParams.get("page[size]")).toBe("50");
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[status]")).toBeNull();
});
});
@@ -291,7 +297,9 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", ()
const url = new URL(calledUrl);
expect(url.searchParams.get("page[number]")).toBe("3");
expect(url.searchParams.get("page[size]")).toBe("20");
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[status]")).toBeNull();
});
});
@@ -360,7 +368,9 @@ describe("getFindingGroupResources — caller filters are preserved", () => {
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod");
expect(url.searchParams.get("filter[severity__in]")).toBe("high");
});
@@ -426,7 +436,9 @@ describe("getLatestFindingGroupResources — caller filters are preserved", () =
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[name__icontains]")).toBe(
"instance-prod",
);

View File

@@ -62,7 +62,10 @@ function normalizeFindingGroupResourceFilters(
}
const DEFAULT_FINDING_GROUPS_SORT =
"-severity,-delta,-fail_count,-last_seen_at";
"-status,-severity,-delta,-fail_count,-last_seen_at";
const DEFAULT_FINDING_GROUP_RESOURCES_SORT =
"-status,-delta,-severity,-last_seen_at";
interface FetchFindingGroupsParams {
page?: number;
@@ -133,7 +136,7 @@ async function fetchFindingGroupResourcesEndpoint(
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
url.searchParams.append("sort", "-severity,-delta,-last_seen_at");
url.searchParams.append("sort", DEFAULT_FINDING_GROUP_RESOURCES_SORT);
appendSanitizedProviderFilters(url, normalizedFilters);

View File

@@ -43,7 +43,6 @@ vi.mock("@/actions/finding-groups", () => ({
}));
import {
resolveFindingIds,
resolveFindingIdsByCheckIds,
resolveFindingIdsByVisibleGroupResources,
} from "./findings-by-resource";
@@ -142,47 +141,6 @@ describe("resolveFindingIdsByCheckIds", () => {
});
});
describe("resolveFindingIds", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("should use the dated findings endpoint when date or scan filters are active", async () => {
// Given
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }, { id: "finding-2" }],
});
// When
const result = await resolveFindingIds({
checkId: "check-1",
resourceUids: ["resource-1", "resource-2"],
hasDateOrScanFilter: true,
filters: {
"filter[scan__in]": "scan-1",
"filter[inserted_at__gte]": "2026-03-01",
},
});
// Then
expect(result).toEqual(["finding-1", "finding-2"]);
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.pathname).toBe("/api/v1/findings");
expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1");
expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe(
"resource-1,resource-2",
);
expect(calledUrl.searchParams.get("filter[scan__in]")).toBe("scan-1");
expect(calledUrl.searchParams.get("filter[inserted_at__gte]")).toBe(
"2026-03-01",
);
});
});
describe("resolveFindingIdsByVisibleGroupResources", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -190,22 +148,18 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("should resolve finding IDs from the group's visible resource UIDs instead of muting the whole check", async () => {
// Given
it("extracts finding_id directly from group resources without a second resolution round-trip", async () => {
// Given — the group resources endpoint returns finding_id in each resource
getLatestFindingGroupResourcesMock
.mockResolvedValueOnce({
data: [
{
id: "resource-row-1",
attributes: {
resource: { uid: "resource-1" },
},
attributes: { finding_id: "finding-1" },
},
{
id: "resource-row-2",
attributes: {
resource: { uid: "resource-2" },
},
attributes: { finding_id: "finding-2" },
},
],
meta: { pagination: { pages: 2 } },
@@ -214,19 +168,12 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
data: [
{
id: "resource-row-3",
attributes: {
resource: { uid: "resource-3" },
},
attributes: { finding_id: "finding-3" },
},
],
meta: { pagination: { pages: 2 } },
});
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }],
});
// When
const result = await resolveFindingIdsByVisibleGroupResources({
checkId: "check-1",
@@ -236,8 +183,13 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
resourceSearch: "visible subset",
});
// Then
// Then — finding IDs come directly from the group resources response
expect(result).toEqual(["finding-1", "finding-2", "finding-3"]);
// No second round-trip to /findings/latest
expect(fetchMock).not.toHaveBeenCalled();
// Group resources endpoint was paginated with correct filters
expect(getLatestFindingGroupResourcesMock).toHaveBeenCalledTimes(2);
expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(1, {
checkId: "check-1",
@@ -246,6 +198,8 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
filters: {
"filter[provider_type__in]": "aws",
"filter[name__icontains]": "visible subset",
"filter[status]": "FAIL",
"filter[muted]": "false",
},
});
expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(2, {
@@ -255,168 +209,56 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
filters: {
"filter[provider_type__in]": "aws",
"filter[name__icontains]": "visible subset",
"filter[status]": "FAIL",
"filter[muted]": "false",
},
});
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.pathname).toBe("/api/v1/findings/latest");
expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1");
expect(calledUrl.searchParams.get("filter[check_id__in]")).toBeNull();
expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe(
"resource-1,resource-2,resource-3",
);
});
});
// ---------------------------------------------------------------------------
// Blocker 3: Muting a group mutes ALL historical findings, not just FAIL ones
//
// The fix: resolveFindingIds must include filter[status]=FAIL so only active
// (failing) findings are resolved for mute, not historical/passing ones.
// ---------------------------------------------------------------------------
describe("resolveFindingIds — Blocker 3: only resolve FAIL findings for mute", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("should include filter[status]=FAIL in the findings resolution URL for mute", async () => {
it("deduplicates finding IDs across pages", async () => {
// Given — same finding_id appears on both pages
getLatestFindingGroupResourcesMock
.mockResolvedValueOnce({
data: [
{ id: "r-1", attributes: { finding_id: "finding-1" } },
{ id: "r-2", attributes: { finding_id: "finding-2" } },
],
meta: { pagination: { pages: 2 } },
})
.mockResolvedValueOnce({
data: [{ id: "r-3", attributes: { finding_id: "finding-2" } }],
meta: { pagination: { pages: 2 } },
});
// When
const result = await resolveFindingIdsByVisibleGroupResources({
checkId: "check-1",
});
// Then — no duplicates
expect(result).toEqual(["finding-1", "finding-2"]);
expect(fetchMock).not.toHaveBeenCalled();
});
it("uses the dated endpoint when date or scan filters are active", async () => {
// Given
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }, { id: "finding-2" }],
getFindingGroupResourcesMock.mockResolvedValueOnce({
data: [{ id: "r-1", attributes: { finding_id: "finding-1" } }],
meta: { pagination: { pages: 1 } },
});
// When
await resolveFindingIds({
await resolveFindingIdsByVisibleGroupResources({
checkId: "check-1",
resourceUids: ["resource-1", "resource-2"],
});
// Then — the URL must filter to only FAIL status findings
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL");
});
it("should include filter[status]=FAIL even when date or scan filters are active", async () => {
// Given
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }],
});
// When
await resolveFindingIds({
checkId: "check-1",
resourceUids: ["resource-1"],
hasDateOrScanFilter: true,
filters: {
"filter[inserted_at__gte]": "2026-01-01",
"filter[scan__in]": "scan-1",
},
});
// Then
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.pathname).toBe("/api/v1/findings");
expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL");
});
it("should override caller filter[status] with FAIL — no duplicate params", async () => {
// Given — caller passes filter[status]=PASS via filters dict
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }],
});
// When
await resolveFindingIds({
checkId: "check-1",
resourceUids: ["resource-1"],
filters: {
"filter[status]": "PASS",
},
});
// Then — hardcoded FAIL must win, exactly 1 value
const calledUrl = new URL(fetchMock.mock.calls[0][0] as string);
const statusValues = calledUrl.searchParams.getAll("filter[status]");
expect(statusValues).toHaveLength(1);
expect(statusValues[0]).toBe("FAIL");
});
});
// ---------------------------------------------------------------------------
// Fix 4: Unbounded page[size] cap
//
// The bug: createResourceFindingResolutionUrl sets page[size]=resourceUids.length
// with no upper bound guard. The production fix adds Math.min(resourceUids.length, MAX_PAGE_SIZE)
// with MAX_PAGE_SIZE=500 as an explicit defensive cap.
// ---------------------------------------------------------------------------
describe("resolveFindingIds — Fix 4: page[size] explicit cap at MAX_PAGE_SIZE=500", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("should use resourceUids.length as page[size] for a small batch (under 500)", async () => {
// Given — 3 resources, well under the cap
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }],
});
// When
await resolveFindingIds({
checkId: "check-1",
resourceUids: ["resource-1", "resource-2", "resource-3"],
});
// Then — page[size] should equal the number of resourceUids (3)
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.searchParams.get("page[size]")).toBe("3");
});
it("should cap page[size] at 500 when the chunk has exactly 500 UIDs (boundary value)", async () => {
// Given — exactly 500 unique UIDs (at the cap boundary)
const resourceUids = Array.from({ length: 500 }, (_, i) => `resource-${i}`);
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({ data: [] });
// When
await resolveFindingIds({
checkId: "check-1",
resourceUids,
});
// Then — page[size] must be exactly 500 (not capped lower)
const firstUrl = new URL(fetchMock.mock.calls[0][0] as string);
expect(firstUrl.searchParams.get("page[size]")).toBe("500");
});
it("should cap page[size] at 500 even when a chunk would exceed 500 — Math.min guard in URL builder", async () => {
// Given — 501 UIDs. The chunker splits into [500, 1].
// The FIRST chunk has 500 UIDs → page[size] should be 500 (Math.min(500, 500)).
// The SECOND chunk has 1 UID → page[size] should be 1 (Math.min(1, 500)).
// This proves the Math.min cap fires correctly on every chunk.
const resourceUids = Array.from({ length: 501 }, (_, i) => `resource-${i}`);
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({ data: [] });
// When
await resolveFindingIds({
checkId: "check-1",
resourceUids,
});
// Then — two fetch calls: one for 500 UIDs, one for 1 UID
expect(fetchMock).toHaveBeenCalledTimes(2);
const firstUrl = new URL(fetchMock.mock.calls[0][0] as string);
const secondUrl = new URL(fetchMock.mock.calls[1][0] as string);
expect(firstUrl.searchParams.get("page[size]")).toBe("500");
expect(secondUrl.searchParams.get("page[size]")).toBe("1");
// Then — uses getFindingGroupResources (dated), not getLatestFindingGroupResources
expect(getFindingGroupResourcesMock).toHaveBeenCalledTimes(1);
expect(getLatestFindingGroupResourcesMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
});

View File

@@ -14,22 +14,12 @@ const FINDING_IDS_RESOLUTION_CONCURRENCY = 4;
const FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE = 500;
const FINDING_FIELDS = "uid";
/** Explicit upper bound for page[size] in resource-finding resolution requests. */
const MAX_RESOURCE_FINDING_PAGE_SIZE = 500;
interface ResolveFindingIdsByCheckIdsParams {
checkIds: string[];
filters?: Record<string, string>;
hasDateOrScanFilter?: boolean;
}
interface ResolveFindingIdsParams {
checkId: string;
resourceUids: string[];
filters?: Record<string, string>;
hasDateOrScanFilter?: boolean;
}
interface ResolveFindingIdsByVisibleGroupResourcesParams {
checkId: string;
filters?: Record<string, string>;
@@ -42,8 +32,8 @@ interface FindingIdsPageResponse {
totalPages: number;
}
interface FindingGroupResourceUidsPageResponse {
resourceUids: string[];
interface FindingGroupResourceFindingIdsPageResponse {
findingIds: string[];
totalPages: number;
}
@@ -100,78 +90,7 @@ async function fetchFindingIdsPage({
};
}
function chunkValues<T>(values: T[], chunkSize: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < values.length; index += chunkSize) {
chunks.push(values.slice(index, index + chunkSize));
}
return chunks;
}
function createResourceFindingResolutionUrl({
checkId,
resourceUids,
filters = {},
hasDateOrScanFilter = false,
}: ResolveFindingIdsParams): URL {
const endpoint = hasDateOrScanFilter ? "findings" : "findings/latest";
const url = new URL(`${apiBaseUrl}/${endpoint}`);
url.searchParams.append("filter[check_id]", checkId);
url.searchParams.append("filter[resource_uid__in]", resourceUids.join(","));
url.searchParams.append("filter[muted]", "false");
url.searchParams.append(
"page[size]",
Math.min(resourceUids.length, MAX_RESOURCE_FINDING_PAGE_SIZE).toString(),
);
appendSanitizedProviderTypeFilters(url, filters);
// Hardcoded FAIL filter AFTER appendSanitizedProviderTypeFilters — .set()
// guarantees this wins even if the caller passes filter[status] in filters.
url.searchParams.set("filter[status]", "FAIL");
return url;
}
async function fetchFindingIdsForResourceUids({
headers,
...params
}: ResolveFindingIdsParams & {
headers: HeadersInit;
}): Promise<string[]> {
const response = await fetch(
createResourceFindingResolutionUrl(params).toString(),
{
headers,
},
);
const data = await handleApiResponse(response);
if (!data?.data || !Array.isArray(data.data)) {
return [];
}
return data.data
.map((item: { id?: string }) => item.id)
.filter((id: string | undefined): id is string => Boolean(id));
}
function buildFindingGroupResourceFilters({
filters = {},
resourceSearch,
}: Pick<
ResolveFindingIdsByVisibleGroupResourcesParams,
"filters" | "resourceSearch"
>): Record<string, string> {
const nextFilters = { ...filters };
if (resourceSearch) {
nextFilters["filter[name__icontains]"] = resourceSearch;
}
return nextFilters;
}
async function fetchFindingGroupResourceUidsPage({
async function fetchFindingGroupResourceFindingIdsPage({
checkId,
filters = {},
hasDateOrScanFilter = false,
@@ -179,77 +98,44 @@ async function fetchFindingGroupResourceUidsPage({
resourceSearch,
}: ResolveFindingIdsByVisibleGroupResourcesParams & {
page: number;
}): Promise<FindingGroupResourceUidsPageResponse> {
}): Promise<FindingGroupResourceFindingIdsPageResponse> {
const fetchFn = hasDateOrScanFilter
? getFindingGroupResources
: getLatestFindingGroupResources;
const resolvedFilters: Record<string, string> = {
...filters,
"filter[status]": "FAIL",
"filter[muted]": "false",
};
if (resourceSearch) {
resolvedFilters["filter[name__icontains]"] = resourceSearch;
}
const response = await fetchFn({
checkId,
page,
pageSize: FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE,
filters: buildFindingGroupResourceFilters({ filters, resourceSearch }),
filters: resolvedFilters,
});
const data = response?.data;
if (!data || !Array.isArray(data)) {
return { resourceUids: [], totalPages: 1 };
return { findingIds: [], totalPages: 1 };
}
return {
resourceUids: data
findingIds: data
.map(
(item: { attributes?: { resource?: { uid?: string } } }) =>
item.attributes?.resource?.uid,
(item: { attributes?: { finding_id?: string } }) =>
item.attributes?.finding_id,
)
.filter((uid: string | undefined): uid is string => Boolean(uid)),
.filter((id: string | undefined): id is string => Boolean(id)),
totalPages: response?.meta?.pagination?.pages ?? 1,
};
}
/**
* Resolves resource UIDs + check ID into actual finding UUIDs.
* Uses /findings/latest (or /findings when date/scan filters are active)
* with check_id and resource_uid__in filters to batch-resolve actual finding IDs.
*/
export const resolveFindingIds = async ({
checkId,
resourceUids,
filters = {},
hasDateOrScanFilter = false,
}: ResolveFindingIdsParams): Promise<string[]> => {
if (resourceUids.length === 0) {
return [];
}
const headers = await getAuthHeaders({ contentType: false });
const resourceUidChunks = chunkValues(
Array.from(new Set(resourceUids)),
FINDING_IDS_RESOLUTION_PAGE_SIZE,
);
try {
const results = await runWithConcurrencyLimit(
resourceUidChunks,
FINDING_IDS_RESOLUTION_CONCURRENCY,
(resourceUidChunk) =>
fetchFindingIdsForResourceUids({
checkId,
resourceUids: resourceUidChunk,
filters,
hasDateOrScanFilter,
headers,
}),
);
return Array.from(new Set(results.flat()));
} catch (error) {
console.error("Error resolving finding IDs:", error);
return [];
}
};
/**
* Resolves check IDs into actual finding UUIDs.
* Used at the group level where each row represents a check_id.
@@ -305,8 +191,12 @@ export const resolveFindingIdsByCheckIds = async ({
};
/**
* Resolves a finding-group row to the actual findings for the resources
* Resolves a finding-group row to the actual finding UUIDs for the resources
* currently visible in that group.
*
* Extracts finding_id directly from the group resources endpoint response,
* filtering server-side by status=FAIL and muted=false. No second resolution
* round-trip to /findings/latest is needed.
*/
export const resolveFindingIdsByVisibleGroupResources = async ({
checkId,
@@ -315,7 +205,7 @@ export const resolveFindingIdsByVisibleGroupResources = async ({
resourceSearch,
}: ResolveFindingIdsByVisibleGroupResourcesParams): Promise<string[]> => {
try {
const firstPage = await fetchFindingGroupResourceUidsPage({
const firstPage = await fetchFindingGroupResourceFindingIdsPage({
checkId,
filters,
hasDateOrScanFilter,
@@ -332,7 +222,7 @@ export const resolveFindingIdsByVisibleGroupResources = async ({
remainingPages,
FINDING_IDS_RESOLUTION_CONCURRENCY,
(page) =>
fetchFindingGroupResourceUidsPage({
fetchFindingGroupResourceFindingIdsPage({
checkId,
filters,
hasDateOrScanFilter,
@@ -341,19 +231,12 @@ export const resolveFindingIdsByVisibleGroupResources = async ({
}),
);
const resourceUids = Array.from(
return Array.from(
new Set([
...firstPage.resourceUids,
...remainingResults.flatMap((result) => result.resourceUids),
...firstPage.findingIds,
...remainingResults.flatMap((result) => result.findingIds),
]),
);
return resolveFindingIds({
checkId,
resourceUids,
filters,
hasDateOrScanFilter,
});
} catch (error) {
console.error(
"Error resolving finding IDs from visible group resources:",
@@ -381,7 +264,7 @@ export const getLatestFindingsByResourceUid = async ({
url.searchParams.append("filter[resource_uid]", resourceUid);
url.searchParams.append("filter[status]", "FAIL");
url.searchParams.append("filter[muted]", "include");
url.searchParams.append("sort", "-severity,status,-updated_at");
url.searchParams.append("sort", "-severity,-updated_at");
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());

View File

@@ -16,27 +16,27 @@ const customQuery: AttackPathQuery = {
provider: "aws",
attribution: null,
documentation_link: {
text: "Cartography schema used by Prowler for AWS graphs",
link: "https://example.com/schema",
text: "Learn how to write custom openCypher queries",
link: "https://example.com/docs",
},
parameters: [],
},
};
describe("QueryDescription", () => {
it("renders the schema documentation link inside an info alert", () => {
it("renders the documentation link inside an info alert", () => {
// Given
render(<QueryDescription query={customQuery} />);
// When
const alert = screen.getByRole("alert");
const link = screen.getByRole("link", {
name: /cartography schema used by prowler for aws graphs/i,
name: /learn how to write custom opencypher queries/i,
});
// Then
expect(alert).toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://example.com/schema");
expect(link).toHaveAttribute("href", "https://example.com/docs");
});
it("does not render unsafe documentation or attribution URLs as clickable links", () => {
@@ -46,7 +46,7 @@ describe("QueryDescription", () => {
attributes: {
...customQuery.attributes,
documentation_link: {
text: "Cartography schema used by Prowler for AWS graphs",
text: "Learn how to write custom openCypher queries",
link: "javascript:alert('xss')",
},
attribution: {
@@ -62,14 +62,14 @@ describe("QueryDescription", () => {
// Then
expect(
screen.queryByRole("link", {
name: /cartography schema used by prowler for aws graphs/i,
name: /learn how to write custom opencypher queries/i,
}),
).not.toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /unsafe source/i }),
).not.toBeInTheDocument();
expect(
screen.getByText(/cartography schema used by prowler for aws graphs/i),
screen.getByText(/learn how to write custom opencypher queries/i),
).toBeInTheDocument();
expect(screen.getByText(/unsafe source/i)).toBeInTheDocument();
});

View File

@@ -12,7 +12,6 @@ import {
executeQuery,
getAttackPathScans,
getAvailableQueries,
getCartographySchema,
} from "@/actions/attack-paths";
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
import { AutoRefresh } from "@/components/scans";
@@ -144,14 +143,10 @@ export default function AttackPathsPage() {
setQueriesLoading(true);
try {
const [queriesData, schemaData] = await Promise.all([
getAvailableQueries(scanId),
getCartographySchema(scanId),
]);
const queriesData = await getAvailableQueries(scanId);
const availableQueries = buildAttackPathQueries(
queriesData?.data ?? [],
schemaData?.data.attributes,
);
if (availableQueries.length > 0) {

View File

@@ -7,6 +7,7 @@ import {
getComplianceRequirements,
} from "@/actions/compliances";
import { getThreatScore } from "@/actions/overview";
import { getScan } from "@/actions/scans";
import {
ClientAccordionWrapper,
ComplianceDownloadContainer,
@@ -37,7 +38,6 @@ interface ComplianceDetailSearchParams {
complianceId: string;
version?: string;
scanId?: string;
scanData?: string;
"filter[region__in]"?: string;
"filter[cis_profile_level]"?: string;
page?: string;
@@ -53,7 +53,7 @@ export default async function ComplianceDetail({
}) {
const { compliancetitle } = await params;
const resolvedSearchParams = await searchParams;
const { complianceId, version, scanId, scanData } = resolvedSearchParams;
const { complianceId, version, scanId } = resolvedSearchParams;
const regionFilter = resolvedSearchParams["filter[region__in]"];
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
const logoPath = getComplianceIcon(compliancetitle);
@@ -72,21 +72,34 @@ export default async function ComplianceDetail({
: `${formattedTitle}`;
let selectedScan: ScanEntity | null = null;
const selectedScanId = scanId || null;
if (scanData) {
selectedScan = JSON.parse(decodeURIComponent(scanData));
}
const [metadataInfoData, attributesData, selectedScanResponse] =
await Promise.all([
getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId,
},
}),
getComplianceAttributes(complianceId),
selectedScanId ? getScan(selectedScanId) : Promise.resolve(null),
]);
const selectedScanId = scanId || selectedScan?.id || null;
const [metadataInfoData, attributesData] = await Promise.all([
getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId,
if (selectedScanResponse?.data) {
const scan = selectedScanResponse.data;
selectedScan = {
id: scan.id,
providerInfo: {
provider: scan.providerInfo?.provider || "aws",
alias: scan.providerInfo?.alias,
uid: scan.providerInfo?.uid,
},
}),
getComplianceAttributes(complianceId),
]);
attributes: {
name: scan.attributes.name,
completed_at: scan.attributes.completed_at,
},
};
}
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];

View File

@@ -32,7 +32,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
scanId,
complianceId,
id,
selectedScan,
}) => {
const searchParams = useSearchParams();
const router = useRouter();
@@ -65,17 +64,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
params.set("version", version);
params.set("scanId", scanId);
if (selectedScan) {
params.set(
"scanData",
JSON.stringify({
id: selectedScan.id,
providerInfo: selectedScan.providerInfo,
attributes: selectedScan.attributes,
}),
);
}
const regionFilter = searchParams.get("filter[region__in]");
if (regionFilter) {
params.set("filter[region__in]", regionFilter);

View File

@@ -40,7 +40,6 @@ export const ThreatScoreBadge = ({
score,
scanId,
provider,
selectedScan,
sectionScores,
}: ThreatScoreBadgeProps) => {
const router = useRouter();
@@ -62,17 +61,6 @@ export const ThreatScoreBadge = ({
params.set("version", version);
params.set("scanId", scanId);
if (selectedScan) {
params.set(
"scanData",
JSON.stringify({
id: selectedScan.id,
providerInfo: selectedScan.providerInfo,
attributes: selectedScan.attributes,
}),
);
}
const regionFilter = searchParams.get("filter[region__in]");
if (regionFilter) {
params.set("filter[region__in]", regionFilter);

View File

@@ -86,7 +86,14 @@ export const FindingsFilters = ({
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
const customFilters = [
...filterFindings,
...filterFindings.map((filter) => ({
...filter,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
providers,
scans: scanDetails,
}),
})),
{
key: FilterType.REGION,
labelCheckboxGroup: "Regions",
@@ -124,6 +131,11 @@ export const FindingsFilters = ({
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
valueLabelMapping: scanDetails,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${FilterType.SCAN}]`, value, {
providers,
scans: scanDetails,
}),
index: 7,
},
];

View File

@@ -101,6 +101,24 @@ describe("getFindingsFilterDisplayValue", () => {
).toBe("Scan Account");
});
it("normalizes finding statuses for display", () => {
expect(getFindingsFilterDisplayValue("filter[status__in]", "FAIL")).toBe(
"Fail",
);
});
it("normalizes severities for display", () => {
expect(
getFindingsFilterDisplayValue("filter[severity__in]", "critical"),
).toBe("Critical");
});
it("formats delta values for display", () => {
expect(getFindingsFilterDisplayValue("filter[delta__in]", "new")).toBe(
"New",
);
});
it("falls back to the scan provider uid when the alias is missing", () => {
expect(
getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", {

View File

@@ -9,6 +9,11 @@ interface GetFindingsFilterDisplayValueOptions {
scans?: Array<{ [scanId: string]: ScanEntity }>;
}
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
new: "New",
changed: "Changed",
};
function getProviderAccountDisplayValue(
providerId: string,
providers: ProviderProps[],
@@ -62,6 +67,11 @@ export function getFindingsFilterDisplayValue(
] ?? formatLabel(value)
);
}
if (filterKey === "filter[delta__in]") {
return (
FINDING_DELTA_DISPLAY_NAMES[value.toLowerCase()] ?? formatLabel(value)
);
}
if (filterKey === "filter[category__in]") {
return getCategoryLabel(value);
}

View File

@@ -24,6 +24,17 @@ vi.mock("next/navigation", () => ({
import { FloatingMuteButton } from "./floating-mute-button";
function deferredPromise<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
// ---------------------------------------------------------------------------
// Fix 3: onBeforeOpen rejection resets isResolving
// ---------------------------------------------------------------------------
@@ -31,7 +42,6 @@ import { FloatingMuteButton } from "./floating-mute-button";
describe("FloatingMuteButton — onBeforeOpen error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
@@ -58,11 +68,9 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
});
});
it("should log the error when onBeforeOpen rejects", async () => {
it("should show the preparation error in the modal when onBeforeOpen rejects", async () => {
// Given
const error = new Error("Fetch failed");
const onBeforeOpen = vi.fn().mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const onBeforeOpen = vi.fn().mockRejectedValue(new Error("Fetch failed"));
const user = userEvent.setup();
render(
@@ -76,9 +84,26 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
// When
await user.click(screen.getByRole("button"));
// Then — error was logged
// Then
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalled();
const lastCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[
{
isOpen: boolean;
isPreparing?: boolean;
preparationError?: string | null;
},
]
>
).at(-1);
expect(lastCall?.[0]).toMatchObject({
isOpen: true,
isPreparing: false,
preparationError:
"We couldn't prepare this mute action. Please try again.",
});
});
});
@@ -102,10 +127,76 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
await waitFor(() => {
const lastCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[{ isOpen: boolean; findingIds: string[] }]
[
{
isOpen: boolean;
findingIds: string[];
isPreparing?: boolean;
},
]
>
).at(-1);
expect(lastCall?.[0]?.isOpen).toBe(true);
});
});
it("should open the modal immediately in preparing state while IDs are still resolving", async () => {
// Given
const deferred = deferredPromise<string[]>();
const onBeforeOpen = vi.fn().mockReturnValue(deferred.promise);
const user = userEvent.setup();
render(
<FloatingMuteButton
selectedCount={3}
selectedFindingIds={["group-1", "group-2", "group-3"]}
onBeforeOpen={onBeforeOpen}
/>,
);
// When
await user.click(screen.getByRole("button"));
// Then
const preparingCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[
{
isOpen: boolean;
findingIds: string[];
isPreparing?: boolean;
},
]
>
).at(-1);
expect(preparingCall?.[0]).toMatchObject({
isOpen: true,
isPreparing: true,
findingIds: [],
});
// And when the IDs resolve
deferred.resolve(["id-1", "id-2"]);
await waitFor(() => {
const resolvedCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[
{
isOpen: boolean;
findingIds: string[];
isPreparing?: boolean;
},
]
>
).at(-1);
expect(resolvedCall?.[0]).toMatchObject({
isOpen: true,
isPreparing: false,
findingIds: ["id-1", "id-2"],
});
});
});
});

View File

@@ -31,22 +31,46 @@ export function FloatingMuteButton({
const [isModalOpen, setIsModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
const [isResolving, setIsResolving] = useState(false);
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
const [mutePreparationError, setMutePreparationError] = useState<
string | null
>(null);
const handleModalOpenChange = (
nextOpen: boolean | ((previousOpen: boolean) => boolean),
) => {
const resolvedOpen =
typeof nextOpen === "function" ? nextOpen(isModalOpen) : nextOpen;
setIsModalOpen(resolvedOpen);
if (!resolvedOpen) {
setResolvedIds([]);
setIsPreparingMuteModal(false);
setMutePreparationError(null);
}
};
const handleClick = async () => {
if (onBeforeOpen) {
setResolvedIds([]);
setMutePreparationError(null);
setIsPreparingMuteModal(true);
setIsModalOpen(true);
setIsResolving(true);
try {
const ids = await onBeforeOpen();
setResolvedIds(ids);
if (ids.length > 0) {
setIsModalOpen(true);
}
} catch (error) {
console.error(
"FloatingMuteButton: failed to resolve finding IDs",
error,
setMutePreparationError(
ids.length === 0
? "No findings could be resolved for this selection. Try refreshing the page and trying again."
: null,
);
} catch {
setMutePreparationError(
"We couldn't prepare this mute action. Please try again.",
);
} finally {
setIsPreparingMuteModal(false);
setIsResolving(false);
}
} else {
@@ -65,10 +89,12 @@ export function FloatingMuteButton({
<>
<MuteFindingsModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
onOpenChange={handleModalOpenChange}
findingIds={findingIds}
onComplete={handleComplete}
isBulkOperation={isBulkOperation}
isPreparing={isPreparingMuteModal}
preparationError={mutePreparationError}
/>
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">

View File

@@ -5,7 +5,10 @@ import { Dispatch, SetStateAction, useState, useTransition } from "react";
import { createMuteRule } from "@/actions/mute-rules";
import { MuteRuleActionState } from "@/actions/mute-rules/types";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { useToast } from "@/components/ui";
import { FormButtons } from "@/components/ui/form";
@@ -15,6 +18,8 @@ interface MuteFindingsModalProps {
findingIds: string[];
onComplete?: () => void;
isBulkOperation?: boolean;
isPreparing?: boolean;
preparationError?: string | null;
}
export function MuteFindingsModal({
@@ -23,6 +28,8 @@ export function MuteFindingsModal({
findingIds,
onComplete,
isBulkOperation = false,
isPreparing = false,
preparationError = null,
}: MuteFindingsModalProps) {
const { toast } = useToast();
const [state, setState] = useState<MuteRuleActionState | null>(null);
@@ -32,6 +39,12 @@ export function MuteFindingsModal({
onOpenChange(false);
};
const isSubmitDisabled =
isPending ||
isPreparing ||
findingIds.length === 0 ||
Boolean(preparationError);
return (
<Modal
open={isOpen}
@@ -43,6 +56,10 @@ export function MuteFindingsModal({
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
if (isSubmitDisabled) {
return;
}
const formData = new FormData(e.currentTarget);
startTransition(() => {
@@ -77,52 +94,121 @@ export function MuteFindingsModal({
value={JSON.stringify(findingIds)}
/>
<div className="rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
<p className="text-sm text-slate-600 dark:text-slate-400">
You are about to mute{" "}
<span className="font-semibold text-slate-900 dark:text-white">
{findingIds.length}
</span>{" "}
{findingIds.length === 1 ? "finding" : "findings"}.
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-500">
Muted findings will be hidden by default but can be shown using
filters.
</p>
</div>
{isPreparing ? (
<>
<div className="rounded-lg bg-slate-50 p-4 dark:bg-slate-800/50">
<div className="flex items-start gap-3">
<Spinner className="mt-0.5 size-5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-slate-900 dark:text-white">
Preparing findings to mute...
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Large finding groups can take a few seconds while we gather
the matching findings.
</p>
</div>
</div>
</div>
<Input
name="name"
label="Rule Name"
placeholder="e.g., Ignore dev environment S3 buckets"
isRequired
variant="bordered"
isInvalid={!!state?.errors?.name}
errorMessage={state?.errors?.name}
isDisabled={isPending}
description="A descriptive name for this mute rule"
/>
<div className="space-y-3" aria-hidden="true">
<div className="space-y-2">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-11 w-full rounded-lg" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-20 rounded" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
</div>
<Textarea
name="reason"
label="Reason"
placeholder="e.g., These are expected findings in the development environment"
isRequired
variant="bordered"
minRows={3}
maxRows={6}
isInvalid={!!state?.errors?.reason}
errorMessage={state?.errors?.reason}
isDisabled={isPending}
description="Explain why these findings are being muted"
/>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleCancel}
>
Cancel
</Button>
<Button type="button" size="lg" disabled>
<Spinner className="size-4" />
Preparing...
</Button>
</div>
</>
) : preparationError ? (
<>
<div className="rounded-lg bg-slate-50 p-4 dark:bg-slate-800/50">
<p className="text-sm font-medium text-slate-900 dark:text-white">
We couldn&apos;t prepare this mute action.
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{preparationError}
</p>
</div>
<FormButtons
setIsOpen={onOpenChange}
onCancel={handleCancel}
submitText="Mute Findings"
isDisabled={isPending}
/>
<div className="flex w-full justify-end">
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleCancel}
>
Close
</Button>
</div>
</>
) : (
<>
<div className="rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
<p className="text-sm text-slate-600 dark:text-slate-400">
You are about to mute{" "}
<span className="font-semibold text-slate-900 dark:text-white">
{findingIds.length}
</span>{" "}
{findingIds.length === 1 ? "finding" : "findings"}.
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-500">
Muted findings will be hidden by default but can be shown using
filters.
</p>
</div>
<Input
name="name"
label="Rule Name"
placeholder="e.g., Ignore dev environment S3 buckets"
isRequired
variant="bordered"
isInvalid={!!state?.errors?.name}
errorMessage={state?.errors?.name}
isDisabled={isPending}
description="A descriptive name for this mute rule"
/>
<Textarea
name="reason"
label="Reason"
placeholder="e.g., These are expected findings in the development environment"
isRequired
variant="bordered"
minRows={3}
maxRows={6}
isInvalid={!!state?.errors?.reason}
errorMessage={state?.errors?.reason}
isDisabled={isPending}
description="Explain why these findings are being muted"
/>
<FormButtons
setIsOpen={onOpenChange}
onCancel={handleCancel}
submitText="Mute Findings"
isDisabled={isPending}
/>
</>
)}
</form>
</Modal>
);

View File

@@ -3,6 +3,10 @@ import userEvent from "@testing-library/user-event";
import type { InputHTMLAttributes, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
const { notificationIndicatorMock } = vi.hoisted(() => ({
notificationIndicatorMock: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Hoist mocks for dependencies
// ---------------------------------------------------------------------------
@@ -72,7 +76,10 @@ vi.mock("./impacted-resources-cell", () => ({
vi.mock("./notification-indicator", () => ({
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" },
NotificationIndicator: () => null,
NotificationIndicator: (props: unknown) => {
notificationIndicatorMock(props);
return null;
},
}));
// ---------------------------------------------------------------------------
@@ -95,10 +102,23 @@ function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
checkTitle: "S3 Bucket Public Access",
severity: "critical",
status: "FAIL",
muted: false,
resourcesTotal: 5,
resourcesFail: 3,
newCount: 0,
changedCount: 0,
newFailCount: 0,
newFailMutedCount: 0,
newPassCount: 0,
newPassMutedCount: 0,
newManualCount: 0,
newManualMutedCount: 0,
changedFailCount: 0,
changedFailMutedCount: 0,
changedPassCount: 0,
changedPassMutedCount: 0,
changedManualCount: 0,
changedManualMutedCount: 0,
mutedCount: 0,
providers: ["aws"],
updatedAt: "2024-01-01T00:00:00Z",
@@ -325,3 +345,25 @@ describe("column-finding-groups — group selection", () => {
expect(screen.getByRole("checkbox", { name: "Select row" })).toBeDisabled();
});
});
describe("column-finding-groups — indicators", () => {
it("should prefer the new indicator when the new delta exists only in the breakdown fields", () => {
notificationIndicatorMock.mockClear();
renderSelectCell({
muted: true,
newCount: 0,
changedCount: 0,
newFailMutedCount: 1,
changedFailCount: 2,
});
expect(notificationIndicatorMock).toHaveBeenCalledWith(
expect.objectContaining({
delta: "new",
isMuted: true,
showDeltaWhenMuted: true,
}),
);
});
});

View File

@@ -10,6 +10,10 @@ import {
StatusFindingBadge,
} from "@/components/ui/table";
import { cn } from "@/lib";
import {
getFilteredFindingGroupDelta,
isFindingGroupMuted,
} from "@/lib/findings-groups";
import { FindingGroupRow, ProviderType } from "@/types";
import { DataTableRowActions } from "./data-table-row-actions";
@@ -25,6 +29,8 @@ interface GetColumnFindingGroupsOptions {
expandedCheckId?: string | null;
/** True when the expanded group has individually selected resources */
hasResourceSelection?: boolean;
/** Active URL filters — used to make the delta indicator status-aware */
filters?: Record<string, string | string[] | undefined>;
}
const VISIBLE_DISABLED_CHECKBOX_CLASS =
@@ -36,6 +42,7 @@ export function getColumnFindingGroups({
onDrillDown,
expandedCheckId,
hasResourceSelection = false,
filters = {},
}: GetColumnFindingGroupsOptions): ColumnDef<FindingGroupRow>[] {
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
const isAllSelected =
@@ -74,14 +81,13 @@ export function getColumnFindingGroups({
},
cell: ({ row }) => {
const group = row.original;
const allMuted =
group.mutedCount > 0 && group.mutedCount === group.resourcesTotal;
const allMuted = isFindingGroupMuted(group);
const isExpanded = expandedCheckId === group.checkId;
const deltaKey = getFilteredFindingGroupDelta(group, filters);
const delta =
group.newCount > 0
deltaKey === "new"
? DeltaValues.NEW
: group.changedCount > 0
: deltaKey === "changed"
? DeltaValues.CHANGED
: DeltaValues.NONE;
@@ -89,12 +95,17 @@ export function getColumnFindingGroups({
const canSelect = canMuteFindingGroup({
resourcesFail: group.resourcesFail,
resourcesTotal: group.resourcesTotal,
muted: group.muted,
mutedCount: group.mutedCount,
});
return (
<div className="flex items-center gap-2">
<NotificationIndicator delta={delta} isMuted={allMuted} />
<NotificationIndicator
delta={delta}
isMuted={allMuted}
showDeltaWhenMuted
/>
{canExpand ? (
<button
type="button"
@@ -149,9 +160,7 @@ export function getColumnFindingGroups({
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const rawStatus = row.original.status as string;
const status = rawStatus === "MUTED" ? "FAIL" : row.original.status;
return <StatusFindingBadge status={status} />;
return <StatusFindingBadge status={row.original.status} />;
},
enableSorting: false,
},

View File

@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { InputHTMLAttributes, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
@@ -26,7 +27,19 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
SendToJiraModal: ({
findingId,
isOpen,
}: {
findingId: string;
isOpen: boolean;
}) => (
<div
data-testid="jira-modal"
data-finding-id={findingId}
data-open={isOpen ? "true" : "false"}
/>
),
}));
vi.mock("@/components/icons/services/IconServices", () => ({
@@ -37,8 +50,18 @@ vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ActionDropdownItem: ({ label }: { label: string }) => (
<button>{label}</button>
ActionDropdownItem: ({
label,
onSelect,
disabled,
}: {
label: string;
onSelect?: () => void;
disabled?: boolean;
}) => (
<button disabled={disabled} onClick={onSelect}>
{label}
</button>
),
}));
@@ -200,4 +223,50 @@ describe("column-finding-resources", () => {
expect(screen.getByText("my-bucket")).toBeInTheDocument();
expect(screen.getByText("arn:aws:s3:::my-bucket")).toBeInTheDocument();
});
it("should open Send to Jira modal with finding UUID directly", async () => {
// Given
const user = userEvent.setup();
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const actionColumn = columns.find(
(col) => (col as { id?: string }).id === "actions",
);
if (!actionColumn?.cell) {
throw new Error("actions column not found");
}
const CellComponent = actionColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
render(
<div>
{CellComponent({
row: {
original: makeResource({
findingId: "real-finding-uuid",
}),
},
})}
</div>,
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
expect(screen.getByTestId("jira-modal")).toHaveAttribute(
"data-finding-id",
"real-finding-uuid",
);
expect(screen.getByTestId("jira-modal")).toHaveAttribute(
"data-open",
"true",
);
});
});

View File

@@ -66,6 +66,14 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
const handleMuteClick = async () => {
const displayIds = getDisplayIds();
// Single resource: findingId is already a real finding UUID
if (displayIds.length === 1) {
setResolvedIds(displayIds);
setIsMuteModalOpen(true);
return;
}
// Multi-select: resolve through context
if (resolveMuteIds) {
setIsResolving(true);
const ids = await resolveMuteIds(displayIds);
@@ -179,6 +187,7 @@ export function getColumnFindingResources({
delta={row.original.delta as DeltaType | undefined}
isMuted={row.original.isMuted}
mutedReason={row.original.mutedReason}
showDeltaWhenMuted
/>
<CornerDownRight className="text-text-neutral-tertiary h-4 w-4 shrink-0" />
<Checkbox
@@ -218,14 +227,9 @@ export function getColumnFindingResources({
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const rawStatus = row.original.status;
const status: FindingStatus =
rawStatus === "MUTED" || rawStatus === "FAIL"
? "FAIL"
: rawStatus === "PASS"
? "PASS"
: "FAIL";
return <StatusFindingBadge status={status} />;
return (
<StatusFindingBadge status={row.original.status as FindingStatus} />
);
},
enableSorting: false,
},

View File

@@ -97,6 +97,7 @@ export function getColumnFindings(
delta={delta}
isMuted={isMuted}
mutedReason={mutedReason}
showDeltaWhenMuted
/>
);
},

View File

@@ -0,0 +1,182 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { MuteFindingsModalMock } = vi.hoisted(() => ({
MuteFindingsModalMock: vi.fn(() => null),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));
vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: MuteFindingsModalMock,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/icons/services/IconServices", () => ({
JiraIcon: () => null,
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
ActionDropdownItem: ({
label,
onSelect,
disabled,
}: {
label: string;
onSelect: () => void;
disabled?: boolean;
}) => (
<button onClick={onSelect} disabled={disabled}>
{label}
</button>
),
}));
vi.mock("@/components/shadcn/spinner/spinner", () => ({
Spinner: () => <span>Loading</span>,
}));
import { DataTableRowActions } from "./data-table-row-actions";
import { FindingsSelectionContext } from "./findings-selection-context";
function deferredPromise<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("DataTableRowActions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("opens the mute modal immediately in preparing state for finding groups", async () => {
// Given
const deferred = deferredPromise<string[]>();
const resolveMuteIds = vi.fn().mockReturnValue(deferred.promise);
const user = userEvent.setup();
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: [],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds,
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-1",
rowType: "group",
checkId: "ecs_task_definitions_no_environment_secrets",
checkTitle: "ECS task definitions no environment secrets",
mutedCount: 0,
resourcesFail: 475,
resourcesTotal: 475,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
// When
await user.click(
screen.getByRole("button", { name: "Mute Finding Group" }),
);
// Then
const preparingCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[
{
isOpen: boolean;
isPreparing?: boolean;
findingIds: string[];
},
]
>
).at(-1);
expect(preparingCall?.[0]).toMatchObject({
isOpen: true,
isPreparing: true,
findingIds: [],
});
// And when the resolver finishes
deferred.resolve(["finding-1", "finding-2"]);
await waitFor(() => {
const resolvedCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[
{
isOpen: boolean;
isPreparing?: boolean;
findingIds: string[];
},
]
>
).at(-1);
expect(resolvedCall?.[0]).toMatchObject({
isOpen: true,
isPreparing: false,
findingIds: ["finding-1", "finding-2"],
});
});
});
it("disables the mute action for groups without impacted resources", () => {
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: [],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds: vi.fn(),
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-2",
rowType: "group",
checkId: "check-with-zero-failures",
checkTitle: "Check with zero failures",
mutedCount: 0,
resourcesFail: 0,
resourcesTotal: 42,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
expect(
screen.getByRole("button", { name: "Mute Finding Group" }),
).toBeDisabled();
});
});

View File

@@ -13,7 +13,9 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { isFindingGroupMuted } from "@/lib/findings-groups";
import { canMuteFindingGroup } from "./finding-group-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
export interface FindingRowData {
@@ -28,7 +30,9 @@ export interface FindingRowData {
rowType?: string;
checkId?: string;
checkTitle?: string;
muted?: boolean;
mutedCount?: number;
resourcesFail?: number;
resourcesTotal?: number;
}
@@ -38,15 +42,27 @@ export interface FindingRowData {
*/
function extractRowInfo(data: FindingRowData) {
if (data.rowType === "group") {
const allMuted =
(data.mutedCount ?? 0) > 0 && data.mutedCount === data.resourcesTotal;
const isMuted = isFindingGroupMuted({
muted: data.muted,
mutedCount: data.mutedCount ?? 0,
resourcesFail: data.resourcesFail ?? 0,
resourcesTotal: data.resourcesTotal ?? 0,
});
return {
isMuted: allMuted,
isMuted,
canMute: canMuteFindingGroup({
resourcesFail: data.resourcesFail ?? 0,
resourcesTotal: data.resourcesTotal ?? 0,
muted: data.muted,
mutedCount: data.mutedCount ?? 0,
}),
title: data.checkTitle || "Security Finding",
};
}
return {
isMuted: data.attributes?.muted ?? false,
canMute: !(data.attributes?.muted ?? false),
title: data.attributes?.check_metadata?.checktitle || "Security Finding",
};
}
@@ -64,8 +80,12 @@ export function DataTableRowActions<T extends FindingRowData>({
const finding = row.original;
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
const [mutePreparationError, setMutePreparationError] = useState<
string | null
>(null);
const { isMuted, title: findingTitle } = extractRowInfo(finding);
const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding);
// Get selection context - if there are other selected rows, include them
const selectionContext = useContext(FindingsSelectionContext);
@@ -103,15 +123,45 @@ export function DataTableRowActions<T extends FindingRowData>({
return isGroup ? "Mute Finding Group" : "Mute Finding";
};
const handleMuteModalOpenChange = (
nextOpen: boolean | ((previousOpen: boolean) => boolean),
) => {
const resolvedOpen =
typeof nextOpen === "function" ? nextOpen(isMuteModalOpen) : nextOpen;
setIsMuteModalOpen(resolvedOpen);
if (!resolvedOpen) {
setIsPreparingMuteModal(false);
setMutePreparationError(null);
setResolvedIds([]);
}
};
const handleMuteClick = async () => {
const displayIds = getDisplayIds();
if (resolveMuteIds) {
setResolvedIds([]);
setMutePreparationError(null);
setIsPreparingMuteModal(true);
setIsMuteModalOpen(true);
setIsResolving(true);
const ids = await resolveMuteIds(displayIds);
setResolvedIds(ids);
setIsResolving(false);
if (ids.length > 0) setIsMuteModalOpen(true);
try {
const ids = await resolveMuteIds(displayIds);
setResolvedIds(ids);
setMutePreparationError(
ids.length === 0
? "No findings could be resolved for this group. Try refreshing the page and trying again."
: null,
);
} catch {
setMutePreparationError(
"We couldn't prepare this mute action. Please try again.",
);
} finally {
setIsPreparingMuteModal(false);
setIsResolving(false);
}
} else {
// Regular findings — IDs are already valid finding UUIDs
setResolvedIds(displayIds);
@@ -146,10 +196,12 @@ export function DataTableRowActions<T extends FindingRowData>({
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
onOpenChange={handleMuteModalOpenChange}
findingIds={resolvedIds}
onComplete={handleMuteComplete}
isBulkOperation={finding.rowType === "group"}
isPreparing={isPreparingMuteModal}
preparationError={mutePreparationError}
/>
<div className="flex items-center justify-end">
@@ -165,7 +217,7 @@ export function DataTableRowActions<T extends FindingRowData>({
)
}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={isMuted || isResolving}
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
{!isGroup && (

View File

@@ -7,23 +7,21 @@ describe("canMuteFindingGroup", () => {
expect(
canMuteFindingGroup({
resourcesFail: 0,
resourcesTotal: 2,
mutedCount: 0,
muted: false,
}),
).toBe(false);
});
it("returns false when all resources are already muted", () => {
it("returns false when the explicit muted flag marks the group as fully muted", () => {
expect(
canMuteFindingGroup({
resourcesFail: 3,
resourcesTotal: 3,
mutedCount: 3,
muted: true,
}),
).toBe(false);
});
it("returns false when all failing resources are muted even if PASS resources exist", () => {
it("returns false when legacy counters indicate all failing resources are muted", () => {
expect(
canMuteFindingGroup({
resourcesFail: 2,
@@ -37,8 +35,7 @@ describe("canMuteFindingGroup", () => {
expect(
canMuteFindingGroup({
resourcesFail: 2,
resourcesTotal: 5,
mutedCount: 1,
muted: false,
}),
).toBe(true);
});

View File

@@ -1,13 +1,25 @@
import { isFindingGroupMuted } from "@/lib/findings-groups";
interface FindingGroupSelectionState {
resourcesFail: number;
resourcesTotal: number;
mutedCount: number;
resourcesTotal?: number;
muted?: boolean;
mutedCount?: number;
}
export function canMuteFindingGroup({
resourcesFail,
muted,
mutedCount,
resourcesTotal,
}: FindingGroupSelectionState): boolean {
const allMuted = mutedCount > 0 && mutedCount === resourcesFail;
return resourcesFail > 0 && !allMuted;
return (
resourcesFail > 0 &&
!isFindingGroupMuted({
muted,
mutedCount: mutedCount ?? 0,
resourcesFail,
resourcesTotal: resourcesTotal ?? 0,
})
);
}

View File

@@ -11,7 +11,6 @@ import { ChevronLeft } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import {
Table,
@@ -24,6 +23,10 @@ import {
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
import { cn, hasDateOrScanFilter } from "@/lib";
import {
getFilteredFindingGroupDelta,
isFindingGroupMuted,
} from "@/lib/findings-groups";
import { FindingGroupRow, FindingResourceRow } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
@@ -111,18 +114,9 @@ export function FindingsGroupDrillDown({
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter((id): id is string => id !== null && id !== undefined && id !== "");
/** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */
/** findingId values are already real finding UUIDs — no resolution needed. */
const resolveResourceIds = async (ids: string[]) => {
const resourceUids = ids
.map((id) => resources.find((r) => r.findingId === id)?.resourceUid)
.filter(Boolean) as string[];
if (resourceUids.length === 0) return [];
return resolveFindingIds({
checkId: group.checkId,
resourceUids,
filters,
hasDateOrScanFilter: hasDateOrScan,
});
return ids.filter(Boolean);
};
const selectableRowCount = resources.filter(canMuteFindingResource).length;
@@ -162,15 +156,15 @@ export function FindingsGroupDrillDown({
});
// Delta for the sticky header
const deltaKey = getFilteredFindingGroupDelta(group, filters);
const delta =
group.newCount > 0
deltaKey === "new"
? DeltaValues.NEW
: group.changedCount > 0
: deltaKey === "changed"
? DeltaValues.CHANGED
: DeltaValues.NONE;
const allMuted =
group.mutedCount > 0 && group.mutedCount === group.resourcesTotal;
const allMuted = isFindingGroupMuted(group);
const rows = table.getRowModel().rows;
@@ -205,7 +199,11 @@ export function FindingsGroupDrillDown({
</button>
{/* Notification indicator */}
<NotificationIndicator delta={delta} isMuted={allMuted} />
<NotificationIndicator
delta={delta}
isMuted={allMuted}
showDeltaWhenMuted
/>
{/* Status badge */}
<StatusFindingBadge status={group.status} />

View File

@@ -4,10 +4,7 @@ import { Row, RowSelectionState } from "@tanstack/react-table";
import { useRouter, useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
import {
resolveFindingIds,
resolveFindingIdsByVisibleGroupResources,
} from "@/actions/findings/findings-by-resource";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
import { DataTable } from "@/components/ui/table";
import { hasDateOrScanFilter } from "@/lib";
import { FindingGroupRow, MetaDataProps } from "@/types";
@@ -93,6 +90,7 @@ export function FindingsGroupTable({
canMuteFindingGroup({
resourcesFail: g.resourcesFail,
resourcesTotal: g.resourcesTotal,
muted: g.muted,
mutedCount: g.mutedCount,
}),
).length;
@@ -102,6 +100,7 @@ export function FindingsGroupTable({
return canMuteFindingGroup({
resourcesFail: group.resourcesFail,
resourcesTotal: group.resourcesTotal,
muted: group.muted,
mutedCount: group.mutedCount,
});
};
@@ -174,6 +173,7 @@ export function FindingsGroupTable({
onDrillDown: handleDrillDown,
expandedCheckId,
hasResourceSelection,
filters,
});
const renderAfterRow = (row: Row<FindingGroupRow>) => {
@@ -238,14 +238,8 @@ export function FindingsGroupTable({
selectedCheckIds.length > 0
? resolveGroupMuteIds(selectedCheckIds)
: Promise.resolve([]),
hasResourceSelection && expandedCheckId
? resolveFindingIds({
checkId: expandedCheckId,
resourceUids: resourceSelection,
filters,
hasDateOrScanFilter: hasDateOrScan,
})
: Promise.resolve([]),
// resourceSelection already contains real finding UUIDs
Promise.resolve(hasResourceSelection ? resourceSelection : []),
]);
return [...groupIds, ...resourceIds];
}}

View File

@@ -12,7 +12,6 @@ import { ChevronsDown } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useImperativeHandle, useRef, useState } from "react";
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TableCell, TableRow } from "@/components/ui/table";
@@ -24,6 +23,10 @@ import { FindingGroupRow, FindingResourceRow } from "@/types";
import { getColumnFindingResources } from "./column-finding-resources";
import { canMuteFindingResource } from "./finding-resource-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
getFilteredFindingGroupResourceCount,
getFindingGroupSkeletonCount,
} from "./inline-resource-container.utils";
import {
ResourceDetailDrawer,
useResourceDetailDrawer,
@@ -40,8 +43,8 @@ interface InlineResourceContainerProps {
group: FindingGroupRow;
resourceSearch: string;
columnCount: number;
/** Called with selected resource UIDs (not finding IDs) for parent-level mute resolution */
onResourceSelectionChange: (resourceUids: string[]) => void;
/** Called with selected finding IDs (real UUIDs) for parent-level mute */
onResourceSelectionChange: (findingIds: string[]) => void;
ref?: React.Ref<InlineResourceContainerHandle>;
}
@@ -55,11 +58,17 @@ interface InlineResourceContainerProps {
/** Max skeleton rows that fit in the 440px scroll container */
const MAX_SKELETON_ROWS = 7;
function ResourceSkeletonRow() {
function ResourceSkeletonRow({
isEmptyStateSized = false,
}: {
isEmptyStateSized?: boolean;
}) {
const cellClassName = isEmptyStateSized ? "h-24 py-3" : "py-3";
return (
<TableRow className="hover:bg-transparent">
{/* Select: indicator + corner arrow + checkbox */}
<TableCell>
<TableCell className={cellClassName}>
<div className="flex items-center gap-2">
<Skeleton className="size-1.5 rounded-full" />
<Skeleton className="size-4 rounded" />
@@ -67,55 +76,55 @@ function ResourceSkeletonRow() {
</div>
</TableCell>
{/* Resource: icon + name + uid */}
<TableCell>
<TableCell className={cellClassName}>
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<div className="space-y-1">
<Skeleton className="h-3.5 w-32 rounded" />
<Skeleton className="h-3 w-20 rounded" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-3.5 w-20 rounded" />
</div>
</div>
</TableCell>
{/* Status */}
<TableCell>
<TableCell className={cellClassName}>
<Skeleton className="h-6 w-11 rounded-md" />
</TableCell>
{/* Service */}
<TableCell>
<Skeleton className="h-4 w-16 rounded" />
<TableCell className={cellClassName}>
<Skeleton className="h-4.5 w-16 rounded" />
</TableCell>
{/* Region */}
<TableCell>
<Skeleton className="h-4 w-20 rounded" />
<TableCell className={cellClassName}>
<Skeleton className="h-4.5 w-20 rounded" />
</TableCell>
{/* Severity */}
<TableCell>
<TableCell className={cellClassName}>
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full" />
<Skeleton className="h-4 w-12 rounded" />
<Skeleton className="h-4.5 w-12 rounded" />
</div>
</TableCell>
{/* Account: provider icon + alias + uid */}
<TableCell>
<TableCell className={cellClassName}>
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<div className="space-y-1">
<Skeleton className="h-3.5 w-24 rounded" />
<Skeleton className="h-3 w-16 rounded" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-3.5 w-16 rounded" />
</div>
</div>
</TableCell>
{/* Last seen */}
<TableCell>
<Skeleton className="h-4 w-24 rounded" />
<TableCell className={cellClassName}>
<Skeleton className="h-4.5 w-24 rounded" />
</TableCell>
{/* Failing for */}
<TableCell>
<Skeleton className="h-4 w-16 rounded" />
<TableCell className={cellClassName}>
<Skeleton className="h-4.5 w-16 rounded" />
</TableCell>
{/* Actions */}
<TableCell>
<Skeleton className="size-6 rounded" />
<TableCell className={cellClassName}>
<Skeleton className="size-8 rounded-md" />
</TableCell>
</TableRow>
);
@@ -161,6 +170,16 @@ export function InlineResourceContainer({
filters["filter[name__icontains]"] = resourceSearch;
}
const skeletonRowCount = getFindingGroupSkeletonCount(
group,
filters,
MAX_SKELETON_ROWS,
);
const filteredResourceCount = getFilteredFindingGroupResourceCount(
group,
filters,
);
const handleSetResources = (
newResources: FindingResourceRow[],
_hasMore: boolean,
@@ -211,16 +230,9 @@ export function InlineResourceContainer({
.filter(Boolean);
const resolveResourceIds = async (ids: string[]) => {
const resourceUids = ids
.map((id) => resources.find((r) => r.findingId === id)?.resourceUid)
.filter(Boolean) as string[];
if (resourceUids.length === 0) return [];
return resolveFindingIds({
checkId: group.checkId,
resourceUids,
filters,
hasDateOrScanFilter: hasDateOrScan,
});
// findingId values are already real finding UUIDs (from the group
// resources endpoint), so no second resolution round-trip is needed.
return ids.filter(Boolean);
};
const selectableRowCount = resources.filter(canMuteFindingResource).length;
@@ -254,11 +266,11 @@ export function InlineResourceContainer({
typeof updater === "function" ? updater(rowSelection) : updater;
setRowSelection(newSelection);
const newResourceUids = Object.keys(newSelection)
const newFindingIds = Object.keys(newSelection)
.filter((key) => newSelection[key])
.map((idx) => resources[parseInt(idx)]?.resourceUid)
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter(Boolean);
onResourceSelectionChange(newResourceUids);
onResourceSelectionChange(newFindingIds);
};
const columns = getColumnFindingResources({
@@ -310,12 +322,12 @@ export function InlineResourceContainer({
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<tbody>
{isLoading && rows.length === 0 ? (
Array.from({
length: Math.min(
group.resourcesTotal,
MAX_SKELETON_ROWS,
),
}).map((_, i) => <ResourceSkeletonRow key={i} />)
Array.from({ length: skeletonRowCount }).map((_, i) => (
<ResourceSkeletonRow
key={i}
isEmptyStateSized={filteredResourceCount === 0}
/>
))
) : rows.length > 0 ? (
rows.map((row) => (
<TableRow

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { FindingGroupRow } from "@/types";
import {
getFilteredFindingGroupResourceCount,
getFindingGroupSkeletonCount,
isFailOnlyStatusFilter,
} from "./inline-resource-container.utils";
function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
return {
id: "group-1",
rowType: "group",
checkId: "check-1",
checkTitle: "Test finding group",
severity: "high",
status: "FAIL",
resourcesTotal: 20,
resourcesFail: 6,
newCount: 0,
changedCount: 0,
mutedCount: 0,
providers: ["aws"],
updatedAt: "2026-04-09T00:00:00Z",
...overrides,
};
}
describe("isFailOnlyStatusFilter", () => {
it("returns true when filter[status__in] only contains FAIL", () => {
expect(
isFailOnlyStatusFilter({
"filter[status__in]": "FAIL",
}),
).toBe(true);
});
it("returns false when filter[status__in] includes more than FAIL", () => {
expect(
isFailOnlyStatusFilter({
"filter[status__in]": "FAIL,PASS",
}),
).toBe(false);
});
it("returns true when filter[status] is FAIL", () => {
expect(
isFailOnlyStatusFilter({
"filter[status]": "FAIL",
}),
).toBe(true);
});
});
describe("getFilteredFindingGroupResourceCount", () => {
it("returns zero filtered resources when FAIL is the only active status and none fail", () => {
expect(
getFilteredFindingGroupResourceCount(
makeGroup({ resourcesFail: 0, resourcesTotal: 4 }),
{ "filter[status__in]": "FAIL" },
),
).toBe(0);
});
});
describe("getFindingGroupSkeletonCount", () => {
it("uses the total resource count when FAIL is not the only active status", () => {
expect(getFindingGroupSkeletonCount(makeGroup(), {}, 7)).toBe(7);
});
it("uses the failing resource count when FAIL is the only active status", () => {
expect(
getFindingGroupSkeletonCount(
makeGroup(),
{ "filter[status__in]": "FAIL" },
7,
),
).toBe(6);
});
it("still caps the skeleton count to the configured maximum", () => {
expect(
getFindingGroupSkeletonCount(
makeGroup({ resourcesFail: 15 }),
{ "filter[status__in]": "FAIL" },
7,
),
).toBe(7);
});
it("reserves one skeleton row when the filtered resource count is zero", () => {
expect(
getFindingGroupSkeletonCount(
makeGroup({ resourcesFail: 0, resourcesTotal: 0 }),
{ "filter[status__in]": "FAIL" },
7,
),
).toBe(1);
});
});

View File

@@ -0,0 +1,55 @@
import { FindingGroupRow } from "@/types";
function parseStatusFilterValue(statusFilterValue?: string): string[] {
if (!statusFilterValue) {
return [];
}
return statusFilterValue
.split(",")
.map((status) => status.trim().toUpperCase())
.filter(Boolean);
}
export function isFailOnlyStatusFilter(
filters: Record<string, string | string[] | undefined>,
): boolean {
const directStatusValues = parseStatusFilterValue(
typeof filters["filter[status]"] === "string"
? filters["filter[status]"]
: undefined,
);
if (directStatusValues.length > 0) {
return directStatusValues.length === 1 && directStatusValues[0] === "FAIL";
}
const multiStatusValues = parseStatusFilterValue(
typeof filters["filter[status__in]"] === "string"
? filters["filter[status__in]"]
: undefined,
);
return multiStatusValues.length === 1 && multiStatusValues[0] === "FAIL";
}
export function getFilteredFindingGroupResourceCount(
group: FindingGroupRow,
filters: Record<string, string | string[] | undefined>,
): number {
return isFailOnlyStatusFilter(filters)
? group.resourcesFail
: group.resourcesTotal;
}
export function getFindingGroupSkeletonCount(
group: FindingGroupRow,
filters: Record<string, string | string[] | undefined>,
maxSkeletonRows: number,
): number {
const filteredTotal = getFilteredFindingGroupResourceCount(group, filters);
// Reserve at least one row so the drill-down keeps visual space while the
// empty state ("No resources found") replaces the skeleton.
return Math.max(1, Math.min(filteredTotal, maxSkeletonRows));
}

View File

@@ -30,14 +30,26 @@ interface NotificationIndicatorProps {
delta?: DeltaType;
isMuted?: boolean;
mutedReason?: string;
showDeltaWhenMuted?: boolean;
}
export const NotificationIndicator = ({
delta,
isMuted = false,
mutedReason,
showDeltaWhenMuted = false,
}: NotificationIndicatorProps) => {
// Muted takes precedence over delta.
const hasDelta = delta === DeltaValues.NEW || delta === DeltaValues.CHANGED;
if (isMuted && hasDelta && showDeltaWhenMuted) {
return (
<div className="flex shrink-0 items-center gap-1">
<MutedIndicator mutedReason={mutedReason} />
<DeltaIndicator delta={delta} />
</div>
);
}
// Uses Popover (not Tooltip) because the content has an interactive link.
// Radix Tooltip does not support interactive content — clicks fall through.
if (isMuted) {
@@ -45,56 +57,64 @@ export const NotificationIndicator = ({
}
// Show dot with tooltip for new or changed findings
if (delta === DeltaValues.NEW || delta === DeltaValues.CHANGED) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
>
<div
className={cn(
"size-1.5 rounded-full",
delta === DeltaValues.NEW
? "bg-system-severity-high"
: "bg-system-severity-low",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<div className="flex items-center gap-1 text-xs">
<span>
{delta === DeltaValues.NEW
? "New finding."
: "Status changed since the previous scan."}
</span>
<Button
aria-label="Learn more about findings"
variant="link"
size="default"
className="text-button-primary h-auto min-w-0 p-0 text-xs"
asChild
>
<a
href={DOCS_URLS.FINDINGS_ANALYSIS}
target="_blank"
rel="noopener noreferrer"
>
Learn more
</a>
</Button>
</div>
</TooltipContent>
</Tooltip>
);
if (hasDelta) {
return <DeltaIndicator delta={delta} />;
}
// No indicator - return minimal width placeholder
return <div className="w-2 shrink-0" />;
};
function DeltaIndicator({
delta,
}: {
delta: typeof DeltaValues.NEW | typeof DeltaValues.CHANGED;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
>
<div
className={cn(
"size-1.5 rounded-full",
delta === DeltaValues.NEW
? "bg-system-severity-high"
: "bg-system-severity-low",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<div className="flex items-center gap-1 text-xs">
<span>
{delta === DeltaValues.NEW
? "New finding."
: "Status changed since the previous scan."}
</span>
<Button
aria-label="Learn more about findings"
variant="link"
size="default"
className="text-button-primary h-auto min-w-0 p-0 text-xs"
asChild
>
<a
href={DOCS_URLS.FINDINGS_ANALYSIS}
target="_blank"
rel="noopener noreferrer"
>
Learn more
</a>
</Button>
</div>
</TooltipContent>
</Tooltip>
);
}
/** Muted indicator with hover-triggered Popover for interactive link. */
function MutedIndicator({ mutedReason }: { mutedReason?: string }) {
const [open, setOpen] = useState(false);

View File

@@ -710,7 +710,7 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
scanId: "scan-from-finding",
});
expect(mockWindowOpen).toHaveBeenCalledWith(
"/compliance/PCI-DSS?complianceId=compliance-2&version=4.0&scanId=scan-from-finding&scanData=%7B%22id%22%3A%22scan-from-finding%22%2C%22providerInfo%22%3A%7B%22provider%22%3A%22aws%22%2C%22alias%22%3A%22prod%22%2C%22uid%22%3A%22123456789%22%7D%2C%22attributes%22%3A%7B%22name%22%3A%22Nightly+scan%22%2C%22completed_at%22%3A%222026-03-30T10%3A05%3A00Z%22%7D%7D",
"/compliance/PCI-DSS?complianceId=compliance-2&version=4.0&scanId=scan-from-finding",
"_blank",
"noopener,noreferrer",
);
@@ -786,7 +786,7 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
scanId: "scan-from-finding",
});
expect(mockWindowOpen).toHaveBeenCalledWith(
"/compliance/KISA-ISMS-P?complianceId=compliance-kisa&version=1.0&scanId=scan-from-finding&scanData=%7B%22id%22%3A%22scan-from-finding%22%2C%22providerInfo%22%3A%7B%22provider%22%3A%22aws%22%2C%22alias%22%3A%22prod%22%2C%22uid%22%3A%22123456789%22%7D%2C%22attributes%22%3A%7B%22name%22%3A%22Nightly+scan%22%2C%22completed_at%22%3A%222026-03-30T10%3A05%3A00Z%22%7D%7D",
"/compliance/KISA-ISMS-P?complianceId=compliance-kisa&version=1.0&scanId=scan-from-finding",
"_blank",
"noopener,noreferrer",
);

View File

@@ -218,16 +218,12 @@ function buildComplianceDetailHref({
version,
scanId,
regionFilter,
currentFinding,
includeScanData,
}: {
complianceId: string;
framework: string;
version: string;
scanId: string;
regionFilter: string | null;
currentFinding: ResourceDrawerFinding | null;
includeScanData: boolean;
}): string {
const params = new URLSearchParams();
params.set("complianceId", complianceId);
@@ -240,24 +236,6 @@ function buildComplianceDetailHref({
params.set("filter[region__in]", regionFilter);
}
if (includeScanData && currentFinding?.scan?.completedAt) {
params.set(
"scanData",
JSON.stringify({
id: currentFinding.scan.id,
providerInfo: {
provider: currentFinding.providerType,
alias: currentFinding.providerAlias,
uid: currentFinding.providerUid,
},
attributes: {
name: currentFinding.scan.name,
completed_at: currentFinding.scan.completedAt,
},
}),
);
}
return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`;
}
@@ -377,8 +355,6 @@ export function ResourceDetailDrawerContent({
version: complianceMatch.version,
scanId: complianceScanId,
regionFilter,
currentFinding: f,
includeScanData: f?.scan?.id === complianceScanId,
}),
"_blank",
"noopener,noreferrer",

View File

@@ -54,6 +54,7 @@ export const getResourceFindingsColumns = (
delta={row.original.attributes.delta}
isMuted={row.original.attributes.muted}
mutedReason={row.original.attributes.muted_reason}
showDeltaWhenMuted
/>
</div>
),

View File

@@ -6,6 +6,8 @@ export const DOCS_URLS = {
"https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings",
AWS_ORGANIZATIONS:
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
ATTACK_PATHS_CUSTOM_QUERIES:
"https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries",
} as const;
// CloudFormation template URL for the ProwlerScan role.

View File

@@ -0,0 +1,238 @@
import { describe, expect, it } from "vitest";
import type { FindingGroupRow } from "@/types";
import {
getActiveStatusFilter,
getFilteredFindingGroupDelta,
getFindingGroupDelta,
isFindingGroupMuted,
} from "./findings-groups";
function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
return {
id: "group-1",
rowType: "group",
checkId: "check-1",
checkTitle: "Test finding group",
severity: "high",
status: "FAIL",
muted: false,
resourcesTotal: 5,
resourcesFail: 3,
newCount: 0,
changedCount: 0,
mutedCount: 0,
providers: ["aws"],
updatedAt: "2026-04-10T00:00:00Z",
...overrides,
};
}
describe("isFindingGroupMuted", () => {
it("should prefer the explicit muted flag from the API", () => {
expect(
isFindingGroupMuted(
makeGroup({
muted: true,
mutedCount: 0,
resourcesFail: 3,
resourcesTotal: 5,
}),
),
).toBe(true);
});
it("should fall back to legacy counters when muted is not available", () => {
expect(
isFindingGroupMuted(
makeGroup({
muted: undefined,
mutedCount: 3,
resourcesFail: 3,
}),
),
).toBe(true);
});
});
describe("getFindingGroupDelta", () => {
it("should return new when the muted breakdown contains new findings", () => {
expect(
getFindingGroupDelta(
makeGroup({
newCount: 0,
changedCount: 0,
newFailMutedCount: 1,
}),
),
).toBe("new");
});
it("should return changed when only changed breakdown counters are present", () => {
expect(
getFindingGroupDelta(
makeGroup({
newCount: 0,
changedCount: 0,
changedManualMutedCount: 2,
}),
),
).toBe("changed");
});
it("should prioritize new over changed when both breakdowns are present", () => {
expect(
getFindingGroupDelta(
makeGroup({
newCount: 0,
changedCount: 0,
newPassCount: 1,
changedFailCount: 3,
}),
),
).toBe("new");
});
it("should fall back to legacy counters when breakdowns are missing", () => {
expect(
getFindingGroupDelta(
makeGroup({
newCount: 1,
changedCount: 0,
}),
),
).toBe("new");
});
});
describe("getActiveStatusFilter", () => {
it("returns null when no status filter is active", () => {
expect(getActiveStatusFilter({})).toBeNull();
});
it("returns the single value from filter[status]", () => {
const result = getActiveStatusFilter({ "filter[status]": "FAIL" });
expect(result).toEqual(new Set(["FAIL"]));
});
it("returns the parsed set from filter[status__in]", () => {
const result = getActiveStatusFilter({
"filter[status__in]": "FAIL,MANUAL",
});
expect(result).toEqual(new Set(["FAIL", "MANUAL"]));
});
it("prefers filter[status] over filter[status__in] when both are present", () => {
const result = getActiveStatusFilter({
"filter[status]": "PASS",
"filter[status__in]": "FAIL,MANUAL",
});
expect(result).toEqual(new Set(["PASS"]));
});
it("ignores unknown status values and returns null if nothing remains", () => {
expect(
getActiveStatusFilter({ "filter[status__in]": "UNKNOWN,FOO" }),
).toBeNull();
});
});
describe("getFilteredFindingGroupDelta", () => {
it("falls back to the aggregate delta when no status filter is active", () => {
expect(
getFilteredFindingGroupDelta(
makeGroup({
newPassCount: 2,
}),
{},
),
).toBe("new");
});
it("ignores deltas that belong to filtered-out statuses", () => {
// Filter is FAIL, but the only delta is a new PASS → should be hidden.
expect(
getFilteredFindingGroupDelta(
makeGroup({
newPassCount: 3,
}),
{ "filter[status__in]": "FAIL" },
),
).toBe("none");
});
it("surfaces FAIL deltas when the filter is FAIL", () => {
expect(
getFilteredFindingGroupDelta(
makeGroup({
newFailCount: 1,
}),
{ "filter[status]": "FAIL" },
),
).toBe("new");
});
it("counts muted breakdown counters towards the filtered status", () => {
// A muted new FAIL still belongs to the FAIL bucket — a FAIL filter
// should still light up the "new" indicator.
expect(
getFilteredFindingGroupDelta(
makeGroup({
newFailMutedCount: 1,
}),
{ "filter[status]": "FAIL" },
),
).toBe("new");
});
it("sums multiple filtered statuses from filter[status__in]", () => {
// Filter is FAIL+MANUAL, new delta is only in MANUAL → should still show.
expect(
getFilteredFindingGroupDelta(
makeGroup({
newManualCount: 1,
}),
{ "filter[status__in]": "FAIL,MANUAL" },
),
).toBe("new");
});
it("prefers new over changed within the filtered status", () => {
expect(
getFilteredFindingGroupDelta(
makeGroup({
newFailCount: 1,
changedFailCount: 2,
}),
{ "filter[status]": "FAIL" },
),
).toBe("new");
});
it("returns changed when only changed counters match the filtered status", () => {
expect(
getFilteredFindingGroupDelta(
makeGroup({
newPassCount: 2, // filtered out
changedFailCount: 1,
}),
{ "filter[status]": "FAIL" },
),
).toBe("changed");
});
it("falls back to the aggregate delta when breakdowns are missing (legacy API)", () => {
// No breakdown fields populated but legacy newCount is set. With a FAIL
// filter active we cannot know which status bucket it belongs to, so we
// fall back to showing the delta rather than silently hiding it.
expect(
getFilteredFindingGroupDelta(
makeGroup({
newCount: 1,
}),
{ "filter[status]": "FAIL" },
),
).toBe("new");
});
});

201
ui/lib/findings-groups.ts Normal file
View File

@@ -0,0 +1,201 @@
import type { FindingGroupRow } from "@/types";
type FindingGroupMutedState = Pick<
FindingGroupRow,
"muted" | "mutedCount" | "resourcesFail" | "resourcesTotal"
>;
type FindingGroupDeltaState = Pick<
FindingGroupRow,
| "newCount"
| "changedCount"
| "newFailCount"
| "newFailMutedCount"
| "newPassCount"
| "newPassMutedCount"
| "newManualCount"
| "newManualMutedCount"
| "changedFailCount"
| "changedFailMutedCount"
| "changedPassCount"
| "changedPassMutedCount"
| "changedManualCount"
| "changedManualMutedCount"
>;
export function isFindingGroupMuted(group: FindingGroupMutedState): boolean {
if (typeof group.muted === "boolean") {
return group.muted;
}
const mutedCount = group.mutedCount ?? 0;
if (mutedCount === 0) {
return false;
}
return (
mutedCount === group.resourcesFail || mutedCount === group.resourcesTotal
);
}
function getNewDeltaTotal(group: FindingGroupDeltaState): number {
const breakdownTotal =
(group.newFailCount ?? 0) +
(group.newFailMutedCount ?? 0) +
(group.newPassCount ?? 0) +
(group.newPassMutedCount ?? 0) +
(group.newManualCount ?? 0) +
(group.newManualMutedCount ?? 0);
return breakdownTotal > 0 ? breakdownTotal : group.newCount;
}
function getChangedDeltaTotal(group: FindingGroupDeltaState): number {
const breakdownTotal =
(group.changedFailCount ?? 0) +
(group.changedFailMutedCount ?? 0) +
(group.changedPassCount ?? 0) +
(group.changedPassMutedCount ?? 0) +
(group.changedManualCount ?? 0) +
(group.changedManualMutedCount ?? 0);
return breakdownTotal > 0 ? breakdownTotal : group.changedCount;
}
export function getFindingGroupDelta(
group: FindingGroupDeltaState,
): "new" | "changed" | "none" {
if (getNewDeltaTotal(group) > 0) {
return "new";
}
if (getChangedDeltaTotal(group) > 0) {
return "changed";
}
return "none";
}
const FINDING_GROUP_STATUSES = ["FAIL", "PASS", "MANUAL"] as const;
type FindingGroupStatus = (typeof FINDING_GROUP_STATUSES)[number];
type FindingGroupFiltersRecord = Record<string, string | string[] | undefined>;
function parseStatusFilterValue(
rawValue: string | string[] | undefined,
): FindingGroupStatus[] {
if (!rawValue) {
return [];
}
const joined = Array.isArray(rawValue) ? rawValue.join(",") : rawValue;
return joined
.split(",")
.map((status) => status.trim().toUpperCase())
.filter((status): status is FindingGroupStatus =>
(FINDING_GROUP_STATUSES as readonly string[]).includes(status),
);
}
/**
* Returns the set of statuses the user has explicitly narrowed the findings
* view to, or null when no status filter is active (→ all statuses should be
* considered). Supports both `filter[status]` (single value) and
* `filter[status__in]` (comma-separated values).
*/
export function getActiveStatusFilter(
filters: FindingGroupFiltersRecord,
): Set<FindingGroupStatus> | null {
const direct = parseStatusFilterValue(filters["filter[status]"]);
if (direct.length > 0) {
return new Set(direct);
}
const multi = parseStatusFilterValue(filters["filter[status__in]"]);
if (multi.length > 0) {
return new Set(multi);
}
return null;
}
function hasAnyDeltaBreakdown(group: FindingGroupDeltaState): boolean {
return (
(group.newFailCount ?? 0) > 0 ||
(group.newFailMutedCount ?? 0) > 0 ||
(group.newPassCount ?? 0) > 0 ||
(group.newPassMutedCount ?? 0) > 0 ||
(group.newManualCount ?? 0) > 0 ||
(group.newManualMutedCount ?? 0) > 0 ||
(group.changedFailCount ?? 0) > 0 ||
(group.changedFailMutedCount ?? 0) > 0 ||
(group.changedPassCount ?? 0) > 0 ||
(group.changedPassMutedCount ?? 0) > 0 ||
(group.changedManualCount ?? 0) > 0 ||
(group.changedManualMutedCount ?? 0) > 0
);
}
function getNewDeltaForStatuses(
group: FindingGroupDeltaState,
statuses: Set<FindingGroupStatus>,
): number {
let total = 0;
if (statuses.has("FAIL")) {
total += (group.newFailCount ?? 0) + (group.newFailMutedCount ?? 0);
}
if (statuses.has("PASS")) {
total += (group.newPassCount ?? 0) + (group.newPassMutedCount ?? 0);
}
if (statuses.has("MANUAL")) {
total += (group.newManualCount ?? 0) + (group.newManualMutedCount ?? 0);
}
return total;
}
function getChangedDeltaForStatuses(
group: FindingGroupDeltaState,
statuses: Set<FindingGroupStatus>,
): number {
let total = 0;
if (statuses.has("FAIL")) {
total += (group.changedFailCount ?? 0) + (group.changedFailMutedCount ?? 0);
}
if (statuses.has("PASS")) {
total += (group.changedPassCount ?? 0) + (group.changedPassMutedCount ?? 0);
}
if (statuses.has("MANUAL")) {
total +=
(group.changedManualCount ?? 0) + (group.changedManualMutedCount ?? 0);
}
return total;
}
/**
* Filter-aware variant of {@link getFindingGroupDelta}. When a status filter
* is active, only delta counters belonging to the filtered statuses contribute
* to the indicator. When no status filter is active, or when the API response
* lacks breakdown counters (legacy shape), this falls back to the aggregate
* delta so rows still surface deltas correctly.
*/
export function getFilteredFindingGroupDelta(
group: FindingGroupDeltaState,
filters: FindingGroupFiltersRecord,
): "new" | "changed" | "none" {
const activeStatuses = getActiveStatusFilter(filters);
if (!activeStatuses || !hasAnyDeltaBreakdown(group)) {
return getFindingGroupDelta(group);
}
if (getNewDeltaForStatuses(group, activeStatuses) > 0) {
return "new";
}
if (getChangedDeltaForStatuses(group, activeStatuses) > 0) {
return "changed";
}
return "none";
}

View File

@@ -16,10 +16,29 @@ export interface FindingGroupRow {
checkTitle: string;
severity: Severity;
status: FindingStatus;
muted?: boolean;
resourcesTotal: number;
resourcesFail: number;
passCount?: number;
failCount?: number;
manualCount?: number;
passMutedCount?: number;
failMutedCount?: number;
manualMutedCount?: number;
newCount: number;
changedCount: number;
newFailCount?: number;
newFailMutedCount?: number;
newPassCount?: number;
newPassMutedCount?: number;
newManualCount?: number;
newManualMutedCount?: number;
changedFailCount?: number;
changedFailMutedCount?: number;
changedPassCount?: number;
changedPassMutedCount?: number;
changedManualCount?: number;
changedManualMutedCount?: number;
mutedCount: number;
providers: ProviderType[];
updatedAt: string;