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 }) => (
+
+
+
+ {label}
+
+ {counts[id]}
+
+
+
+ {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.title}
+
+
+
+ {attributes.account}
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+ No issues in this scope.
+
+ )}
+
+ View all issues
+
+
+
+);
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 && (
+
+ setVisibleResources((value) => value + RESOURCE_PAGE_SIZE)
+ }
+ >
+ Show more resources
+
+ )}
+ {visibleResources > RESOURCE_PAGE_SIZE && (
+ setVisibleResources(RESOURCE_PAGE_SIZE)}
+ >
+ Show fewer resources
+
+ )}
+
+ ) : null}
+
+ Remediation
+ {issue.fix}
+ {activeScanId && (
+
+
+ Open this scan's failed findings
+
+
+ )}
+
+
+ );
+};
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;
+ }) => (
+
+ onNodeClick(
+ data.nodes.find((node) => node.labels.includes("ProwlerFinding")) ?? {
+ id: "finding",
+ labels: ["ProwlerFinding"],
+ properties: { check_id: "s3_public", resource_uid: "bucket" },
+ },
+ )
+ }
+ >
+ Open finding
+
+ ),
+}));
+
+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}
+
+
+ startTransition(() => (onRetry ? onRetry() : router.refresh()))
+ }
+ >
+ {pending ? "Retrying…" : "Retry"}
+
+
+
+ );
+}
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 (
+ expansion?.toggle(row.original.id)}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+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 (
+
+
+ {option.label}
+ {option.count === undefined ? "" : ` ${option.count}`}
+
+
+ );
+ })}
+
+
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;
+}