From f46b86101acf5cc49305724700ca56565ad00818 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:34:25 +0200 Subject: [PATCH] feat(ui): add issues views using the shared design system (#13068) --- ui/actions/issues/index.ts | 1 + ui/actions/issues/issues.adapter.ts | 46 ++++ ui/actions/issues/issues.test.ts | 121 +++++++++ ui/actions/issues/issues.ts | 122 +++++++++ .../exploitability-chart.skeleton.tsx | 19 ++ .../_components/exploitability-chart.test.tsx | 43 +++ .../_components/exploitability-chart.tsx | 117 ++++++++ .../_components/top-issues.skeleton.tsx | 19 ++ .../exploitability/_components/top-issues.tsx | 79 ++++++ .../exploitability-chart.ssr.tsx | 36 +++ .../_overview/exploitability/index.ts | 5 + .../exploitability/top-issues.ssr.test.tsx | 71 +++++ .../exploitability/top-issues.ssr.tsx | 20 ++ .../query-builder/_components/graph/index.ts | 5 - .../query-builder/_components/index.ts | 2 +- .../node-detail/node-detail-panel.tsx | 3 +- .../_components/node-detail/node-overview.tsx | 3 +- .../query-builder/_hooks/use-graph-state.ts | 3 +- .../attack-paths-page.harness.ts | 2 +- .../attack-paths-page.integration.test.tsx | 4 +- .../query-builder/attack-paths-page.tsx | 18 +- .../issues/_components/issues-list.test.tsx | 105 ++++++++ .../issues/_components/issues-list.tsx | 92 +++++++ ui/app/(prowler)/issues/page.test.tsx | 48 ++++ ui/app/(prowler)/issues/page.tsx | 48 ++++ ui/app/(prowler)/page.tsx | 19 +- ui/changelog.d/issues-exploitability.added.md | 1 + .../attack-paths}/graph/attack-path-graph.tsx | 32 ++- .../graph/graph-controls.test.tsx | 2 +- .../attack-paths}/graph/graph-controls.tsx | 0 .../attack-paths}/graph/graph-legend.test.tsx | 3 +- .../attack-paths}/graph/graph-legend.tsx | 16 +- .../graph/graph-loading.test.tsx | 2 +- .../attack-paths}/graph/graph-loading.tsx | 0 ui/components/attack-paths/graph/index.ts | 5 + .../graph/nodes/finding-node.test.tsx | 5 +- .../graph/nodes/finding-node.tsx | 15 +- .../graph/nodes/graph-node-shell.tsx | 3 +- .../attack-paths}/graph/nodes/group-node.tsx | 14 +- .../graph/nodes/hidden-handles.tsx | 0 .../graph/nodes/internet-node.tsx | 6 +- .../graph/nodes/outcome-node.tsx | 14 +- .../graph/nodes/resource-node.test.tsx | 5 +- .../graph/nodes/resource-node.tsx | 15 +- ui/components/issues/issue-detail.test.tsx | 111 ++++++++ ui/components/issues/issue-detail.tsx | 234 ++++++++++++++++ .../issues/issue-graph.adapter.test.ts | 213 +++++++++++++++ ui/components/issues/issue-graph.adapter.ts | 109 ++++++++ ui/components/issues/issue-graph.test.tsx | 209 ++++++++++++++ ui/components/issues/issue-graph.tsx | 176 ++++++++++++ ui/components/issues/issues-load-error.tsx | 37 +++ ui/components/issues/issues-summary.tsx | 59 ++++ ui/components/issues/issues-table.test.tsx | 88 ++++++ ui/components/issues/issues-table.tsx | 157 +++++++++++ .../issues/issues.integration.test.tsx | 255 ++++++++++++++++++ ui/components/issues/verdict-filter.test.tsx | 80 ++++++ ui/components/issues/verdict-filter.tsx | 104 +++++++ .../app-sidebar/navigation-config.test.ts | 3 + .../layout/app-sidebar/navigation-config.ts | 13 + .../radio-group-provider.integration.test.tsx | 6 +- .../registry-row-logo.integration.test.tsx | 4 +- .../table/resource-detail-content.tsx | 2 +- .../table => hooks}/use-finding-details.ts | 0 .../attack-paths}/edge-orientation.ts | 0 .../_lib => lib/attack-paths}/export.test.ts | 3 +- .../_lib => lib/attack-paths}/export.ts | 19 +- .../_lib => lib/attack-paths}/format.ts | 0 .../attack-paths}/graph-colors.test.ts | 4 +- .../_lib => lib/attack-paths}/graph-colors.ts | 4 +- .../_lib => lib/attack-paths}/graph-utils.ts | 3 +- .../attack-paths}/group-graph.test.ts | 13 +- .../_lib => lib/attack-paths}/group-graph.ts | 10 +- .../_lib => lib/attack-paths}/index.ts | 21 +- .../_lib => lib/attack-paths}/layout.test.ts | 3 +- .../_lib => lib/attack-paths}/layout.ts | 14 +- .../attack-paths}/node-dimensions.ts | 0 .../attack-paths}/node-label-lines.test.ts | 2 +- .../attack-paths}/node-label-lines.ts | 0 .../_lib => lib/attack-paths}/node-types.ts | 0 .../attack-paths}/node-visuals.test.ts | 6 +- .../_lib => lib/attack-paths}/node-visuals.ts | 5 +- ui/lib/issues/scope.ts | 27 ++ ui/lib/issues/verdicts.test.ts | 43 +++ ui/lib/issues/verdicts.ts | 46 ++++ ui/types/issues.ts | 135 ++++++++++ 85 files changed, 3255 insertions(+), 152 deletions(-) create mode 100644 ui/actions/issues/index.ts create mode 100644 ui/actions/issues/issues.adapter.ts create mode 100644 ui/actions/issues/issues.test.ts create mode 100644 ui/actions/issues/issues.ts create mode 100644 ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx create mode 100644 ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx create mode 100644 ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx create mode 100644 ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx create mode 100644 ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx create mode 100644 ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx create mode 100644 ui/app/(prowler)/_overview/exploitability/index.ts create mode 100644 ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx create mode 100644 ui/app/(prowler)/_overview/exploitability/top-issues.ssr.tsx delete mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts create mode 100644 ui/app/(prowler)/issues/_components/issues-list.test.tsx create mode 100644 ui/app/(prowler)/issues/_components/issues-list.tsx create mode 100644 ui/app/(prowler)/issues/page.test.tsx create mode 100644 ui/app/(prowler)/issues/page.tsx create mode 100644 ui/changelog.d/issues-exploitability.added.md rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/attack-path-graph.tsx (97%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/graph-controls.test.tsx (95%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/graph-controls.tsx (100%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/graph-legend.test.tsx (99%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/graph-legend.tsx (98%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/graph-loading.test.tsx (89%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/graph-loading.tsx (100%) create mode 100644 ui/components/attack-paths/graph/index.ts rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/nodes/finding-node.test.tsx (95%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/nodes/finding-node.tsx (92%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/nodes/graph-node-shell.tsx (95%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/nodes/group-node.tsx (93%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/nodes/hidden-handles.tsx (100%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/nodes/internet-node.tsx (93%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/nodes/outcome-node.tsx (90%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/nodes/resource-node.test.tsx (96%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_components => components/attack-paths}/graph/nodes/resource-node.tsx (91%) create mode 100644 ui/components/issues/issue-detail.test.tsx create mode 100644 ui/components/issues/issue-detail.tsx create mode 100644 ui/components/issues/issue-graph.adapter.test.ts create mode 100644 ui/components/issues/issue-graph.adapter.ts create mode 100644 ui/components/issues/issue-graph.test.tsx create mode 100644 ui/components/issues/issue-graph.tsx create mode 100644 ui/components/issues/issues-load-error.tsx create mode 100644 ui/components/issues/issues-summary.tsx create mode 100644 ui/components/issues/issues-table.test.tsx create mode 100644 ui/components/issues/issues-table.tsx create mode 100644 ui/components/issues/issues.integration.test.tsx create mode 100644 ui/components/issues/verdict-filter.test.tsx create mode 100644 ui/components/issues/verdict-filter.tsx rename ui/{components/resources/table => hooks}/use-finding-details.ts (100%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/edge-orientation.ts (100%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/export.test.ts (98%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/export.ts (97%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/format.ts (100%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/graph-colors.test.ts (94%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/graph-colors.ts (98%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/graph-utils.ts (98%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/group-graph.test.ts (99%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/group-graph.ts (98%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/index.ts (52%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/layout.test.ts (99%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/layout.ts (94%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/node-dimensions.ts (100%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/node-label-lines.test.ts (92%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/node-label-lines.ts (100%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/node-types.ts (100%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/node-visuals.test.ts (99%) rename ui/{app/(prowler)/attack-paths/(workflow)/query-builder/_lib => lib/attack-paths}/node-visuals.ts (98%) create mode 100644 ui/lib/issues/scope.ts create mode 100644 ui/lib/issues/verdicts.test.ts create mode 100644 ui/lib/issues/verdicts.ts create mode 100644 ui/types/issues.ts diff --git a/ui/actions/issues/index.ts b/ui/actions/issues/index.ts new file mode 100644 index 0000000000..bd8854f374 --- /dev/null +++ b/ui/actions/issues/index.ts @@ -0,0 +1 @@ +export * from "./issues"; diff --git a/ui/actions/issues/issues.adapter.ts b/ui/actions/issues/issues.adapter.ts new file mode 100644 index 0000000000..017c7b66bf --- /dev/null +++ b/ui/actions/issues/issues.adapter.ts @@ -0,0 +1,46 @@ +import { FindingProps } from "@/types/components"; +import { IssueFinding } from "@/types/issues"; +import { JsonApiDocument, JsonApiResource } from "@/types/jsonapi"; +import { ResourceProps } from "@/types/resources"; + +interface IssueFindingResource extends Pick { + attributes: Pick< + FindingProps["attributes"], + "check_id" | "check_metadata" | "severity" + >; + relationships: Pick; +} + +interface IssueFindingsDocument + extends JsonApiDocument { + included?: JsonApiResource>[]; +} + +export const toIssueFindings = ( + payload: IssueFindingsDocument | undefined, +): IssueFinding[] | undefined => { + if (!Array.isArray(payload?.data)) return undefined; + const resourceUids = new Map( + (payload.included ?? []) + .filter((resource) => resource.type === "resources") + .map((resource) => [resource.id, resource.attributes.uid]), + ); + + return payload.data.flatMap((finding) => + (finding.relationships?.resources?.data ?? []).flatMap((resource) => { + const resourceUid = resourceUids.get(resource.id); + if (!resourceUid) return []; + return [ + { + id: finding.id, + checkId: finding.attributes.check_id, + checkTitle: + finding.attributes.check_metadata?.checktitle || + finding.attributes.check_id, + severity: finding.attributes.severity, + resourceUid, + }, + ]; + }), + ); +}; diff --git a/ui/actions/issues/issues.test.ts b/ui/actions/issues/issues.test.ts new file mode 100644 index 0000000000..4286ad6064 --- /dev/null +++ b/ui/actions/issues/issues.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + }), +); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +import { + getIssue, + getIssueFindings, + getIssues, + getIssuesOverview, +} from "./issues"; + +describe("issues actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response("", { status: 500 })); + // handleApiResponse rejects on a 5xx; the action must catch it. + handleApiResponseMock.mockRejectedValue(new Error("Server error")); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("getIssues returns undefined when the API answers 500", async () => { + await expect(getIssues()).resolves.toBeUndefined(); + }); + + it("getIssue returns undefined when the API answers 500", async () => { + await expect(getIssue("issue-1")).resolves.toBeUndefined(); + }); + + it("getIssuesOverview returns undefined when the API answers 500", async () => { + await expect(getIssuesOverview()).resolves.toBeUndefined(); + }); + + it("loads folded findings from the exact scan and maps all associated resources", async () => { + // Given + handleApiResponseMock.mockResolvedValue({ + data: [ + { + id: "finding-1", + attributes: { + check_id: "ssh", + severity: "critical", + check_metadata: { checktitle: "SSH exposed" }, + }, + relationships: { resources: { data: [{ id: "r1" }, { id: "r2" }] } }, + }, + ], + included: [ + { type: "resources", id: "r1", attributes: { uid: "instance" } }, + { type: "resources", id: "r2", attributes: { uid: "security-group" } }, + ], + }); + + // When + const findings = await getIssueFindings("issue-scan", ["finding-uid"]); + + // Then + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.pathname).toBe("/api/v1/findings"); + expect(url.searchParams.get("filter[scan]")).toBe("issue-scan"); + expect(url.searchParams.get("filter[uid__in]")).toBe("finding-uid"); + expect(url.searchParams.get("include")).toBe("resources"); + expect(findings).toEqual( + ["instance", "security-group"].map((resourceUid) => ({ + id: "finding-1", + checkId: "ssh", + checkTitle: "SSH exposed", + severity: "critical", + resourceUid, + })), + ); + }); + + it("caps unique finding UIDs at 50", async () => { + // Given + handleApiResponseMock.mockResolvedValue({ data: [] }); + const uids = Array.from({ length: 60 }, (_, index) => `uid-${index}`); + // When + await getIssueFindings("scan-1", [uids[0], ...uids]); + // Then + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.searchParams.get("filter[uid__in]")).toBe( + uids.slice(0, 50).join(","), + ); + expect(url.searchParams.get("page[size]")).toBe("50"); + }); + + it("does not issue an unfiltered request for an empty list or missing scan", async () => { + await expect(getIssueFindings("scan-1", [])).resolves.toEqual([]); + await expect(getIssueFindings("", ["uid-1"])).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("distinguishes API failures from an empty set of findings", async () => { + await expect( + getIssueFindings("scan-1", ["uid-1"]), + ).resolves.toBeUndefined(); + handleApiResponseMock.mockResolvedValueOnce({ + errors: [{ status: "403" }], + }); + await expect( + getIssueFindings("scan-1", ["uid-1"]), + ).resolves.toBeUndefined(); + }); +}); diff --git a/ui/actions/issues/issues.ts b/ui/actions/issues/issues.ts new file mode 100644 index 0000000000..a32d354570 --- /dev/null +++ b/ui/actions/issues/issues.ts @@ -0,0 +1,122 @@ +"use server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; +import { + ISSUE_FINDINGS_LIMIT, + IssueDetailResponse, + IssueFinding, + IssueResponse, + IssuesOverviewResponse, +} from "@/types/issues"; + +import { toIssueFindings } from "./issues.adapter"; + +const withFilters = ( + url: URL, + filters: Record, +) => { + Object.entries(filters).forEach(([key, value]) => { + if (key === "filter[search]" || value === undefined) return; + // `__in` filters take one comma-separated value (filter[verdict__in]=a,b); + // a repeated param would keep only the last value. + url.searchParams.set(key, Array.isArray(value) ? value.join(",") : value); + }); +}; + +export const getIssuesOverview = async ({ + filters = {}, +}: { + filters?: Record; +} = {}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/issues/overview`); + withFilters(url, filters); + + try { + const response = await fetch(url.toString(), { headers }); + return await handleApiResponse(response); + } catch (error) { + console.error("Error fetching issue verdict summary:", error); + return undefined; + } +}; + +export const getIssues = async ({ + filters = {}, + page = 1, + pageSize = 10, + sort, +}: { + filters?: Record; + page?: number; + pageSize?: number; + /** + * Omitted by default on purpose. The API already orders worst verdict + * first, and restating it here meant the two could disagree — which they + * did: a stale `sort=verdict` became invalid when ordering moved to + * `verdict_rank`, and the list silently came back empty while the counts + * beside it still rendered. + */ + sort?: string; +} = {}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/issues`); + url.searchParams.append("page[number]", String(page)); + url.searchParams.append("page[size]", String(pageSize)); + if (sort) url.searchParams.append("sort", sort); + withFilters(url, filters); + + try { + const response = await fetch(url.toString(), { headers }); + return await handleApiResponse(response); + } catch (error) { + console.error("Error fetching issues:", error); + return undefined; + } +}; + +export const getIssue = async ( + id: string, +): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/issues/${encodeURIComponent(id)}`); + + try { + const response = await fetch(url.toString(), { headers }); + return await handleApiResponse(response); + } catch (error) { + console.error("Error fetching issue:", error); + return undefined; + } +}; + +/** Load a bounded preview from the issue's exact scan, including every resource association. */ +export const getIssueFindings = async ( + scanId: string, + findingUids: string[], +): Promise => { + if (!scanId) return undefined; + const uids = Array.from(new Set(findingUids)).slice(0, ISSUE_FINDINGS_LIMIT); + if (uids.length === 0) return []; + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/findings`); + url.searchParams.set("filter[scan]", scanId); + url.searchParams.set("filter[uid__in]", uids.join(",")); + url.searchParams.set("include", "resources"); + url.searchParams.set( + "fields[findings]", + "check_id,severity,check_metadata,resources", + ); + url.searchParams.set("fields[resources]", "uid"); + url.searchParams.set("page[size]", String(uids.length)); + + try { + const response = await fetch(url.toString(), { headers }); + return toIssueFindings(await handleApiResponse(response)); + } catch (error) { + console.error("Error fetching issue findings:", error); + return undefined; + } +}; diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx new file mode 100644 index 0000000000..7bfa15643a --- /dev/null +++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx @@ -0,0 +1,19 @@ +import { Card, CardContent, CardHeader, Skeleton } from "@/components/shadcn"; + +export function ExploitabilityChartSkeleton() { + return ( + + + + + + + + + + + ); +} diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx new file mode 100644 index 0000000000..3b5b9c16f0 --- /dev/null +++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ExploitabilityChart } from "./exploitability-chart"; + +describe("ExploitabilityChart", () => { + it("leads with exploitable + likely and links each verdict into the list", () => { + render( + , + ); + + expect( + screen.getByRole("link", { name: "3 exploitable or likely issues" }), + ).toHaveAttribute( + "href", + "/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict__in%5D=exploitable%2Clikely", + ); + expect(screen.getByText("0 exploitable · 3 likely")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /Not exploitable/ }), + ).toHaveAttribute( + "href", + "/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=not_exploitable", + ); + expect(screen.getByText("covers 17 findings")).toBeInTheDocument(); + expect( + screen.getByText("37 failed findings folded into 12 issues"), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx new file mode 100644 index 0000000000..5c525e8564 --- /dev/null +++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx @@ -0,0 +1,117 @@ +import { TriangleAlert, Layers } from "lucide-react"; +import Link from "next/link"; + +import { + Button, + ResourceStatsCard, + CardVariant, + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/shadcn"; +import { issuesHref } from "@/lib/issues/scope"; +import { Verdict } from "@/types/issues"; + +interface ExploitabilityChartProps { + counts: Record; + issues: number; + findings: number; + actionable: number; + notExploitableFindings: number; + /** Overview scope, carried into every drill-down so the counts still match. */ + scope: Record; +} + +/** The verdicts below the headline, in the order the card lists them. */ +const REST: { id: Verdict; label: string }[] = [ + { id: "conditional", label: "Conditional" }, + { id: "unknown", label: "Unknown" }, + { id: "not_exploitable", label: "Not exploitable" }, +]; + +/** + * The fold, sized for the ThreatScore row. + * + * The headline is exploitable + likely — the issues someone should look at + * tonight — in the critical red. Everything else is work, not an incident, so + * it sits in a quieter box underneath. Every number links into the issue list, + * so nothing here is a dead end. + */ +export const ExploitabilityChart = ({ + counts, + issues, + findings, + actionable, + notExploitableFindings, + scope, +}: ExploitabilityChartProps) => { + return ( + + + Exploitability + + + + + + +
    + {REST.map(({ id, label }) => ( +
  • + + {id === "not_exploitable" ? ( + + covers {notExploitableFindings.toLocaleString()} finding + {notExploitableFindings === 1 ? "" : "s"} + + ) : null} +
  • + ))} +
+
+ + {/* The fold. Without the findings count the issue count says nothing + about how much noise was removed, so the two are always shown + together. */} +

+ {findings.toLocaleString()} failed findings folded into{" "} + {issues.toLocaleString()} issues +

+
+
+ ); +}; diff --git a/ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx new file mode 100644 index 0000000000..c70510ab60 --- /dev/null +++ b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx @@ -0,0 +1,19 @@ +import { Card, CardContent, Skeleton } from "@/components/shadcn"; + +export function TopIssuesSkeleton() { + return ( + + + + {Array.from({ length: 5 }).map((_, index) => ( + + ))} + + + ); +} diff --git a/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx new file mode 100644 index 0000000000..743b399500 --- /dev/null +++ b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx @@ -0,0 +1,79 @@ +import Link from "next/link"; + +import { IssuesLoadError } from "@/components/issues/issues-load-error"; +import { Card, CardContent, CardTitle } from "@/components/shadcn"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { issuesHref } from "@/lib/issues/scope"; +import { VERDICTS } from "@/lib/issues/verdicts"; +import { IssueResource } from "@/types/issues"; + +interface TopIssuesProps { + issues: IssueResource[] | null; + scope: Record; +} + +export const TopIssues = ({ issues, scope }: TopIssuesProps) => ( + + Top issues + + {issues === null ? ( + + ) : issues.length > 0 ? ( +
    + {issues.map(({ id, attributes, relationships }) => { + const verdict = VERDICTS.find( + (item) => item.id === attributes.verdict, + ); + const providerId = relationships?.provider?.data?.id; + return ( +
  • +
    +
    + + {verdict?.label ?? attributes.verdict} + + + {attributes.findings_count}{" "} + {attributes.findings_count === 1 ? "finding" : "findings"} + +
    +
    + +

    + {attributes.account} +

    +
    +
    +
  • + ); + })} +
+ ) : ( +

+ No issues in this scope. +

+ )} + +
+
+); diff --git a/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx b/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx new file mode 100644 index 0000000000..6364ea7309 --- /dev/null +++ b/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx @@ -0,0 +1,36 @@ +import { getIssuesOverview } from "@/actions/issues"; +import { IssuesLoadError } from "@/components/issues/issues-load-error"; +import { Card } from "@/components/shadcn/card/card"; +import { pickIssueScope } from "@/lib/issues/scope"; +import { verdictCounts } from "@/lib/issues/verdicts"; + +import { SSRComponentProps } from "../_types"; + +import { ExploitabilityChart } from "./_components/exploitability-chart"; + +export const ExploitabilityChartSSR = async ({ + searchParams, +}: SSRComponentProps) => { + const filters = pickIssueScope(searchParams); + const summary = await getIssuesOverview({ filters }); + + const attributes = summary?.data?.attributes; + if (!attributes) { + return ( + + + + ); + } + + return ( + + ); +}; diff --git a/ui/app/(prowler)/_overview/exploitability/index.ts b/ui/app/(prowler)/_overview/exploitability/index.ts new file mode 100644 index 0000000000..9992e497fa --- /dev/null +++ b/ui/app/(prowler)/_overview/exploitability/index.ts @@ -0,0 +1,5 @@ +export { ExploitabilityChart } from "./_components/exploitability-chart"; +export { ExploitabilityChartSkeleton } from "./_components/exploitability-chart.skeleton"; +export { ExploitabilityChartSSR } from "./exploitability-chart.ssr"; +export { TopIssuesSkeleton } from "./_components/top-issues.skeleton"; +export { TopIssuesSSR } from "./top-issues.ssr"; diff --git a/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx b/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx new file mode 100644 index 0000000000..9a19c9a21b --- /dev/null +++ b/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { getIssues } from "@/actions/issues"; + +import { TopIssuesSSR } from "./top-issues.ssr"; + +vi.mock("@/actions/issues", () => ({ getIssues: vi.fn() })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +describe("TopIssuesSSR", () => { + it("links each issue to its own pattern and account", async () => { + vi.mocked(getIssues).mockResolvedValue({ + data: [ + { + type: "issues", + id: "i1", + attributes: { + pattern: "public_snapshot", + account: "aws_testing_1", + title: "RDS snapshot is visible to every AWS account", + verdict: "likely", + findings_count: 1, + }, + relationships: { + provider: { data: { type: "providers", id: "p1" } }, + }, + }, + ], + } as never); + + render(await TopIssuesSSR({ searchParams: {} })); + + const row = screen.getByRole("link", { name: /RDS snapshot/ }); + expect(row.closest("li")).toHaveTextContent("Likely"); + expect(row.closest("li")).toHaveTextContent("aws_testing_1"); + expect(row.closest("li")).toHaveTextContent("1 finding"); + expect(row).toHaveAttribute( + "href", + "/issues?filter%5Bpattern%5D=public_snapshot&filter%5Bprovider_id__in%5D=p1", + ); + expect( + screen.getByRole("link", { name: "View all issues" }), + ).toHaveAttribute("href", "/issues"); + }); + + it("shows a load error, not 'no attack paths', when the list fails", async () => { + vi.mocked(getIssues).mockResolvedValue({ + error: "Invalid filter", + status: 400, + } as never); + + render(await TopIssuesSSR({ searchParams: {} })); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Failed to load issues.", + ); + expect(screen.queryByText(/No attack paths/)).not.toBeInTheDocument(); + }); + + it("shows the empty state for a successful empty list", async () => { + vi.mocked(getIssues).mockResolvedValue({ data: [] } as never); + + render(await TopIssuesSSR({ searchParams: {} })); + + expect(screen.getByText("No issues in this scope.")).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.tsx b/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.tsx new file mode 100644 index 0000000000..ac54791bee --- /dev/null +++ b/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.tsx @@ -0,0 +1,20 @@ +import { getIssues } from "@/actions/issues"; +import { pickIssueScope } from "@/lib/issues/scope"; + +import { SSRComponentProps } from "../_types"; + +import { TopIssues } from "./_components/top-issues"; + +export const TopIssuesSSR = async ({ searchParams }: SSRComponentProps) => { + const scope = pickIssueScope(searchParams); + // Worst verdict first is the API default, so the first page is the top. + const response = await getIssues({ filters: scope, pageSize: 5 }); + + return ( + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts deleted file mode 100644 index 52b316d510..0000000000 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { GraphHandle } from "./attack-path-graph"; -export { AttackPathGraph } from "./attack-path-graph"; -export { GraphControls } from "./graph-controls"; -export { GraphLegend } from "./graph-legend"; -export { GraphLoading } from "./graph-loading"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts index 83161cc4ef..dceb5584da 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts @@ -1,5 +1,5 @@ export { ExecuteButton } from "./execute-button"; -export * from "./graph"; +export * from "@/components/attack-paths/graph"; export * from "./node-detail"; export { QueryDescription } from "./query-description"; export { QueryExecutionError } from "./query-execution-error"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx index 1e6f013aec..97fbd59ba7 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx @@ -9,10 +9,9 @@ import { SheetTitle, } from "@/components/shadcn/sheet/sheet"; import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { isProwlerFindingNode } from "@/lib/attack-paths"; import type { GraphNode } from "@/types/attack-paths"; -import { isProwlerFindingNode } from "../../_lib"; - import { NodeFindings } from "./node-findings"; import { NodeOverview } from "./node-overview"; import { NodeResources } from "./node-resources"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx index 1961b43316..d6e9e509cc 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx @@ -3,10 +3,9 @@ import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet"; import { DateWithTime } from "@/components/shadcn/entities/date-with-time"; import { InfoField } from "@/components/shadcn/info-field/info-field"; +import { formatNodeLabels, isProwlerFindingNode } from "@/lib/attack-paths"; import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths"; -import { formatNodeLabels, isProwlerFindingNode } from "../../_lib"; - interface NodeOverviewProps { node: GraphNode; } diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts index 07dc16a2c8..0f766bd302 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; +import { computeFilteredSubgraph } from "@/lib/attack-paths"; import type { AttackPathGraphData, AttackPathQueryExecution, @@ -9,8 +10,6 @@ import type { GraphState, } from "@/types/attack-paths"; -import { computeFilteredSubgraph } from "../_lib"; - interface FilteredViewState { isFilteredView: boolean; filteredNodeId: string | null; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts index e358d9287e..219c08bd61 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts @@ -7,8 +7,8 @@ import { vi } from "vitest"; import { BrowserHarness } from "@/__tests__/browser-harness"; +import { isProwlerFindingNode } from "@/lib/attack-paths"; -import { isProwlerFindingNode } from "./_lib"; import type { PageFixture } from "./attack-paths-page.fixtures"; export class AttackPathPageHarness extends BrowserHarness { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx index d15733db06..6d26564274 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx @@ -15,6 +15,8 @@ import { beforeEach, describe, expect, test as base, vi } from "vitest"; import { handlersForFixture } from "@/__tests__/msw/handlers/attack-paths"; import { worker } from "@/__tests__/msw/worker"; import { render } from "@/__tests__/render-browser"; +import { getPathEdges, isProwlerFindingNode } from "@/lib/attack-paths"; +import { layoutWithDagre } from "@/lib/attack-paths/layout"; import { useLighthouseContextStore } from "@/store/lighthouse-context/store"; import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils"; @@ -37,8 +39,6 @@ vi.mock("@/actions/findings", async () => { }); import { useGraphStore } from "./_hooks/use-graph-state"; -import { getPathEdges, isProwlerFindingNode } from "./_lib"; -import { layoutWithDagre } from "./_lib/layout"; import AttackPathsPage from "./attack-paths-page"; import { fixtures, type PageFixture } from "./attack-paths-page.fixtures"; import { AttackPathPageHarness } from "./attack-paths-page.harness"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index e7ed018411..815701868a 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -12,10 +12,10 @@ import { getAvailableQueries, } from "@/actions/attack-paths"; import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter"; +import type { GraphHandle } from "@/components/attack-paths/graph/attack-path-graph"; import { FindingDetailDrawer } from "@/components/findings/table"; import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor"; import { PageReady } from "@/components/onboarding"; -import { useFindingDetails } from "@/components/resources/table/use-finding-details"; import { AutoRefresh } from "@/components/scans"; import { Button, Card, useToast } from "@/components/shadcn"; import { @@ -27,7 +27,15 @@ import { DialogTrigger, } from "@/components/shadcn/dialog"; import { StatusAlert } from "@/components/shared/status-alert"; +import { useFindingDetails } from "@/hooks/use-finding-details"; import { useMountEffect } from "@/hooks/use-mount-effect"; +import { exportGraphAsPNG, isProwlerFindingNode } from "@/lib/attack-paths"; +import { + buildAttackPathView, + GROUP_NODE_LABEL, + GROUP_PROPS, + OUTCOME_NODE_LABEL, +} from "@/lib/attack-paths/group-graph"; import { buildAttackPathContext } from "@/lib/lighthouse/context/contributions"; import { isCloud } from "@/lib/shared/env"; import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour"; @@ -63,23 +71,15 @@ import { ScanListTable, } from "./_components"; import { AttackPathsStatusPanel } from "./_components/attack-paths-status-panel"; -import type { GraphHandle } from "./_components/graph/attack-path-graph"; import { useAttackPathScans } from "./_hooks/use-attack-path-scans"; import { useGraphState } from "./_hooks/use-graph-state"; import { useQueryBuilder } from "./_hooks/use-query-builder"; -import { exportGraphAsPNG, isProwlerFindingNode } from "./_lib"; import { ATTACK_PATHS_VIEW_STATES, getAttackPathsViewState, getGraphBuildingProgress, isScanInFlight, } from "./_lib/get-attack-paths-view-state"; -import { - buildAttackPathView, - GROUP_NODE_LABEL, - GROUP_PROPS, - OUTCOME_NODE_LABEL, -} from "./_lib/group-graph"; const SCROLL_CONTAINER_CLASS = "minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"; diff --git a/ui/app/(prowler)/issues/_components/issues-list.test.tsx b/ui/app/(prowler)/issues/_components/issues-list.test.tsx new file mode 100644 index 0000000000..f875dc914b --- /dev/null +++ b/ui/app/(prowler)/issues/_components/issues-list.test.tsx @@ -0,0 +1,105 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getIssues, getIssuesOverview } from "@/actions/issues"; + +import { IssuesList } from "./issues-list"; + +vi.mock("@/actions/issues", () => ({ + getIssues: vi.fn(), + getIssuesOverview: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +vi.mock("@/components/issues/verdict-filter", () => ({ + VerdictFilter: () => null, +})); + +vi.mock("@/components/issues/issues-table", () => ({ + IssuesTable: () => null, +})); + +describe("IssuesList", () => { + beforeEach(() => { + vi.mocked(getIssuesOverview).mockResolvedValue({ + data: { + type: "issues-overview", + attributes: { + exploitable: 0, + likely: 0, + conditional: 0, + not_exploitable: 0, + unknown: 0, + issues: 0, + findings: 0, + actionable: 0, + not_exploitable_findings: 0, + }, + }, + }); + }); + + it.each([ + ["a 4xx", { error: "Forbidden", status: 403 }], + ["a network failure", undefined], + ])("shows a load error, not an empty state, on %s", async (_, response) => { + vi.mocked(getIssues).mockResolvedValue(response as never); + + render(await IssuesList({ searchParams: {} })); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Failed to load issues.", + ); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(screen.queryByText(/No issues match/)).not.toBeInTheDocument(); + }); + + it("shows the empty state for a successful empty list", async () => { + vi.mocked(getIssues).mockResolvedValue({ data: [] } as never); + + render(await IssuesList({ searchParams: {} })); + + expect( + screen.getByText("No issues match this filter."), + ).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("reports a summary failure without replacing it with zero counts", async () => { + // Given + vi.mocked(getIssuesOverview).mockResolvedValue(undefined); + vi.mocked(getIssues).mockResolvedValue({ data: [] }); + // When + render(await IssuesList({ searchParams: {} })); + // Then + expect(screen.getByRole("alert")).toHaveTextContent( + "Failed to load issue summary.", + ); + expect( + screen.getByText("No issues match this filter."), + ).toBeInTheDocument(); + }); + + it("forwards combined verdict filters only to the list", async () => { + vi.mocked(getIssues).mockResolvedValue({ data: [] }); + await IssuesList({ + searchParams: { + "filter[verdict__in]": "exploitable,likely", + "filter[provider_id__in]": "provider-1", + }, + }); + expect(getIssues).toHaveBeenLastCalledWith( + expect.objectContaining({ + filters: { + "filter[verdict__in]": "exploitable,likely", + "filter[provider_id__in]": "provider-1", + }, + }), + ); + expect(getIssuesOverview).toHaveBeenLastCalledWith({ + filters: { "filter[provider_id__in]": "provider-1" }, + }); + }); +}); diff --git a/ui/app/(prowler)/issues/_components/issues-list.tsx b/ui/app/(prowler)/issues/_components/issues-list.tsx new file mode 100644 index 0000000000..fc23bf720f --- /dev/null +++ b/ui/app/(prowler)/issues/_components/issues-list.tsx @@ -0,0 +1,92 @@ +import { ReactNode } from "react"; + +import { getIssues, getIssuesOverview } from "@/actions/issues"; +import { IssuesLoadError } from "@/components/issues/issues-load-error"; +import { IssuesSummary } from "@/components/issues/issues-summary"; +import { IssuesTable } from "@/components/issues/issues-table"; +import { VerdictFilter } from "@/components/issues/verdict-filter"; +import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope"; +import { verdictCounts } from "@/lib/issues/verdicts"; +import { SearchParamsProps } from "@/types"; + +/** Only the filters the issues endpoint understands are forwarded. */ +const ISSUE_FILTER_KEYS = [ + "filter[verdict]", + "filter[verdict__in]", + "filter[cloud]", + "filter[pattern]", + "filter[severity]", + "filter[provider_type]", + "filter[provider_alias]", + ...ISSUE_SCOPE_KEYS, +]; + +export const IssuesList = async ({ + searchParams, + filters: filterControls, +}: { + searchParams: SearchParamsProps; + /** The scope selectors, rendered between the verdict chips and the table. */ + filters?: ReactNode; +}) => { + const filters: Record = {}; + ISSUE_FILTER_KEYS.forEach((key) => { + const value = searchParams[key]; + if (typeof value === "string" && value) filters[key] = value; + }); + + const [summary, issues] = await Promise.all([ + // The counts are deliberately NOT filtered by verdict: the ladder has to + // keep showing every verdict's total, or picking one would zero the rest + // and there would be no way back. + getIssuesOverview({ + filters: Object.fromEntries( + Object.entries(filters).filter( + ([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]", + ), + ), + }), + getIssues({ + filters, + page: Number(searchParams.page) || 1, + pageSize: Number(searchParams.pageSize) || 10, + sort: + typeof searchParams.sort === "string" ? searchParams.sort : undefined, + }), + ]); + + const attributes = summary?.data?.attributes; + // handleApiResponse resolves {error, status} on a 4xx and undefined on a + // network failure; neither is an empty list. + const rows = Array.isArray(issues?.data) ? issues.data : null; + + return ( + <> + {attributes ? ( + + ) : ( + + )} + + + + {filterControls} + + {rows === null ? ( + + ) : rows.length ? ( + + ) : ( +

+ No issues match this filter. +

+ )} + + ); +}; diff --git a/ui/app/(prowler)/issues/page.test.tsx b/ui/app/(prowler)/issues/page.test.tsx new file mode 100644 index 0000000000..d9c8b6447f --- /dev/null +++ b/ui/app/(prowler)/issues/page.test.tsx @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getAllProviders, getAllProviderGroups, notFound } = vi.hoisted(() => ({ + getAllProviders: vi.fn(), + getAllProviderGroups: vi.fn(), + notFound: vi.fn(() => { + throw new Error("NOT_FOUND"); + }), +})); +vi.mock("next/navigation", () => ({ notFound })); +vi.mock("@/actions/providers", () => ({ getAllProviders })); +vi.mock("@/actions/manage-groups/manage-groups", () => ({ + getAllProviderGroups, +})); +vi.mock("@/components/filters/provider-account-selectors", () => ({ + ProviderAccountSelectors: () => null, +})); +vi.mock("@/components/filters/provider-group-selector", () => ({ + ProviderGroupSelector: () => null, +})); +vi.mock("@/components/shadcn/content-layout", () => ({ + ContentLayout: () => null, +})); +vi.mock("./_components/issues-list", () => ({ IssuesList: () => null })); + +import Issues from "./page"; + +describe("Issues deployment availability", () => { + beforeEach(() => { + getAllProviders.mockResolvedValue({ data: [] }); + getAllProviderGroups.mockResolvedValue({ data: [] }); + }); + it("returns not found before loading data in Local Server", async () => { + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + await expect(Issues({ searchParams: Promise.resolve({}) })).rejects.toThrow( + "NOT_FOUND", + ); + expect(getAllProviders).not.toHaveBeenCalled(); + expect(getAllProviderGroups).not.toHaveBeenCalled(); + }); + it("loads the scope controls in Cloud deployments", async () => { + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + await Issues({ searchParams: Promise.resolve({}) }); + expect(notFound).not.toHaveBeenCalled(); + expect(getAllProviders).toHaveBeenCalledOnce(); + expect(getAllProviderGroups).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/app/(prowler)/issues/page.tsx b/ui/app/(prowler)/issues/page.tsx new file mode 100644 index 0000000000..5280a9f271 --- /dev/null +++ b/ui/app/(prowler)/issues/page.tsx @@ -0,0 +1,48 @@ +import { ShieldAlert } from "lucide-react"; +import { notFound } from "next/navigation"; +import { Suspense } from "react"; + +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; +import { getAllProviders } from "@/actions/providers"; +import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; +import { ProviderGroupSelector } from "@/components/filters/provider-group-selector"; +import { ContentLayout } from "@/components/shadcn/content-layout"; +import { isCloud } from "@/lib/shared/env"; +import { SearchParamsProps } from "@/types"; + +import { IssuesList } from "./_components/issues-list"; + +export default async function Issues({ + searchParams, +}: { + searchParams: Promise; +}) { + if (!isCloud()) notFound(); + + const resolvedSearchParams = await searchParams; + const [providersData, providerGroupsData] = await Promise.all([ + getAllProviders(), + getAllProviderGroups(), + ]); + + return ( + }> + Loading issues…

+ } + > + + + + + } + /> +
+
+ ); +} diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 6e150b3094..9df682c902 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -24,6 +24,12 @@ import { AttackSurfaceSSR, } from "./_overview/attack-surface"; import { CheckFindingsSSR } from "./_overview/check-findings"; +import { + ExploitabilityChartSkeleton, + ExploitabilityChartSSR, + TopIssuesSkeleton, + TopIssuesSSR, +} from "./_overview/exploitability"; import { GraphsTabsWrapper } from "./_overview/graphs-tabs/graphs-tabs-wrapper"; import { RiskPipelineViewSkeleton } from "./_overview/graphs-tabs/risk-pipeline-view"; import { @@ -96,6 +102,12 @@ export default async function Home({ + {isCloud() && ( + }> + + + )} + }> @@ -136,11 +148,16 @@ export default async function Home({ - {/* Charts column: Attack Surface on top, Findings Over Time below */} + {/* Charts column: Attack Surface, Top issues, Findings Over Time */}
}> + {isCloud() && ( + }> + + + )} }> diff --git a/ui/changelog.d/issues-exploitability.added.md b/ui/changelog.d/issues-exploitability.added.md new file mode 100644 index 0000000000..6f94d9b250 --- /dev/null +++ b/ui/changelog.d/issues-exploitability.added.md @@ -0,0 +1 @@ +Issues page and overview cards for Cloud deployments, with verdict filters, accessible issue details, and scoped finding evidence diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/components/attack-paths/graph/attack-path-graph.tsx similarity index 97% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx rename to ui/components/attack-paths/graph/attack-path-graph.tsx index db17ef7c6e..96f13cb115 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx +++ b/ui/components/attack-paths/graph/attack-path-graph.tsx @@ -22,14 +22,11 @@ import { useState, } from "react"; -import { isCloud } from "@/lib/shared/env"; -import { cn } from "@/lib/utils"; -import type { - AttackPathGraphData, - AttackPathOutcome, - GraphNode, -} from "@/types/attack-paths"; - +import { FindingNode } from "@/components/attack-paths/graph/nodes/finding-node"; +import { GroupNode } from "@/components/attack-paths/graph/nodes/group-node"; +import { InternetNode } from "@/components/attack-paths/graph/nodes/internet-node"; +import { OutcomeNode } from "@/components/attack-paths/graph/nodes/outcome-node"; +import { ResourceNode } from "@/components/attack-paths/graph/nodes/resource-node"; import { computeFilteredSubgraph, getNodeBorderColor, @@ -38,18 +35,19 @@ import { GRAPH_EDGE_HIGHLIGHT_COLOR, isProwlerFindingNode, resolveHiddenFindingIds, -} from "../../_lib"; +} from "@/lib/attack-paths"; import { type AttackPathView, buildAttackPathView, -} from "../../_lib/group-graph"; -import { layoutWithDagre, NODE_TYPE } from "../../_lib/layout"; - -import { FindingNode } from "./nodes/finding-node"; -import { GroupNode } from "./nodes/group-node"; -import { InternetNode } from "./nodes/internet-node"; -import { OutcomeNode } from "./nodes/outcome-node"; -import { ResourceNode } from "./nodes/resource-node"; +} from "@/lib/attack-paths/group-graph"; +import { layoutWithDagre, NODE_TYPE } from "@/lib/attack-paths/layout"; +import { isCloud } from "@/lib/shared/env"; +import { cn } from "@/lib/utils"; +import type { + AttackPathGraphData, + AttackPathOutcome, + GraphNode, +} from "@/types/attack-paths"; // --- Types --- diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx b/ui/components/attack-paths/graph/graph-controls.test.tsx similarity index 95% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx rename to ui/components/attack-paths/graph/graph-controls.test.tsx index 0050c60128..d65fcba71b 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx +++ b/ui/components/attack-paths/graph/graph-controls.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { GraphControls } from "./graph-controls"; +import { GraphControls } from "@/components/attack-paths/graph/graph-controls"; const baseProps = { onZoomIn: vi.fn(), diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx b/ui/components/attack-paths/graph/graph-controls.tsx similarity index 100% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx rename to ui/components/attack-paths/graph/graph-controls.tsx diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx b/ui/components/attack-paths/graph/graph-legend.test.tsx similarity index 99% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx rename to ui/components/attack-paths/graph/graph-legend.test.tsx index 2d02d54517..0e6dd7818b 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx +++ b/ui/components/attack-paths/graph/graph-legend.test.tsx @@ -1,11 +1,10 @@ import { render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { GraphLegend } from "@/components/attack-paths/graph/graph-legend"; import { isCloud } from "@/lib/shared/env"; import type { AttackPathGraphData } from "@/types/attack-paths"; -import { GraphLegend } from "./graph-legend"; - vi.mock("next-themes", () => ({ useTheme: () => ({ resolvedTheme: "dark" }), })); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx b/ui/components/attack-paths/graph/graph-legend.tsx similarity index 98% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx rename to ui/components/attack-paths/graph/graph-legend.tsx index 471c9b9834..7a99b34277 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx +++ b/ui/components/attack-paths/graph/graph-legend.tsx @@ -10,9 +10,6 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/shadcn/tooltip"; -import { isCloud } from "@/lib/shared/env"; -import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; - import { getNodeBorderColor, getNodeColor, @@ -22,10 +19,15 @@ import { GRAPH_EDGE_HIGHLIGHT_COLOR, GRAPH_NODE_BORDER_COLORS, GRAPH_NODE_COLORS, -} from "../../_lib/graph-colors"; -import { resolveHiddenFindingIds } from "../../_lib/graph-utils"; -import { isProwlerFindingNode } from "../../_lib/node-types"; -import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals"; +} from "@/lib/attack-paths/graph-colors"; +import { resolveHiddenFindingIds } from "@/lib/attack-paths/graph-utils"; +import { isProwlerFindingNode } from "@/lib/attack-paths/node-types"; +import { + NODE_CATEGORY, + resolveNodeVisual, +} from "@/lib/attack-paths/node-visuals"; +import { isCloud } from "@/lib/shared/env"; +import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; const LEGEND_PREVIEW = { BADGE_RADIUS: 16, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx b/ui/components/attack-paths/graph/graph-loading.test.tsx similarity index 89% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx rename to ui/components/attack-paths/graph/graph-loading.test.tsx index a87fb546b2..f805475b95 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx +++ b/ui/components/attack-paths/graph/graph-loading.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { GraphLoading } from "./graph-loading"; +import { GraphLoading } from "@/components/attack-paths/graph/graph-loading"; describe("GraphLoading", () => { it("uses the provider wizard loading pattern", () => { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx b/ui/components/attack-paths/graph/graph-loading.tsx similarity index 100% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx rename to ui/components/attack-paths/graph/graph-loading.tsx diff --git a/ui/components/attack-paths/graph/index.ts b/ui/components/attack-paths/graph/index.ts new file mode 100644 index 0000000000..1a9817c99e --- /dev/null +++ b/ui/components/attack-paths/graph/index.ts @@ -0,0 +1,5 @@ +export type { GraphHandle } from "@/components/attack-paths/graph/attack-path-graph"; +export { AttackPathGraph } from "@/components/attack-paths/graph/attack-path-graph"; +export { GraphControls } from "@/components/attack-paths/graph/graph-controls"; +export { GraphLegend } from "@/components/attack-paths/graph/graph-legend"; +export { GraphLoading } from "@/components/attack-paths/graph/graph-loading"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx b/ui/components/attack-paths/graph/nodes/finding-node.test.tsx similarity index 95% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx rename to ui/components/attack-paths/graph/nodes/finding-node.test.tsx index d29a262ee2..e985faa9eb 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx +++ b/ui/components/attack-paths/graph/nodes/finding-node.test.tsx @@ -3,13 +3,12 @@ import userEvent from "@testing-library/user-event"; import { type NodeProps, Position } from "@xyflow/react"; import { describe, expect, it, vi } from "vitest"; +import { FindingNode } from "@/components/attack-paths/graph/nodes/finding-node"; import type { GraphNode } from "@/types/attack-paths"; -import { FindingNode } from "./finding-node"; - const hiddenHandlesMock = vi.hoisted(() => vi.fn(() => null)); -vi.mock("./hidden-handles", () => ({ +vi.mock("@/components/attack-paths/graph/nodes/hidden-handles", () => ({ HiddenHandles: hiddenHandlesMock, })); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx b/ui/components/attack-paths/graph/nodes/finding-node.tsx similarity index 92% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx rename to ui/components/attack-paths/graph/nodes/finding-node.tsx index 78583c628a..63166813cd 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx +++ b/ui/components/attack-paths/graph/nodes/finding-node.tsx @@ -2,19 +2,17 @@ import { type NodeProps, Position } from "@xyflow/react"; +import { HiddenHandles } from "@/components/attack-paths/graph/nodes/hidden-handles"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; +import { resolveNodeColors, resolveNodeVisual } from "@/lib/attack-paths"; +import { FINDING_NODE_DIMENSIONS } from "@/lib/attack-paths/node-dimensions"; +import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines"; import type { GraphNode } from "@/types/attack-paths"; -import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; -import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; -import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; - -import { HiddenHandles } from "./hidden-handles"; - interface FindingNodeData { graphNode: GraphNode; [key: string]: unknown; @@ -130,8 +128,7 @@ export const FindingNode = ({ data, selected }: NodeProps) => { y={TITLE_Y} textAnchor="middle" dominantBaseline="middle" - fill="#ffffff" - style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }} + className="fill-text-neutral-primary" pointerEvents="none" > {displayTitle.lines.map((line, index) => ( @@ -150,7 +147,7 @@ export const FindingNode = ({ data, selected }: NodeProps) => { x={TEXT_X} y={SEVERITY_Y} fontSize="9px" - fill="rgba(255,255,255,0.82)" + className="fill-text-neutral-secondary" > {severity} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/graph-node-shell.tsx b/ui/components/attack-paths/graph/nodes/graph-node-shell.tsx similarity index 95% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/graph-node-shell.tsx rename to ui/components/attack-paths/graph/nodes/graph-node-shell.tsx index 80bc1ec43f..3055415983 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/graph-node-shell.tsx +++ b/ui/components/attack-paths/graph/nodes/graph-node-shell.tsx @@ -3,6 +3,7 @@ import { Position } from "@xyflow/react"; import type { ReactNode } from "react"; +import { HiddenHandles } from "@/components/attack-paths/graph/nodes/hidden-handles"; import { Tooltip, TooltipContent, @@ -10,8 +11,6 @@ import { } from "@/components/shadcn/tooltip"; import { cn } from "@/lib/utils"; -import { HiddenHandles } from "./hidden-handles"; - interface GraphNodeShellProps { width: number; height: number; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/group-node.tsx b/ui/components/attack-paths/graph/nodes/group-node.tsx similarity index 93% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/group-node.tsx rename to ui/components/attack-paths/graph/nodes/group-node.tsx index 75292d87ab..47b10ccddc 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/group-node.tsx +++ b/ui/components/attack-paths/graph/nodes/group-node.tsx @@ -2,18 +2,16 @@ import { type NodeProps } from "@xyflow/react"; -import type { GraphNode } from "@/types/attack-paths"; - +import { GraphNodeShell } from "@/components/attack-paths/graph/nodes/graph-node-shell"; import { GRAPH_COUNT_BADGE_STROKE_COLOR, resolveNodeColors, resolveNodeVisual, -} from "../../../_lib"; -import { GROUP_PROPS } from "../../../_lib/group-graph"; -import { GROUP_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; -import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; - -import { GraphNodeShell } from "./graph-node-shell"; +} from "@/lib/attack-paths"; +import { GROUP_PROPS } from "@/lib/attack-paths/group-graph"; +import { GROUP_NODE_DIMENSIONS } from "@/lib/attack-paths/node-dimensions"; +import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines"; +import type { GraphNode } from "@/types/attack-paths"; interface GroupNodeData { graphNode: GraphNode; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx b/ui/components/attack-paths/graph/nodes/hidden-handles.tsx similarity index 100% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx rename to ui/components/attack-paths/graph/nodes/hidden-handles.tsx diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx b/ui/components/attack-paths/graph/nodes/internet-node.tsx similarity index 93% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx rename to ui/components/attack-paths/graph/nodes/internet-node.tsx index 097e2903c5..539b615ef3 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx +++ b/ui/components/attack-paths/graph/nodes/internet-node.tsx @@ -2,12 +2,10 @@ import { type NodeProps } from "@xyflow/react"; +import { HiddenHandles } from "@/components/attack-paths/graph/nodes/hidden-handles"; +import { resolveNodeColors } from "@/lib/attack-paths"; import type { GraphNode } from "@/types/attack-paths"; -import { resolveNodeColors } from "../../../_lib"; - -import { HiddenHandles } from "./hidden-handles"; - interface InternetNodeData { graphNode: GraphNode; [key: string]: unknown; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/outcome-node.tsx b/ui/components/attack-paths/graph/nodes/outcome-node.tsx similarity index 90% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/outcome-node.tsx rename to ui/components/attack-paths/graph/nodes/outcome-node.tsx index b0134fa1a2..d2890e8ef0 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/outcome-node.tsx +++ b/ui/components/attack-paths/graph/nodes/outcome-node.tsx @@ -3,17 +3,15 @@ import { type NodeProps } from "@xyflow/react"; import { Crosshair } from "lucide-react"; -import type { GraphNode } from "@/types/attack-paths"; - +import { GraphNodeShell } from "@/components/attack-paths/graph/nodes/graph-node-shell"; import { GRAPH_OUTCOME_BORDER_COLOR, GRAPH_OUTCOME_FILL_COLOR, -} from "../../../_lib"; -import { OUTCOME_PROPS } from "../../../_lib/group-graph"; -import { OUTCOME_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; -import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; - -import { GraphNodeShell } from "./graph-node-shell"; +} from "@/lib/attack-paths"; +import { OUTCOME_PROPS } from "@/lib/attack-paths/group-graph"; +import { OUTCOME_NODE_DIMENSIONS } from "@/lib/attack-paths/node-dimensions"; +import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines"; +import type { GraphNode } from "@/types/attack-paths"; interface OutcomeNodeData { graphNode: GraphNode; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx b/ui/components/attack-paths/graph/nodes/resource-node.test.tsx similarity index 96% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx rename to ui/components/attack-paths/graph/nodes/resource-node.test.tsx index 4cdf1a46b3..358dea4173 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx +++ b/ui/components/attack-paths/graph/nodes/resource-node.test.tsx @@ -3,13 +3,12 @@ import userEvent from "@testing-library/user-event"; import { type NodeProps, Position } from "@xyflow/react"; import { describe, expect, it, vi } from "vitest"; +import { ResourceNode } from "@/components/attack-paths/graph/nodes/resource-node"; import type { GraphNode } from "@/types/attack-paths"; -import { ResourceNode } from "./resource-node"; - const hiddenHandlesMock = vi.hoisted(() => vi.fn(() => null)); -vi.mock("./hidden-handles", () => ({ +vi.mock("@/components/attack-paths/graph/nodes/hidden-handles", () => ({ HiddenHandles: hiddenHandlesMock, })); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx b/ui/components/attack-paths/graph/nodes/resource-node.tsx similarity index 91% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx rename to ui/components/attack-paths/graph/nodes/resource-node.tsx index 3120129091..7b012778ef 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx +++ b/ui/components/attack-paths/graph/nodes/resource-node.tsx @@ -2,19 +2,17 @@ import { type NodeProps, Position } from "@xyflow/react"; +import { HiddenHandles } from "@/components/attack-paths/graph/nodes/hidden-handles"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; +import { resolveNodeColors, resolveNodeVisual } from "@/lib/attack-paths"; +import { RESOURCE_NODE_DIMENSIONS } from "@/lib/attack-paths/node-dimensions"; +import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines"; import type { GraphNode } from "@/types/attack-paths"; -import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; -import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; -import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; - -import { HiddenHandles } from "./hidden-handles"; - interface ResourceNodeData { graphNode: GraphNode; hasFindings?: boolean; @@ -115,8 +113,7 @@ export const ResourceNode = ({ data, selected }: NodeProps) => { y={NAME_Y} textAnchor="middle" dominantBaseline="middle" - fill="#ffffff" - style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }} + className="fill-text-neutral-primary" pointerEvents="none" > {displayName.lines.map((line, index) => ( @@ -135,7 +132,7 @@ export const ResourceNode = ({ data, selected }: NodeProps) => { x={TEXT_X} y={TYPE_Y} fontSize="9px" - fill="rgba(255,255,255,0.8)" + className="fill-text-neutral-secondary" > {typeLabel} diff --git a/ui/components/issues/issue-detail.test.tsx b/ui/components/issues/issue-detail.test.tsx new file mode 100644 index 0000000000..e2679e534d --- /dev/null +++ b/ui/components/issues/issue-detail.test.tsx @@ -0,0 +1,111 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IssueDetail } from "@/components/issues/issue-detail"; +import { IssueAttributes } from "@/types/issues"; + +const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({ + getIssueMock: vi.fn(), + getIssueFindingsMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) })); + +vi.mock("@/actions/issues", () => ({ + getIssue: getIssueMock, + getIssueFindings: getIssueFindingsMock, +})); + +vi.mock("@/components/issues/issue-graph", () => ({ + IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => ( +
{resourceUids.length} resources
+ ), +})); + +const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`); + +const issue = { + pattern: "unmodelled", + cloud: "aws", + account: "prod", + title: "Findings no attack path models yet (300)", + verdict: "unknown", + severity: "high", + legs: {}, + fix: "Review these findings individually.", + findings_count: 300, + inserted_at: "", + updated_at: "", +} as unknown as IssueAttributes; + +describe("IssueDetail", () => { + beforeEach(() => { + getIssueMock.mockReset(); + getIssueFindingsMock.mockResolvedValue([]); + }); + + it("links to the issue's scan findings and reveals every folded resource", async () => { + getIssueMock.mockResolvedValue({ + data: { attributes: { resource_uids: resourceUids } }, + }); + const user = userEvent.setup(); + render(); + + expect( + screen.getByRole("link", { name: /failed findings/i }), + ).toHaveAttribute( + "href", + "/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL", + ); + + // The list leaves the uids out; the detail fetches them when it opens. + expect(getIssueMock).toHaveBeenCalledWith("issue-1"); + expect(await screen.findByText("resource-1")).toBeInTheDocument(); + expect(screen.queryByText("resource-15")).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Show more resources" }), + ); + expect(screen.getByText("resource-15")).toBeInTheDocument(); + expect(screen.getByTestId("issue-graph")).toHaveTextContent("15 resources"); + }); + + it("says so when the resources cannot be loaded", async () => { + getIssueMock.mockResolvedValue(undefined); + render(); + + expect( + await screen.findByText(/could not load the resources/i), + ).toBeInTheDocument(); + expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument(); + }); + + it("reports a failed findings request and retries using the issue scan", async () => { + // Given + getIssueMock.mockResolvedValue({ + data: { + attributes: { + resource_uids: resourceUids, + finding_uids: ["finding-uid"], + }, + }, + }); + getIssueFindingsMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([]); + render(); + + // When + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not load the findings for this issue", + ); + expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + + // Then + expect(await screen.findByTestId("issue-graph")).toBeInTheDocument(); + expect(getIssueFindingsMock).toHaveBeenLastCalledWith("scan-1", [ + "finding-uid", + ]); + }); +}); diff --git a/ui/components/issues/issue-detail.tsx b/ui/components/issues/issue-detail.tsx new file mode 100644 index 0000000000..e5d0eeda18 --- /dev/null +++ b/ui/components/issues/issue-detail.tsx @@ -0,0 +1,234 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import { getIssue, getIssueFindings } from "@/actions/issues"; +import { IssueGraph } from "@/components/issues/issue-graph"; +import { IssuesLoadError } from "@/components/issues/issues-load-error"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { Card } from "@/components/shadcn/card/card"; +import { CodeSnippet } from "@/components/shadcn/code-snippet"; +import { legBadgeVariant } from "@/lib/issues/verdicts"; +import { + IssueAttributes, + IssueFinding, + LegName, + LegState, +} from "@/types/issues"; + +const LEG_QUESTIONS: Record = { + reachability: "Can an attacker reach it?", + access: "Can an attacker authenticate or bypass authentication?", + target: "Is there a target worth reaching?", + controls: "Are protective controls in place?", + verification: "Would the activity be detected?", +}; +const LEG_LABELS: Record = { + reachability: "Reachability", + access: "Access", + target: "Target", + controls: "Controls", + verification: "Verification", +}; +const STATE_LABELS: Record = { + yes: "Yes", + no: "No", + partial: "Partly", + unknown: "Unknown", +}; +const RESOURCE_PAGE_SIZE = 12; + +interface IssueDetailProps { + id: string; + issue: IssueAttributes; + scanId?: string; + providerId?: string; +} + +type ResourcesState = + | { status: "loading" } + | { status: "error" } + | { + status: "loaded"; + resources: string[]; + findings: IssueFinding[] | undefined; + scanId?: string; + providerId?: string; + }; + +export const IssueDetail = ({ + id, + issue, + scanId, + providerId, +}: IssueDetailProps) => { + const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE); + const [attempt, setAttempt] = useState(0); + // Resource UIDs are only included by the detail endpoint, fetched on expansion. + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + getIssue(id) + .then(async (detail) => { + if (cancelled) return; + const attributes = detail?.data?.attributes; + if (!attributes?.resource_uids) { + setState({ status: "error" }); + return; + } + const detailScanId = + detail?.data.relationships?.scan?.data?.id ?? scanId; + const detailProviderId = + detail?.data.relationships?.provider?.data?.id ?? providerId; + const findingUids = attributes.finding_uids ?? []; + const findings = detailScanId + ? await getIssueFindings(detailScanId, findingUids) + : findingUids.length === 0 + ? [] + : undefined; + if (!cancelled) + setState({ + status: "loaded", + resources: attributes.resource_uids, + findings, + scanId: detailScanId, + providerId: detailProviderId, + }); + }) + .catch(() => { + if (!cancelled) setState({ status: "error" }); + }); + return () => { + cancelled = true; + }; + }, [id, scanId, providerId, attempt]); + + const retry = () => { + setState({ status: "loading" }); + setAttempt((value) => value + 1); + }; + const activeScanId = state.status === "loaded" ? state.scanId : scanId; + + return ( +
+ {state.status === "loaded" && state.findings === undefined && ( + + )} + {state.status === "loaded" && + state.findings !== undefined && + state.resources.length > 0 && ( + + )} +
+

+ Exploitability assessment +

+
+ {(Object.keys(LEG_QUESTIONS) as LegName[]).map((name) => { + const leg = issue.legs[name]; + if (!leg) return null; + return ( + +
+
{LEG_LABELS[name]}
+ + {STATE_LABELS[leg.state]} + +
+

+ {LEG_QUESTIONS[name]} +

+

+ {leg.text} +

+ {leg.evidence.length > 0 && ( +
    + {leg.evidence.map((line) => ( +
  • + +
  • + ))} +
+ )} +
+ ); + })} +
+
+ {state.status === "loading" ? ( +

+ Loading issue details… +

+ ) : state.status === "error" ? ( + + ) : state.resources.length > 0 ? ( +
+

+ Affected resources ({state.resources.length}) +

+
    + {state.resources.slice(0, visibleResources).map((uid) => ( +
  • + +
  • + ))} +
+ {visibleResources < state.resources.length && ( + + )} + {visibleResources > RESOURCE_PAGE_SIZE && ( + + )} +
+ ) : null} +
+

Remediation

+

{issue.fix}

+ {activeScanId && ( + + )} +
+
+ ); +}; diff --git a/ui/components/issues/issue-graph.adapter.test.ts b/ui/components/issues/issue-graph.adapter.test.ts new file mode 100644 index 0000000000..75372477b9 --- /dev/null +++ b/ui/components/issues/issue-graph.adapter.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; + +import { + buildIssueGraph, + GRAPH_RESOURCE_LIMIT, +} from "@/components/issues/issue-graph.adapter"; +import { IssueAttributes } from "@/types/issues"; + +const leg = (state: string, evidence: string[] = []) => ({ + state, + text: "", + evidence, +}); + +const issue = (overrides: Partial = {}) => + ({ + pattern: "aws_public_bucket", + verdict: "likely", + severity: "high", + legs: { + reachability: leg("yes", ["s3_bucket_public_access · arn:aws:s3:::a"]), + access: leg("unknown"), + target: leg("yes", ["s3_bucket_public_access · arn:aws:s3:::a"]), + controls: leg("no", ["s3_bucket_default_encryption · arn:aws:s3:::b"]), + verification: leg("no"), + }, + ...overrides, + }) as unknown as IssueAttributes; + +const ids = (view: ReturnType) => + view.nodes.map((n) => n.id); + +describe("buildIssueGraph", () => { + it("draws folded findings absent from leg evidence with their real severity", () => { + // Given - only the instance is cited, but both security groups also have findings. + const resources = ["instance", "security-group-ssh", "security-group-rdp"]; + const assessedIssue = issue(); + assessedIssue.legs.reachability.evidence = ["ssh · instance"]; + const findings = resources.map((resourceUid, index) => ({ + id: `finding-${index}`, + checkId: index === 2 ? "rdp" : "ssh", + checkTitle: index === 2 ? "RDP exposed" : "SSH exposed", + severity: index === 0 ? ("critical" as const) : ("high" as const), + resourceUid, + })); + + // When + const view = buildIssueGraph(assessedIssue, resources, findings); + + // Then - cited findings are not duplicated and every resource has its finding. + expect(view.edges).toHaveLength(3); + for (const finding of findings) { + expect(view.nodes).toContainEqual( + expect.objectContaining({ + id: `finding:${finding.checkId} · ${finding.resourceUid}`, + properties: expect.objectContaining({ + finding_id: finding.id, + check_title: finding.checkTitle, + severity: finding.severity, + }), + }), + ); + } + }); + + it("prioritizes folded findings over evidence-only resources at the graph limit", () => { + // Given - the first 12 resources have evidence; the last has a folded finding. + const resources = Array.from( + { length: GRAPH_RESOURCE_LIMIT + 1 }, + (_, index) => `resource-${index}`, + ); + const assessedIssue = issue(); + assessedIssue.legs.reachability.evidence = resources + .slice(0, GRAPH_RESOURCE_LIMIT) + .map((uid) => `supporting-check · ${uid}`); + const resourceUid = resources[GRAPH_RESOURCE_LIMIT]; + // When + const view = buildIssueGraph(assessedIssue, resources, [ + { + id: "folded", + checkId: "ssh", + checkTitle: "SSH exposed", + severity: "critical", + resourceUid, + }, + ]); + // Then + expect(view.nodes[0].id).toBe(`resource:${resourceUid}`); + expect(ids(view)).toContain(`finding:ssh · ${resourceUid}`); + expect( + view.nodes.filter((node) => node.id.startsWith("resource:")), + ).toHaveLength(GRAPH_RESOURCE_LIMIT); + }); + + it("draws only resource-to-finding evidence associations", () => { + const view = buildIssueGraph(issue(), ["arn:aws:s3:::a", "arn:aws:s3:::b"]); + + expect(ids(view)).toEqual([ + "resource:arn:aws:s3:::a", + "resource:arn:aws:s3:::b", + // The same evidence line on two legs is one finding. + "finding:s3_bucket_public_access · arn:aws:s3:::a", + "finding:s3_bucket_default_encryption · arn:aws:s3:::b", + ]); + expect(view.nodes[0]).toMatchObject({ + labels: ["S3Bucket"], + properties: { name: "a" }, + }); + expect(view.edges).toContainEqual( + expect.objectContaining({ + source: "resource:arn:aws:s3:::b", + target: "finding:s3_bucket_default_encryption · arn:aws:s3:::b", + type: "HAS_FINDING", + }), + ); + expect(view.nodes.at(-1)?.properties).not.toHaveProperty("severity"); + expect(view.nodes.at(-1)?.properties).toMatchObject({ + check_id: "s3_bucket_default_encryption", + resource_uid: "arn:aws:s3:::b", + }); + }); + + it.each(["yes", "no", "partial", "unknown"])( + "does not infer per-resource Internet reachability from aggregate %s", + (state) => { + const view = buildIssueGraph( + issue({ legs: { reachability: leg(state) } } as never), + ["arn:aws:s3:::a"], + ); + + expect(ids(view)).not.toContain("internet"); + expect(view.edges.map((e) => e.source)).not.toContain("internet"); + }, + ); + + it("prioritizes resources with evidence before applying the graph limit", () => { + // Given - evidence belongs to resources beyond the initial graph limit. + const uids = Array.from( + { length: GRAPH_RESOURCE_LIMIT + 3 }, + (_, i) => `arn:aws:s3:::${String.fromCharCode(97 + i)}`, + ).reverse(); + + // When + const view = buildIssueGraph(issue(), uids); + + // Then - both findings remain visible within the resource limit. + expect(view.nodes.filter((n) => n.id.startsWith("resource:"))).toHaveLength( + GRAPH_RESOURCE_LIMIT, + ); + expect(ids(view)).toEqual( + expect.arrayContaining([ + "resource:arn:aws:s3:::a", + "resource:arn:aws:s3:::b", + "finding:s3_bucket_public_access · arn:aws:s3:::a", + "finding:s3_bucket_default_encryption · arn:aws:s3:::b", + ]), + ); + }); + + it("keeps the limit and skips findings whose resources do not fit", () => { + // Given - more evidence-bearing resources than the graph can display. + const uids = Array.from( + { length: GRAPH_RESOURCE_LIMIT + 1 }, + (_, i) => `resource-${i}`, + ); + const assessedIssue = issue(); + assessedIssue.legs.reachability.evidence = uids.map( + (uid) => `check · ${uid}`, + ); + + // When + const view = buildIssueGraph(assessedIssue, uids); + + // Then - preserve input order and never leave an edge without its resource. + expect( + view.nodes.filter((node) => node.id.startsWith("resource:")), + ).toHaveLength(GRAPH_RESOURCE_LIMIT); + expect(view.edges).toHaveLength(GRAPH_RESOURCE_LIMIT); + expect(ids(view)).not.toContain( + `finding:check · resource-${GRAPH_RESOURCE_LIMIT}`, + ); + for (const edge of view.edges) { + expect(ids(view)).toContain(edge.source); + expect(ids(view)).toContain(edge.target); + } + expect(uids[0]).toBe("resource-0"); + }); + + it.each([ + { evidence: [] }, + { evidence: ["malformed", " · resource-12", "check · unrelated-resource"] }, + ])( + "preserves resource order without matching valid evidence: $evidence", + ({ evidence }) => { + // Given + const uids = Array.from( + { length: GRAPH_RESOURCE_LIMIT + 1 }, + (_, i) => `resource-${i}`, + ); + const assessedIssue = issue(); + assessedIssue.legs.reachability.evidence = evidence; + + // When + const view = buildIssueGraph(assessedIssue, uids); + + // Then + expect(ids(view)).toEqual( + uids.slice(0, GRAPH_RESOURCE_LIMIT).map((uid) => `resource:${uid}`), + ); + expect(view.edges).toEqual([]); + }, + ); +}); diff --git a/ui/components/issues/issue-graph.adapter.ts b/ui/components/issues/issue-graph.adapter.ts new file mode 100644 index 0000000000..3abd73554e --- /dev/null +++ b/ui/components/issues/issue-graph.adapter.ts @@ -0,0 +1,109 @@ +import { AttackPathView } from "@/lib/attack-paths/group-graph"; +import { GraphEdge, GraphNode } from "@/types/attack-paths"; +import { IssueAttributes, IssueFinding } from "@/types/issues"; + +/** Resources drawn before the rest are left to the list below the graph. */ +export const GRAPH_RESOURCE_LIMIT = 12; + +// shortcut: covers the resource kinds the issue patterns fold today; anything +// else draws with the renderer's generic icon. Extend when a pattern adds one. +const RESOURCE_LABELS: [RegExp, string][] = [ + [/^arn:aws:s3:/, "S3Bucket"], + [/^arn:aws:iam::[^:]*:role\//, "AWSRole"], + [/^arn:aws:iam::[^:]*:user\//, "AWSUser"], + [/^arn:aws:iam::[^:]*:root$/, "AWSAccount"], + [/^arn:aws:ec2:.*:instance\//, "EC2Instance"], + [/^arn:aws:ec2:.*:security-group\//, "EC2SecurityGroup"], + [/^arn:aws:lambda:/, "AWSLambda"], + [/^arn:aws:rds:/, "RDSInstance"], + [/^arn:aws:secretsmanager:/, "Secret"], + [/\/virtualMachines\//i, "VirtualMachine"], +]; + +const resourceLabel = (uid: string) => + RESOURCE_LABELS.find(([pattern]) => pattern.test(uid))?.[1] ?? "Resource"; + +/** The last path or ARN segment: what an operator calls the resource. */ +const shortName = (uid: string) => + uid.split(/[/:]/).filter(Boolean).pop() ?? uid; + +/** Folded findings and supporting evidence associate checks with resources, not attack paths. */ +export const buildIssueGraph = ( + issue: IssueAttributes, + resourceUids: string[], + findings: IssueFinding[] = [], +): AttackPathView => { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const evidence = Object.values(issue.legs ?? {}) + .flatMap((leg) => leg?.evidence ?? []) + .map((line) => { + const [checkId, uid] = line.split(" · "); + return { line, checkId, uid }; + }) + .filter(({ checkId, uid }) => checkId && uid); + const evidenceResources = new Set(evidence.map(({ uid }) => uid)); + const findingResources = new Set( + findings.map(({ resourceUid }) => resourceUid), + ); + const resources = [ + ...resourceUids.filter((uid) => findingResources.has(uid)), + ...resourceUids.filter( + (uid) => !findingResources.has(uid) && evidenceResources.has(uid), + ), + ...resourceUids.filter( + (uid) => !findingResources.has(uid) && !evidenceResources.has(uid), + ), + ].slice(0, GRAPH_RESOURCE_LIMIT); + const resourceId = (uid: string) => `resource:${uid}`; + + resources.forEach((uid) => { + nodes.push({ + id: resourceId(uid), + labels: [resourceLabel(uid)], + properties: { id: uid, name: shortName(uid) }, + }); + }); + + const shown = new Set(resources); + const seen = new Set(); + const addFinding = ( + uid: string, + checkId: string, + properties: GraphNode["properties"], + ) => { + const line = `${checkId} · ${uid}`; + if (!shown.has(uid) || seen.has(line)) return; + seen.add(line); + const id = `finding:${line}`; + nodes.push({ + id, + labels: ["ProwlerFinding"], + properties: { + id, + check_id: checkId, + resource_uid: uid, + ...properties, + }, + }); + edges.push({ + id: `${uid}->${id}`, + source: resourceId(uid), + target: id, + type: "HAS_FINDING", + }); + }; + + findings.forEach(({ id, checkId, checkTitle, severity, resourceUid }) => + addFinding(resourceUid, checkId, { + finding_id: id, + check_title: checkTitle, + severity, + }), + ); + evidence.forEach(({ checkId, uid }) => + addFinding(uid, checkId, { check_title: checkId }), + ); + + return { nodes, edges, groupMembers: new Map() }; +}; diff --git a/ui/components/issues/issue-graph.test.tsx b/ui/components/issues/issue-graph.test.tsx new file mode 100644 index 0000000000..cecc2d9651 --- /dev/null +++ b/ui/components/issues/issue-graph.test.tsx @@ -0,0 +1,209 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ComponentProps } from "react"; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { GraphNode } from "@/types/attack-paths"; +import { IssueAttributes } from "@/types/issues"; + +const { getFindings, navigateToFinding } = vi.hoisted(() => ({ + getFindings: vi.fn(), + navigateToFinding: vi.fn(), +})); +vi.mock("@/actions/findings", () => ({ getFindings })); +vi.mock("@/hooks/use-finding-details", () => ({ + useFindingDetails: () => ({ + findingDetails: null, + findingDetailLoading: false, + resetFindingDetails: vi.fn(), + navigateToFinding, + }), +})); +vi.mock("@/components/findings/table", () => ({ + FindingDetailDrawer: () => null, +})); +vi.mock("@/components/attack-paths/graph/attack-path-graph", () => ({ + AttackPathGraph: ({ + data, + onNodeClick, + }: { + data: { nodes: GraphNode[] }; + onNodeClick: (node: GraphNode) => void; + }) => ( + + ), +})); + +import { IssueGraph } from "./issue-graph"; + +const issue = { legs: {} } as IssueAttributes; + +it("requires provider and scan identifiers together in the graph props", () => { + type Props = ComponentProps; + type BaseProps = Pick; + + expectTypeOf().toExtend(); + expectTypeOf< + BaseProps & { providerId: string; scanId: string } + >().toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); +}); + +describe("IssueGraph finding lookup", () => { + beforeEach(() => { + navigateToFinding.mockResolvedValue(undefined); + }); + + it("opens a folded finding by id without another lookup", async () => { + // Given + render( + , + ); + + // When + await userEvent.click(screen.getByRole("button", { name: "Open finding" })); + + // Then + expect(navigateToFinding).toHaveBeenCalledWith("folded-finding"); + expect(getFindings).not.toHaveBeenCalled(); + }); + + it("limits lookup to the issue provider and verifies the returned scan", async () => { + getFindings.mockResolvedValue({ + data: [ + { + id: "finding-1", + relationships: { scan: { data: { id: "scan-1" } } }, + }, + ], + }); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Open finding" })); + expect(getFindings).toHaveBeenCalledWith({ + pageSize: 1, + filters: { + "filter[provider_id]": "provider-1", + "filter[scan]": "scan-1", + "filter[check_id]": "s3_public", + "filter[resource_uid]": "bucket", + "filter[status]": "FAIL", + }, + }); + expect(navigateToFinding).toHaveBeenCalledWith("finding-1"); + }); + + it("rejects a finding from a newer scan", async () => { + getFindings.mockResolvedValue({ + data: [ + { + id: "new-finding", + relationships: { scan: { data: { id: "new-scan" } } }, + }, + ], + }); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Open finding" })); + expect(navigateToFinding).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent( + "no longer available for the issue's scan", + ); + }); + + it("reports failed requests and allows retry", async () => { + getFindings + .mockRejectedValueOnce(new Error("Offline")) + .mockResolvedValueOnce({ data: [] }); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Open finding" })); + expect(screen.getByRole("alert")).toHaveTextContent( + "Could not load this finding", + ); + await userEvent.click(screen.getByRole("button", { name: "Open finding" })); + expect(getFindings).toHaveBeenCalledTimes(2); + }); + + it("shows loading feedback and ignores concurrent clicks", async () => { + let resolve!: (value: { data: [] }) => void; + getFindings.mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Open finding" })); + expect(screen.getByRole("status")).toHaveTextContent( + "Loading finding details", + ); + await userEvent.click(screen.getByRole("button", { name: "Open finding" })); + expect(getFindings).toHaveBeenCalledOnce(); + await act(async () => resolve({ data: [] })); + await waitFor(() => + expect(screen.queryByRole("status")).not.toBeInTheDocument(), + ); + }); + + it("does not look up findings without a provider and scan", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Open finding" })); + expect(getFindings).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent( + "no provider or scan reference", + ); + }); +}); diff --git a/ui/components/issues/issue-graph.tsx b/ui/components/issues/issue-graph.tsx new file mode 100644 index 0000000000..9704831c4d --- /dev/null +++ b/ui/components/issues/issue-graph.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { getFindings } from "@/actions/findings"; +import { AttackPathGraph } from "@/components/attack-paths/graph/attack-path-graph"; +import { FindingDetailDrawer } from "@/components/findings/table"; +import { + buildIssueGraph, + GRAPH_RESOURCE_LIMIT, +} from "@/components/issues/issue-graph.adapter"; +import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { Card } from "@/components/shadcn/card/card"; +import { useFindingDetails } from "@/hooks/use-finding-details"; +import { isProwlerFindingNode } from "@/lib/attack-paths/node-types"; +import { GraphNode } from "@/types/attack-paths"; +import { IssueAttributes, IssueFinding } from "@/types/issues"; + +interface IssueGraphBaseProps { + issue: IssueAttributes; + resourceUids: string[]; + findings?: IssueFinding[]; +} + +interface ScopedIssueGraphProps extends IssueGraphBaseProps { + providerId: string; + scanId: string; +} + +interface UnscopedIssueGraphProps extends IssueGraphBaseProps { + providerId?: never; + scanId?: never; +} + +type IssueGraphProps = ScopedIssueGraphProps | UnscopedIssueGraphProps; + +export const IssueGraph = ({ + issue, + resourceUids, + findings = [], + providerId, + scanId, +}: IssueGraphProps) => { + const finding = useFindingDetails(); + const view = buildIssueGraph(issue, resourceUids, findings); + const shownFindings = new Set( + view.nodes.flatMap((node) => + node.properties.finding_id ? [String(node.properties.finding_id)] : [], + ), + ).size; + const pending = useRef(false); + const mounted = useRef(true); + const [loading, setLoading] = useState(false); + const [lookupComplete, setLookupComplete] = useState(false); + const [error, setError] = useState(null); + const expandedResources = new Set( + view.nodes + .filter((node) => node.id.startsWith("resource:")) + .map((node) => node.id), + ); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const handleNodeClick = async (node: GraphNode) => { + if (!isProwlerFindingNode(node.labels) || pending.current) return; + setError(null); + setLookupComplete(false); + finding.resetFindingDetails(); + let findingId = node.properties.finding_id; + if (!findingId && (!providerId || !scanId)) { + setError( + "Finding details are unavailable because this issue has no provider or scan reference.", + ); + return; + } + pending.current = true; + setLoading(true); + try { + if (!findingId) { + const response = await getFindings({ + pageSize: 1, + filters: { + "filter[provider_id]": providerId, + "filter[scan]": scanId, + "filter[check_id]": String(node.properties.check_id), + "filter[resource_uid]": String(node.properties.resource_uid), + "filter[status]": "FAIL", + }, + }); + if (!mounted.current) return; + if (!Array.isArray(response?.data)) { + setError("Could not load this finding. Select the finding to retry."); + return; + } + const match = response.data[0]; + // Reject mismatched responses even though the lookup is scoped to the scan. + if (!match || match.relationships?.scan?.data?.id !== scanId) { + setError( + "This finding is no longer available for the issue's scan. Refresh issues to load the latest assessment.", + ); + return; + } + findingId = match.id; + } + await finding.navigateToFinding(String(findingId)); + if (mounted.current) setLookupComplete(true); + } catch { + if (mounted.current) + setError("Could not load this finding. Select the finding to retry."); + } finally { + pending.current = false; + if (mounted.current) setLoading(false); + } + }; + const detailFailed = + lookupComplete && !finding.findingDetailLoading && !finding.findingDetails; + + return ( +
+

Finding evidence

+

+ Showing {shownFindings} of {issue.findings_count} issue findings across{" "} + {Math.min(resourceUids.length, GRAPH_RESOURCE_LIMIT)} of{" "} + {resourceUids.length} affected resources, plus supporting checks cited + by the assessment. These links show evidence associations, not confirmed + attack paths. +

+ {loading && ( +

+ Loading finding details… +

+ )} + {(error || detailFailed) && ( + + + {error ?? + "Could not load this finding. Select the finding to retry."} + + + )} + + + + {finding.findingDetails && ( + { + if (!open) { + setLookupComplete(false); + finding.resetFindingDetails(); + } + }} + /> + )} +
+ ); +}; diff --git a/ui/components/issues/issues-load-error.tsx b/ui/components/issues/issues-load-error.tsx new file mode 100644 index 0000000000..cd6f394fe1 --- /dev/null +++ b/ui/components/issues/issues-load-error.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert"; +import { Button } from "@/components/shadcn/button/button"; + +interface IssuesLoadErrorProps { + message?: string; + onRetry?: () => void; +} + +export function IssuesLoadError({ + message = "Failed to load issues.", + onRetry, +}: IssuesLoadErrorProps) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + return ( + + {message} + + + + + ); +} diff --git a/ui/components/issues/issues-summary.tsx b/ui/components/issues/issues-summary.tsx new file mode 100644 index 0000000000..f2a787602a --- /dev/null +++ b/ui/components/issues/issues-summary.tsx @@ -0,0 +1,59 @@ +import { CircleHelp, Layers, ShieldCheck, TriangleAlert } from "lucide-react"; + +import { CardVariant, ResourceStatsCard } from "@/components/shadcn"; +import { IssuesOverviewResponse } from "@/types/issues"; + +/** The fold, as the tiles above the issue list. */ +export const IssuesSummary = ({ + overview, +}: { + overview: IssuesOverviewResponse["data"]["attributes"]; +}) => { + return ( +
+ + + + + +
+ ); +}; diff --git a/ui/components/issues/issues-table.test.tsx b/ui/components/issues/issues-table.test.tsx new file mode 100644 index 0000000000..815262b3ba --- /dev/null +++ b/ui/components/issues/issues-table.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { IssueResponse } from "@/types/issues"; + +import { IssuesTable } from "./issues-table"; + +const { push, getIssue } = vi.hoisted(() => ({ + push: vi.fn(), + getIssue: vi.fn(), +})); +vi.mock("next/navigation", () => ({ + usePathname: () => "/issues", + useRouter: () => ({ push, refresh: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); +vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() })); +vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null })); +vi.mock("@/actions/issues", () => ({ getIssue })); +vi.mock("@/components/issues/issue-graph", () => ({ IssueGraph: () => null })); + +const issue: IssueResponse["data"][number] = { + type: "issues", + id: "issue-1", + attributes: { + pattern: "aws_public_bucket", + cloud: "aws", + account: "prod", + title: "Public bucket", + verdict: "likely", + severity: "high", + findings_count: 3, + fix: "Restrict access", + inserted_at: "", + updated_at: "", + legs: Object.fromEntries( + ["reachability", "access", "target", "controls", "verification"].map( + (name) => [ + name, + { state: "unknown", text: "Not observed", evidence: [] }, + ], + ), + ) as unknown as IssueResponse["data"][number]["attributes"]["legs"], + }, +}; + +describe("IssuesTable", () => { + beforeEach(() => { + getIssue.mockResolvedValue({ data: { attributes: { resource_uids: [] } } }); + }); + + it("sorts findings and verdicts through the URL", async () => { + // Given + const user = userEvent.setup(); + render(); + // When + await user.click(screen.getByRole("button", { name: "Findings" })); + // Then + expect(push).toHaveBeenLastCalledWith("/issues?sort=findings_count", { + scroll: false, + }); + // When + await user.click(screen.getByRole("button", { name: "Verdict" })); + // Then + expect(push).toHaveBeenLastCalledWith("/issues?sort=verdict_rank", { + scroll: false, + }); + }); + it("opens and closes the detail with the keyboard", async () => { + // Given + const user = userEvent.setup(); + render(); + // When + const toggle = screen.getByRole("button", { name: "Expand Public bucket" }); + toggle.focus(); + await user.keyboard("{Enter}"); + // Then + expect( + screen.getByRole("button", { name: "Collapse Public bucket" }), + ).toHaveAttribute("aria-expanded", "true"); + expect(await screen.findByText("Restrict access")).toBeInTheDocument(); + // When + await user.keyboard(" "); + // Then + expect(screen.queryByText("Restrict access")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/issues/issues-table.tsx b/ui/components/issues/issues-table.tsx new file mode 100644 index 0000000000..746f965e79 --- /dev/null +++ b/ui/components/issues/issues-table.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { ColumnDef, Row } from "@tanstack/react-table"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { createContext, useContext, useState } from "react"; + +import { IssueDetail } from "@/components/issues/issue-detail"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { EntityInfo } from "@/components/shadcn/entities"; +import { DataTable, DataTableColumnHeader } from "@/components/shadcn/table"; +import { TableCell, TableRow } from "@/components/shadcn/table/table"; +import { VERDICTS } from "@/lib/issues/verdicts"; +import { MetaDataProps } from "@/types"; +import { IssueResponse } from "@/types/issues"; + +type IssueRow = IssueResponse["data"][number]; + +interface IssueExpansion { + openId: string | null; + toggle: (id: string) => void; +} + +const IssueExpansionContext = createContext(null); + +function IssueExpandCell({ row }: { row: Row }) { + const expansion = useContext(IssueExpansionContext); + const expanded = expansion?.openId === row.original.id; + return ( + + ); +} + +const columns: ColumnDef[] = [ + { id: "expand", header: "", cell: IssueExpandCell }, + { + id: "issue", + header: "Issue", + cell: ({ row }) => ( +
+

+ {row.original.attributes.title} +

+

+ {row.original.attributes.pattern} +

+
+ ), + }, + { + id: "provider", + header: "Provider", + cell: ({ row }) => ( + + ), + }, + { + id: "findings", + accessorKey: "attributes.findings_count", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.attributes.findings_count} + + ), + }, + { + id: "verdict", + accessorKey: "attributes.verdict", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const verdict = VERDICTS.find( + (v) => v.id === row.original.attributes.verdict, + ); + return ( + + {verdict?.label ?? row.original.attributes.verdict} + + ); + }, + }, +]; + +export const IssuesTable = ({ + issues, + metadata, +}: { + issues: IssueRow[]; + metadata?: MetaDataProps; +}) => { + // One open row at a time: each detail fetches its issue's resource ids. + const [openId, setOpenId] = useState(null); + + const toggleRow = (id: string) => + setOpenId((current) => (current === id ? null : id)); + const renderAfterRow = (row: Row) => + row.original.id === openId ? ( + + +
+ +
+
+
+ ) : null; + + return ( +
+ + toggleRow(row.original.id)} + renderAfterRow={renderAfterRow} + /> + +
+ ); +}; diff --git a/ui/components/issues/issues.integration.test.tsx b/ui/components/issues/issues.integration.test.tsx new file mode 100644 index 0000000000..90e967ad4d --- /dev/null +++ b/ui/components/issues/issues.integration.test.tsx @@ -0,0 +1,255 @@ +import { http, HttpResponse } from "msw"; +import { afterEach, describe, expect, it } from "vitest"; +import { page, userEvent } from "vitest/browser"; + +import { worker } from "@/__tests__/msw/worker"; +import { render } from "@/__tests__/render-browser"; +import { ExploitabilityChart } from "@/app/(prowler)/_overview/exploitability/_components/exploitability-chart"; +import { TopIssues } from "@/app/(prowler)/_overview/exploitability/_components/top-issues"; +import { IssuesList } from "@/app/(prowler)/issues/_components/issues-list"; +import { IssueResource, IssuesOverviewAttributes } from "@/types/issues"; + +const resource = `arn:aws:s3:::production-customer-data-${"long-resource-name-".repeat(8)}`; +const issue: IssueResource = { + type: "issues", + id: "issue-1", + attributes: { + pattern: "aws_public_bucket", + cloud: "aws", + account: "Production", + title: "Public bucket exposes customer data", + verdict: "likely", + severity: "high", + findings_count: 3, + fix: "Enable S3 Block Public Access and review the bucket policy.", + inserted_at: "", + updated_at: "", + legs: { + reachability: { + state: "yes", + text: "Public access is enabled.", + evidence: [`s3_public · ${resource}`], + }, + access: { + state: "partial", + text: "Some access controls are in place.", + evidence: [], + }, + target: { + state: "unknown", + text: "Data sensitivity has not been verified.", + evidence: [], + }, + controls: { + state: "no", + text: "Block Public Access is disabled.", + evidence: [], + }, + verification: { + state: "unknown", + text: "Logging has not been verified.", + evidence: [], + }, + }, + }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + scan: { data: { type: "scans", id: "scan-1" } }, + }, +}; +const summary: IssuesOverviewAttributes = { + exploitable: 0, + likely: 1, + conditional: 0, + not_exploitable: 0, + unknown: 0, + findings: 3, + issues: 1, + actionable: 1, + not_exploitable_findings: 0, +}; + +const setup = () => { + window.history.replaceState( + null, + "", + "/issues?filter[pattern]=aws_public_bucket", + ); + worker.use( + http.get("*/issues/overview", () => + HttpResponse.json({ + data: { type: "issues-overview", attributes: summary }, + }), + ), + http.get("*/issues", () => HttpResponse.json({ data: [issue] })), + http.get("*/issues/issue-1", () => + HttpResponse.json({ + data: { + ...issue, + attributes: { + ...issue.attributes, + resource_uids: [resource], + finding_uids: ["f1"], + }, + }, + }), + ), + http.get("*/findings", () => + HttpResponse.json({ + data: [ + { + type: "findings", + id: "f1", + attributes: { + check_id: "s3_public", + severity: "critical", + check_metadata: { checktitle: "S3 bucket is public" }, + }, + relationships: { + resources: { data: [{ type: "resources", id: "r1" }] }, + }, + }, + ], + included: [ + { type: "resources", id: "r1", attributes: { uid: resource } }, + ], + }), + ), + ); +}; + +describe("Issues responsive layout", () => { + afterEach(async () => { + localStorage.removeItem("theme"); + await page.viewport(1280, 800); + }); + + it.each([ + { width: 375, theme: "light" }, + { width: 768, theme: "light" }, + { width: 1440, theme: "light" }, + { width: 375, theme: "dark" }, + { width: 1440, theme: "dark" }, + ])( + "keeps the filters and expanded detail usable at $width px in $theme mode", + async ({ width, theme }) => { + await page.viewport(width, 1000); + localStorage.setItem("theme", theme); + setup(); + const content = await IssuesList({ + searchParams: { "filter[pattern]": "aws_public_bucket" }, + }); + const screen = await render( +
+

Issues

+ {content} +
, + ); + await expect + .element(screen.getByRole("button", { name: "Remove Pattern filter" })) + .toBeVisible(); + await screen + .getByRole("button", { + name: "Expand Public bucket exposes customer data", + }) + .click(); + await expect + .element(screen.getByRole("region", { name: "Affected resources" })) + .toBeVisible(); + await expect + .element(screen.getByText("Partly", { exact: true })) + .toBeVisible(); + await expect + .element( + screen.getByRole("img", { name: "Critical finding risk icon" }), + ) + .toBeVisible(); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width); + const detailBounds = screen + .getByRole("region", { name: "Affected resources" }) + .element() + .getBoundingClientRect(); + expect(detailBounds.right).toBeLessThanOrEqual(width); + if (width < 640) { + await expect + .poll(() => { + const node = screen + .getByTestId("attack-path-finding-node") + .element(); + const canvas = node.closest(".react-flow")!.getBoundingClientRect(); + const bounds = node.getBoundingClientRect(); + return bounds.left >= canvas.left && bounds.right <= canvas.right; + }) + .toBe(true); + } + + // Long resource identifiers and evidence must wrap within the detail. + for (const snippet of screen + .getByText(resource, { exact: true }) + .elements()) { + expect(snippet.scrollWidth).toBeLessThanOrEqual( + snippet.clientWidth + 1, + ); + } + await page.viewport( + width, + Math.max(1000, document.documentElement.scrollHeight), + ); + await screen.getByRole("main").screenshot(); + await screen + .getByRole("button", { + name: "Collapse Public bucket exposes customer data", + }) + .click(); + await expect + .element(screen.getByRole("region", { name: "Affected resources" })) + .not.toBeInTheDocument(); + }, + ); + + it("keeps keyboard focus on the expansion control", async () => { + setup(); + const screen = await render(await IssuesList({ searchParams: {} })); + const toggle = screen.getByRole("button", { + name: "Expand Public bucket exposes customer data", + }); + toggle.element().focus(); + await userEvent.keyboard("{Enter}"); + await expect + .element( + screen.getByRole("button", { + name: "Collapse Public bucket exposes customer data", + }), + ) + .toHaveFocus(); + await userEvent.keyboard(" "); + await expect.element(toggle).toHaveFocus(); + }); + + it.each([375, 768, 1440])( + "contains overview cards at %s px", + async (width) => { + await page.viewport(width, 1000); + const screen = await render( +
+ + +
, + ); + await expect + .element( + screen.getByRole("link", { name: "1 exploitable or likely issues" }), + ) + .toBeVisible(); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width); + await screen.getByRole("main").screenshot(); + }, + ); +}); diff --git a/ui/components/issues/verdict-filter.test.tsx b/ui/components/issues/verdict-filter.test.tsx new file mode 100644 index 0000000000..b6dae05e85 --- /dev/null +++ b/ui/components/issues/verdict-filter.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { VerdictFilter } from "@/components/issues/verdict-filter"; + +const navigationState = vi.hoisted(() => ({ + searchParams: new URLSearchParams(), + push: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/issues", + useRouter: () => ({ push: navigationState.push }), + useSearchParams: () => navigationState.searchParams, +})); + +vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null })); + +const counts = { + exploitable: 2, + likely: 0, + conditional: 0, + not_exploitable: 0, + unknown: 0, +}; + +const hrefOf = (name: RegExp) => + screen.getByRole("link", { name }).getAttribute("href"); + +describe("VerdictFilter", () => { + it("drops the page when a verdict is set or cleared, keeping other params", () => { + navigationState.searchParams = new URLSearchParams( + "page=3&pageSize=10&filter[verdict]=likely", + ); + render(); + + const set = new URLSearchParams(hrefOf(/^exploitable 2$/i)!.split("?")[1]); + expect(set.get("page")).toBeNull(); + expect(set.get("pageSize")).toBe("10"); + expect(set.get("filter[verdict]")).toBe("exploitable"); + + const cleared = new URLSearchParams(hrefOf(/likely/i)!.split("?")[1]); + expect(cleared.get("page")).toBeNull(); + expect(cleared.get("filter[verdict]")).toBeNull(); + }); + + it("offers an All chip that clears the verdict and is active without one", () => { + navigationState.searchParams = new URLSearchParams( + "filter[verdict]=likely", + ); + render(); + + expect(hrefOf(/^all 2$/i)).toBe("/issues"); + }); + it("shows the pattern filter and lets the user remove it", async () => { + // Given + const user = userEvent.setup(); + navigationState.searchParams = new URLSearchParams( + "page=3&filter[pattern]=aws_public_bucket&filter[provider_id__in]=provider-1", + ); + render(); + // When + await user.click( + screen.getByRole("button", { + name: "Remove Pattern filter: aws_public_bucket", + }), + ); + // Then + const target = new URL( + navigationState.push.mock.calls.at(-1)![0], + "http://localhost", + ); + expect(target.searchParams.has("filter[pattern]")).toBe(false); + expect(target.searchParams.get("filter[provider_id__in]")).toBe( + "provider-1", + ); + expect(target.searchParams.get("page")).toBe("1"); + }); +}); diff --git a/ui/components/issues/verdict-filter.tsx b/ui/components/issues/verdict-filter.tsx new file mode 100644 index 0000000000..0d86624917 --- /dev/null +++ b/ui/components/issues/verdict-filter.tsx @@ -0,0 +1,104 @@ +"use client"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; + +import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; +import { FilterSummaryStrip } from "@/components/filters/filter-summary-strip"; +import { Button } from "@/components/shadcn/button/button"; +import { useUrlFilters } from "@/hooks/use-url-filters"; +import { VERDICTS } from "@/lib/issues/verdicts"; +import type { Verdict } from "@/types/issues"; + +interface VerdictSummary { + counts: Record; + total: number; +} + +const EXTRA_FILTER_LABELS = { + "filter[pattern]": "Pattern", + "filter[cloud]": "Cloud", + "filter[severity]": "Severity", + "filter[provider_type]": "Provider type", + "filter[provider_alias]": "Account", + "filter[verdict__in]": "Verdicts", +} as const; + +export function VerdictFilter({ summary }: { summary?: VerdictSummary }) { + const searchParams = useSearchParams(); + const { clearFilter } = useUrlFilters(); + const active = searchParams.get("filter[verdict]"); + const multiple = searchParams.get("filter[verdict__in]"); + + const hrefFor = (verdict: Verdict | null) => { + const params = new URLSearchParams(searchParams.toString()); + params.delete("page"); + params.delete("filter[verdict__in]"); + if (verdict === null || active === verdict) + params.delete("filter[verdict]"); + else params.set("filter[verdict]", verdict); + return params.size ? `/issues?${params}` : "/issues"; + }; + + const options = [ + { id: null, label: "All", count: summary?.total }, + ...VERDICTS.map(({ id, label }) => ({ + id, + label, + count: summary?.counts[id], + })), + ]; + const chips = Object.entries(EXTRA_FILTER_LABELS).flatMap(([key, label]) => { + const value = searchParams.get(key); + if (!value) return []; + const displayValue = + key === "filter[verdict__in]" + ? value + .split(",") + .map( + (id) => + VERDICTS.find((verdict) => verdict.id === id)?.label ?? id, + ) + .join(", ") + : value; + return [{ key, label, value, displayValue }]; + }); + + return ( +
+
+ {options.map((option) => { + const isActive = + !multiple && + (active === option.id || (!active && option.id === null)); + return ( + + ); + })} +
+ clearFilter(key)} + trailingContent={} + /> +
+ ); +} diff --git a/ui/components/layout/app-sidebar/navigation-config.test.ts b/ui/components/layout/app-sidebar/navigation-config.test.ts index d79144e5d0..5eceb3b38e 100644 --- a/ui/components/layout/app-sidebar/navigation-config.test.ts +++ b/ui/components/layout/app-sidebar/navigation-config.test.ts @@ -124,6 +124,9 @@ describe("getNavigationConfig", () => { // Then expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]); + expect(items.find((item) => item.label === "Issues")).toMatchObject({ + href: "/issues", + }); expect(configuration.children).not.toEqual( expect.arrayContaining([ expect.objectContaining({ label: "CLI Import" }), diff --git a/ui/components/layout/app-sidebar/navigation-config.ts b/ui/components/layout/app-sidebar/navigation-config.ts index aa0904d3b9..9fb7d5b010 100644 --- a/ui/components/layout/app-sidebar/navigation-config.ts +++ b/ui/components/layout/app-sidebar/navigation-config.ts @@ -1,6 +1,7 @@ import { Code, CreditCard, + Crosshair, FileText, GitBranch, LayoutGrid, @@ -160,6 +161,18 @@ export function getNavigationConfig({ icon: Tag, active: isRouteActive(pathname, "/findings"), }, + ...(isCloudEnvironment + ? [ + { + kind: NAVIGATION_ITEM_KIND.LINK, + href: "/issues", + label: "Issues", + icon: Crosshair, + active: isRouteActive(pathname, "/issues"), + highlight: true, + } as const, + ] + : []), { kind: NAVIGATION_ITEM_KIND.LINK, href: "/attack-paths", diff --git a/ui/components/providers/radio-group-provider.integration.test.tsx b/ui/components/providers/radio-group-provider.integration.test.tsx index 072d5ffd88..e7a2d7f4e3 100644 --- a/ui/components/providers/radio-group-provider.integration.test.tsx +++ b/ui/components/providers/radio-group-provider.integration.test.tsx @@ -8,7 +8,8 @@ import type { AddProviderFormValues } from "@/types/formSchemas"; import { RadioGroupProvider } from "./radio-group-provider"; -const LOGO_URL = "https://media.registry.example.com/providers/vcf/logo.png"; +// Keep even speculative browser requests on loopback; MSW serves the image. +const LOGO_URL = "https://127.0.0.1/providers/vcf/logo.png"; function Selector() { const form = useForm(); @@ -52,6 +53,9 @@ describe("provider selector logos", () => { await expect .poll(() => registry.querySelector("img")?.getAttribute("src")) .toBe(LOGO_URL); + await expect + .poll(() => registry.querySelector("img")?.naturalWidth) + .toBe(1); // Then expect(logoBox(registry)).toEqual(logoBox(builtIn)); diff --git a/ui/components/providers/table/registry-row-logo.integration.test.tsx b/ui/components/providers/table/registry-row-logo.integration.test.tsx index 0831b144d5..6ce5109467 100644 --- a/ui/components/providers/table/registry-row-logo.integration.test.tsx +++ b/ui/components/providers/table/registry-row-logo.integration.test.tsx @@ -6,7 +6,8 @@ import { render } from "@/__tests__/render-browser"; import { RegistryLogosContext, RegistryRowLogo } from "./registry-row-logo"; -const LOGO_URL = "https://media.registry.example.com/providers/vcf/logo.png"; +// Keep even speculative browser requests on loopback; MSW serves the image. +const LOGO_URL = "https://127.0.0.1/providers/vcf/logo.png"; describe("registry row logo", () => { it("shows the generic glyph until the streamed logos arrive, then the registry logo", async () => { @@ -32,5 +33,6 @@ describe("registry row logo", () => { // Then await expect.poll(() => logoImage()?.getAttribute("src")).toBe(LOGO_URL); + await expect.poll(() => logoImage()?.naturalWidth).toBe(1); }); }); diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx index f686c7c628..2285ef4787 100644 --- a/ui/components/resources/table/resource-detail-content.tsx +++ b/ui/components/resources/table/resource-detail-content.tsx @@ -33,6 +33,7 @@ import { DataTable } from "@/components/shadcn/table"; import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline"; import { ExternalResourceLink } from "@/components/shared/external-resource-link"; import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel"; +import { useFindingDetails } from "@/hooks/use-finding-details"; import { getGroupLabel } from "@/lib/categories"; import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage"; import { getRegionFlag } from "@/lib/region-flags"; @@ -41,7 +42,6 @@ import type { UpdateFindingTriageInput } from "@/types/findings-triage"; import type { ResourceFinding } from "@/types/resources"; import { getResourceFindingsColumns } from "./resource-findings-columns"; -import { useFindingDetails } from "./use-finding-details"; import { useResourceDrawerBootstrap } from "./use-resource-drawer-bootstrap"; const renderValue = (value: string | null | undefined) => { diff --git a/ui/components/resources/table/use-finding-details.ts b/ui/hooks/use-finding-details.ts similarity index 100% rename from ui/components/resources/table/use-finding-details.ts rename to ui/hooks/use-finding-details.ts diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/edge-orientation.ts b/ui/lib/attack-paths/edge-orientation.ts similarity index 100% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/edge-orientation.ts rename to ui/lib/attack-paths/edge-orientation.ts diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts b/ui/lib/attack-paths/export.test.ts similarity index 98% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts rename to ui/lib/attack-paths/export.test.ts index 0e1e3ff2f3..90bbf0a758 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts +++ b/ui/lib/attack-paths/export.test.ts @@ -1,10 +1,9 @@ import type { Rect } from "@xyflow/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { exportGraphAsJSON, exportGraphAsPNG } from "@/lib/attack-paths/export"; import type { AttackPathGraphData } from "@/types/attack-paths"; -import { exportGraphAsJSON, exportGraphAsPNG } from "./export"; - const bounds: Rect = { x: 0, y: 0, width: 100, height: 100 }; const graphData: AttackPathGraphData = { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts b/ui/lib/attack-paths/export.ts similarity index 97% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts rename to ui/lib/attack-paths/export.ts index 14ac418e35..79cee3a45f 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts +++ b/ui/lib/attack-paths/export.ts @@ -7,9 +7,7 @@ import type { Rect } from "@xyflow/react"; -import type { AttackPathGraphData, GraphEdge } from "@/types/attack-paths"; - -import { truncateLabel } from "./format"; +import { truncateLabel } from "@/lib/attack-paths/format"; import { getNodeBorderColor, getNodeColor, @@ -18,23 +16,24 @@ import { GRAPH_EDGE_COLOR_DARK, GRAPH_OUTCOME_BORDER_COLOR, GRAPH_OUTCOME_FILL_COLOR, -} from "./graph-colors"; +} from "@/lib/attack-paths/graph-colors"; import { GROUP_NODE_LABEL, GROUP_PROPS, OUTCOME_NODE_LABEL, OUTCOME_PROPS, -} from "./group-graph"; -import { layoutWithDagre } from "./layout"; +} from "@/lib/attack-paths/group-graph"; +import { layoutWithDagre } from "@/lib/attack-paths/layout"; import { FINDING_NODE_DIMENSIONS, GROUP_NODE_DIMENSIONS, OUTCOME_NODE_DIMENSIONS, RESOURCE_NODE_DIMENSIONS, -} from "./node-dimensions"; -import { getNodeLabelDisplay } from "./node-label-lines"; -import { isProwlerFindingNode } from "./node-types"; -import { resolveNodeVisual } from "./node-visuals"; +} from "@/lib/attack-paths/node-dimensions"; +import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines"; +import { isProwlerFindingNode } from "@/lib/attack-paths/node-types"; +import { resolveNodeVisual } from "@/lib/attack-paths/node-visuals"; +import type { AttackPathGraphData, GraphEdge } from "@/types/attack-paths"; interface ExportGraphOptions { expandedResources?: ReadonlySet; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts b/ui/lib/attack-paths/format.ts similarity index 100% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts rename to ui/lib/attack-paths/format.ts diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.test.ts b/ui/lib/attack-paths/graph-colors.test.ts similarity index 94% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.test.ts rename to ui/lib/attack-paths/graph-colors.test.ts index f508ff4d39..6f8747280f 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.test.ts +++ b/ui/lib/attack-paths/graph-colors.test.ts @@ -7,12 +7,12 @@ import { GRAPH_OUTCOME_BORDER_COLOR, GRAPH_OUTCOME_FILL_COLOR, resolveNodeColors, -} from "./graph-colors"; +} from "@/lib/attack-paths/graph-colors"; import { GROUP_NODE_LABEL, GROUP_PROPS, OUTCOME_NODE_LABEL, -} from "./group-graph"; +} from "@/lib/attack-paths/group-graph"; describe("resolveNodeColors", () => { it("prioritizes selected state over hasFindings for the border color", () => { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts b/ui/lib/attack-paths/graph-colors.ts similarity index 98% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts rename to ui/lib/attack-paths/graph-colors.ts index 071b857558..c1706ce28c 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts +++ b/ui/lib/attack-paths/graph-colors.ts @@ -2,8 +2,8 @@ import { GROUP_NODE_LABEL, GROUP_PROPS, OUTCOME_NODE_LABEL, -} from "./group-graph"; -import { isProwlerFindingNode } from "./node-types"; +} from "@/lib/attack-paths/group-graph"; +import { isProwlerFindingNode } from "@/lib/attack-paths/node-types"; /** * Color constants for attack path graph visualization diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts b/ui/lib/attack-paths/graph-utils.ts similarity index 98% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts rename to ui/lib/attack-paths/graph-utils.ts index c730f7a699..0c2d5df27b 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts +++ b/ui/lib/attack-paths/graph-utils.ts @@ -2,10 +2,9 @@ * Utility functions for attack path graph operations */ +import { isProwlerFindingNode } from "@/lib/attack-paths/node-types"; import type { AttackPathGraphData } from "@/types/attack-paths"; -import { isProwlerFindingNode } from "./node-types"; - export const resolveHiddenFindingIds = ({ expandedResources, findingNodeIds, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.test.ts b/ui/lib/attack-paths/group-graph.test.ts similarity index 99% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.test.ts rename to ui/lib/attack-paths/group-graph.test.ts index fac8e68724..f613f58a4a 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.test.ts +++ b/ui/lib/attack-paths/group-graph.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { - AttackPathGraphData, - AttackPathOutcome, - GraphNode, -} from "@/types/attack-paths"; - import { buildAttackPathView, GROUP_NODE_LABEL, @@ -13,7 +7,12 @@ import { groupKey, OUTCOME_NODE_LABEL, OUTCOME_PROPS, -} from "./group-graph"; +} from "@/lib/attack-paths/group-graph"; +import type { + AttackPathGraphData, + AttackPathOutcome, + GraphNode, +} from "@/types/attack-paths"; const node = (id: string, label: string): GraphNode => ({ id, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.ts b/ui/lib/attack-paths/group-graph.ts similarity index 98% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.ts rename to ui/lib/attack-paths/group-graph.ts index 6c252dfdd5..9c7fef5b5a 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.ts +++ b/ui/lib/attack-paths/group-graph.ts @@ -20,6 +20,12 @@ * over stable raw data, never a data mutation. */ +import { orientEdgeForLayout } from "@/lib/attack-paths/edge-orientation"; +import { isProwlerFindingNode } from "@/lib/attack-paths/node-types"; +import { + NODE_CATEGORY, + resolveNodeVisual, +} from "@/lib/attack-paths/node-visuals"; import type { AttackPathGraphData, AttackPathOutcome, @@ -28,10 +34,6 @@ import type { GraphRelationship, } from "@/types/attack-paths"; -import { orientEdgeForLayout } from "./edge-orientation"; -import { isProwlerFindingNode } from "./node-types"; -import { NODE_CATEGORY, resolveNodeVisual } from "./node-visuals"; - // Synthetic labels so layout/getNodeType and the node registry can identify the // injected nodes. Real graph labels never collide with these. export const GROUP_NODE_LABEL = "__AttackPathGroup"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts b/ui/lib/attack-paths/index.ts similarity index 52% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts rename to ui/lib/attack-paths/index.ts index 86da6e7e37..a3d204b1b9 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts +++ b/ui/lib/attack-paths/index.ts @@ -1,5 +1,9 @@ -export { exportGraphAsJSON, exportGraphAsPNG } from "./export"; -export { formatNodeLabel, formatNodeLabels, truncateLabel } from "./format"; +export { exportGraphAsJSON, exportGraphAsPNG } from "@/lib/attack-paths/export"; +export { + formatNodeLabel, + formatNodeLabels, + truncateLabel, +} from "@/lib/attack-paths/format"; export { getNodeBorderColor, getNodeColor, @@ -14,17 +18,20 @@ export { GRAPH_OUTCOME_FILL_COLOR, GRAPH_SELECTION_COLOR, resolveNodeColors, -} from "./graph-colors"; +} from "@/lib/attack-paths/graph-colors"; export { computeFilteredSubgraph, getPathEdges, resolveHiddenFindingIds, -} from "./graph-utils"; -export { layoutWithDagre } from "./layout"; -export { isProwlerFindingLabel, isProwlerFindingNode } from "./node-types"; +} from "@/lib/attack-paths/graph-utils"; +export { layoutWithDagre } from "@/lib/attack-paths/layout"; +export { + isProwlerFindingLabel, + isProwlerFindingNode, +} from "@/lib/attack-paths/node-types"; export { NODE_CATEGORY, type NodeCategory, type NodeVisual, resolveNodeVisual, -} from "./node-visuals"; +} from "@/lib/attack-paths/node-visuals"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts b/ui/lib/attack-paths/layout.test.ts similarity index 99% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts rename to ui/lib/attack-paths/layout.test.ts index 87a117771c..391861967f 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts +++ b/ui/lib/attack-paths/layout.test.ts @@ -1,10 +1,9 @@ import { Position } from "@xyflow/react"; import { describe, expect, it } from "vitest"; +import { layoutWithDagre } from "@/lib/attack-paths/layout"; import type { GraphEdge, GraphNode } from "@/types/attack-paths"; -import { layoutWithDagre } from "./layout"; - const findingNode: GraphNode = { id: "finding-1", labels: ["ProwlerFinding"], diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts b/ui/lib/attack-paths/layout.ts similarity index 94% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts rename to ui/lib/attack-paths/layout.ts index ab3596bff2..8204ba5b7f 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts +++ b/ui/lib/attack-paths/layout.ts @@ -6,18 +6,20 @@ import { Graph, layout as dagreLayout } from "@dagrejs/dagre"; import { type Edge, type Node, Position } from "@xyflow/react"; -import type { GraphEdge, GraphNode } from "@/types/attack-paths"; - -import { orientEdgeForLayout } from "./edge-orientation"; -import { GROUP_NODE_LABEL, OUTCOME_NODE_LABEL } from "./group-graph"; +import { orientEdgeForLayout } from "@/lib/attack-paths/edge-orientation"; +import { + GROUP_NODE_LABEL, + OUTCOME_NODE_LABEL, +} from "@/lib/attack-paths/group-graph"; import { FINDING_NODE_DIMENSIONS, GROUP_NODE_DIMENSIONS, INTERNET_NODE_DIMENSIONS, OUTCOME_NODE_DIMENSIONS, RESOURCE_NODE_DIMENSIONS, -} from "./node-dimensions"; -import { isProwlerFindingNode } from "./node-types"; +} from "@/lib/attack-paths/node-dimensions"; +import { isProwlerFindingNode } from "@/lib/attack-paths/node-types"; +import type { GraphEdge, GraphNode } from "@/types/attack-paths"; interface NodeData extends Record { graphNode: GraphNode; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts b/ui/lib/attack-paths/node-dimensions.ts similarity index 100% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts rename to ui/lib/attack-paths/node-dimensions.ts diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts b/ui/lib/attack-paths/node-label-lines.test.ts similarity index 92% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts rename to ui/lib/attack-paths/node-label-lines.test.ts index 89c8060d57..d9887e7a1c 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts +++ b/ui/lib/attack-paths/node-label-lines.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getNodeLabelDisplay } from "./node-label-lines"; +import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines"; describe("getNodeLabelDisplay", () => { it("adds an ellipsis within the max width when wrapped label text exceeds the visible line budget", () => { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts b/ui/lib/attack-paths/node-label-lines.ts similarity index 100% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts rename to ui/lib/attack-paths/node-label-lines.ts diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-types.ts b/ui/lib/attack-paths/node-types.ts similarity index 100% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-types.ts rename to ui/lib/attack-paths/node-types.ts diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts b/ui/lib/attack-paths/node-visuals.test.ts similarity index 99% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts rename to ui/lib/attack-paths/node-visuals.test.ts index 48790f29e7..7527feef99 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts +++ b/ui/lib/attack-paths/node-visuals.test.ts @@ -33,10 +33,12 @@ import { AWSIAMIcon, AWSLambdaIcon, } from "@/components/icons/services/IconServices"; +import { + NODE_CATEGORY, + resolveNodeVisual, +} from "@/lib/attack-paths/node-visuals"; import type { GraphNode } from "@/types/attack-paths"; -import { NODE_CATEGORY, resolveNodeVisual } from "./node-visuals"; - const buildNode = (labels: string[], properties = {}): GraphNode => ({ id: labels[0] ?? "unknown-node", labels, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts b/ui/lib/attack-paths/node-visuals.ts similarity index 98% rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts rename to ui/lib/attack-paths/node-visuals.ts index e291855737..b81cfd2894 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts +++ b/ui/lib/attack-paths/node-visuals.ts @@ -46,11 +46,10 @@ import { AWSIAMIcon, AWSLambdaIcon, } from "@/components/icons/services/IconServices"; +import { formatNodeLabel } from "@/lib/attack-paths/format"; +import { isProwlerFindingLabel } from "@/lib/attack-paths/node-types"; import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths"; -import { formatNodeLabel } from "./format"; -import { isProwlerFindingLabel } from "./node-types"; - export const NODE_CATEGORY = { FINDING: "finding", INTERNET: "internet", diff --git a/ui/lib/issues/scope.ts b/ui/lib/issues/scope.ts new file mode 100644 index 0000000000..f84a0dba5a --- /dev/null +++ b/ui/lib/issues/scope.ts @@ -0,0 +1,27 @@ +import { SearchParamsProps } from "@/types"; + +/** The overview's scope selectors that the issues endpoint also understands. */ +export const ISSUE_SCOPE_KEYS = [ + "filter[provider_id__in]", + "filter[provider_type__in]", + "filter[provider_groups__in]", +]; + +export const pickIssueScope = ( + params: SearchParamsProps | undefined | null, +): Record => + Object.fromEntries( + ISSUE_SCOPE_KEYS.flatMap((key) => { + const value = params?.[key]; + return typeof value === "string" && value ? [[key, value]] : []; + }), + ); + +/** The issues route, keeping the overview scope so the counts still match. */ +export const issuesHref = ( + scope: Record, + extra: Record = {}, +) => { + const query = new URLSearchParams({ ...scope, ...extra }).toString(); + return query ? `/issues?${query}` : "/issues"; +}; diff --git a/ui/lib/issues/verdicts.test.ts b/ui/lib/issues/verdicts.test.ts new file mode 100644 index 0000000000..897a5d1134 --- /dev/null +++ b/ui/lib/issues/verdicts.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { verdictCounts } from "@/lib/issues/verdicts"; + +describe("verdictCounts", () => { + it("keeps every verdict count and drops the summary totals", () => { + const summary = { + exploitable: 3, + likely: 2, + conditional: 1, + not_exploitable: 4, + unknown: 5, + issues: 15, + findings: 40, + actionable: 6, + }; + + expect(verdictCounts(summary)).toEqual({ + exploitable: 3, + likely: 2, + conditional: 1, + not_exploitable: 4, + unknown: 5, + }); + }); + + it("zeroes verdicts missing from a partial or absent summary", () => { + expect(verdictCounts({ likely: 2 })).toEqual({ + exploitable: 0, + likely: 2, + conditional: 0, + not_exploitable: 0, + unknown: 0, + }); + expect(verdictCounts(undefined)).toEqual({ + exploitable: 0, + likely: 0, + conditional: 0, + not_exploitable: 0, + unknown: 0, + }); + }); +}); diff --git a/ui/lib/issues/verdicts.ts b/ui/lib/issues/verdicts.ts new file mode 100644 index 0000000000..e64dd4d16f --- /dev/null +++ b/ui/lib/issues/verdicts.ts @@ -0,0 +1,46 @@ +import { ComponentProps } from "react"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import { LegName, LegState, Verdict } from "@/types/issues"; + +interface VerdictDisplay { + id: Verdict; + label: string; + variant: ComponentProps["variant"]; +} + +export const VERDICTS: VerdictDisplay[] = [ + { id: "exploitable", label: "Exploitable", variant: "error" }, + { id: "likely", label: "Likely", variant: "error" }, + { id: "conditional", label: "Conditional", variant: "outline" }, + { id: "not_exploitable", label: "Not exploitable", variant: "success" }, + { id: "unknown", label: "Unknown", variant: "tag" }, +]; + +const LEG_GOOD_WHEN_YES: Record = { + reachability: false, + access: false, + target: false, + controls: true, + verification: true, +}; + +export const legBadgeVariant = ( + leg: LegName, + state: LegState, +): ComponentProps["variant"] => { + if (state === "unknown") return "tag"; + if (state === "partial") return "outline"; + return (state === "yes") === LEG_GOOD_WHEN_YES[leg] ? "success" : "error"; +}; + +/** Per-verdict counts from a summary payload, zero where the API sent none. */ +export const verdictCounts = ( + attributes: Partial> | undefined, +): Record => ({ + exploitable: attributes?.exploitable ?? 0, + likely: attributes?.likely ?? 0, + conditional: attributes?.conditional ?? 0, + not_exploitable: attributes?.not_exploitable ?? 0, + unknown: attributes?.unknown ?? 0, +}); diff --git a/ui/types/issues.ts b/ui/types/issues.ts new file mode 100644 index 0000000000..a5e757368e --- /dev/null +++ b/ui/types/issues.ts @@ -0,0 +1,135 @@ +import { MetaDataProps } from "@/types"; +import { Severity } from "@/types/components"; +import { ProviderType } from "@/types/providers"; + +/** An issue is a group of findings folded along one attack path. */ +export const VERDICT = { + EXPLOITABLE: "exploitable", + LIKELY: "likely", + CONDITIONAL: "conditional", + NOT_EXPLOITABLE: "not_exploitable", + UNKNOWN: "unknown", +} as const; + +export type Verdict = (typeof VERDICT)[keyof typeof VERDICT]; + +/** The five legs of an attack path, each asked separately. */ +export const LEG_NAME = { + REACHABILITY: "reachability", + ACCESS: "access", + TARGET: "target", + CONTROLS: "controls", + VERIFICATION: "verification", +} as const; + +export type LegName = (typeof LEG_NAME)[keyof typeof LEG_NAME]; + +export const LEG_STATE = { + YES: "yes", + NO: "no", + PARTIAL: "partial", + UNKNOWN: "unknown", +} as const; + +export type LegState = (typeof LEG_STATE)[keyof typeof LEG_STATE]; + +export interface LegAssessment { + state: LegState; + text: string; + /** Check ids and resources the leg is drawn from, so a reader can disagree. */ + evidence: string[]; +} + +export interface IssueAttributes { + pattern: string; + cloud: ProviderType; + account: string; + title: string; + verdict: Verdict; + severity: Severity; + legs: Record; + fix: string; + findings_count: number; + inserted_at: string; + updated_at: string; +} + +/** + * Only the detail carries the uid lists: an unmodelled issue can fold tens of + * thousands of findings, too many to ship with every page of the list. + */ +export interface IssueDetailAttributes extends IssueAttributes { + finding_uids: string[]; + resource_uids: string[]; +} + +/** Maximum findings loaded for the graph preview, independently of its resource limit. */ +export const ISSUE_FINDINGS_LIMIT = 50; + +/** One finding-to-resource association from the issue's own scan. */ +export interface IssueFinding { + id: string; + checkId: string; + checkTitle: string; + severity: Severity; + resourceUid: string; +} + +export const ISSUE_RELATIONSHIP_TYPE = { + PROVIDERS: "providers", + SCANS: "scans", +} as const; + +export type IssueRelationshipType = + (typeof ISSUE_RELATIONSHIP_TYPE)[keyof typeof ISSUE_RELATIONSHIP_TYPE]; + +export interface IssueRelationshipData { + type: IssueRelationshipType; + id: string; +} + +export interface IssueRelationship { + data: IssueRelationshipData | null; +} + +export interface IssueRelationships { + provider?: IssueRelationship; + scan?: IssueRelationship; +} + +export interface IssueResource { + type: "issues"; + id: string; + attributes: IssueAttributes; + relationships?: IssueRelationships; +} + +export interface IssueDetailResource extends IssueResource { + attributes: IssueDetailAttributes; +} + +export interface IssueResponse { + data: IssueResource[]; + meta?: MetaDataProps; +} + +export interface IssueDetailResponse { + data: IssueDetailResource; +} + +export interface IssuesOverviewAttributes extends Record { + issues: number; + findings: number; + actionable: number; + not_exploitable_findings: number; +} + +export interface IssuesOverviewResource { + type: "issues-overview"; + id?: string; + attributes: IssuesOverviewAttributes; +} + +export interface IssuesOverviewResponse { + data: IssuesOverviewResource; +}