feat(ui): add issues views using the shared design system (#13068)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-19 11:34:25 +02:00
1 parent f45173997b
commit f46b86101a
85 files changed
+3255 -152

No files matched your search

+1
View File
@@ -0,0 +1 @@
export * from "./issues";
+46
View File
@@ -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<FindingProps, "id"> {
attributes: Pick<
FindingProps["attributes"],
"check_id" | "check_metadata" | "severity"
>;
relationships: Pick<FindingProps["relationships"], "resources">;
}
interface IssueFindingsDocument
extends JsonApiDocument<IssueFindingResource[]> {
included?: JsonApiResource<Pick<ResourceProps["attributes"], "uid">>[];
}
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,
},
];
}),
);
};
+121
View File
@@ -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();
});
});
+122
View File
@@ -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<string, string | string[] | undefined>,
) => {
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<string, string | string[] | undefined>;
} = {}): Promise<IssuesOverviewResponse | undefined> => {
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<string, string | string[] | undefined>;
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<IssueResponse | undefined> => {
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<IssueDetailResponse | undefined> => {
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<IssueFinding[] | undefined> => {
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;
}
};
@@ -0,0 +1,19 @@
import { Card, CardContent, CardHeader, Skeleton } from "@/components/shadcn";
export function ExploitabilityChartSkeleton() {
return (
<Card
variant="base"
className="flex min-h-[372px] w-full min-w-0 flex-col lg:max-w-[312px]"
>
<CardHeader>
<Skeleton className="h-7 w-[170px] rounded-xl" />
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between gap-4">
<Skeleton className="h-[84px] w-[160px] rounded-xl" />
<Skeleton className="h-[120px] w-full rounded-xl" />
<Skeleton className="h-4 w-full rounded-xl" />
</CardContent>
</Card>
);
}
@@ -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(
<ExploitabilityChart
counts={{
exploitable: 0,
likely: 3,
conditional: 4,
not_exploitable: 4,
unknown: 1,
}}
issues={12}
findings={37}
actionable={3}
notExploitableFindings={17}
scope={{ "filter[provider_type__in]": "aws" }}
/>,
);
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();
});
});
@@ -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<Verdict, number>;
issues: number;
findings: number;
actionable: number;
notExploitableFindings: number;
/** Overview scope, carried into every drill-down so the counts still match. */
scope: Record<string, string>;
}
/** 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 (
<Card
variant="base"
className="flex min-h-[372px] w-full min-w-0 flex-col justify-between lg:max-w-[312px]"
>
<CardHeader>
<CardTitle>Exploitability</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between gap-4">
<Button asChild variant="link" size="link-sm">
<Link
href={issuesHref(scope, {
"filter[verdict__in]": "exploitable,likely",
})}
aria-label={`${actionable} exploitable or likely issues`}
>
<ResourceStatsCard
containerless
badge={{
icon: TriangleAlert,
count: actionable,
variant: CardVariant.fail,
}}
label="Exploitable or likely"
stats={[
{
icon: Layers,
label: `${counts.exploitable} exploitable · ${counts.likely} likely`,
},
]}
/>
</Link>
</Button>
<Card variant="inner" padding="md">
<ul className="flex flex-col gap-3 text-xs">
{REST.map(({ id, label }) => (
<li key={id}>
<Button asChild variant="link" size="link-sm">
<Link href={issuesHref(scope, { "filter[verdict]": id })}>
<span>{label}</span>
<span className="text-text-neutral-primary font-semibold tabular-nums">
{counts[id]}
</span>
</Link>
</Button>
{id === "not_exploitable" ? (
<span className="text-text-neutral-tertiary block text-xs">
covers {notExploitableFindings.toLocaleString()} finding
{notExploitableFindings === 1 ? "" : "s"}
</span>
) : null}
</li>
))}
</ul>
</Card>
{/* The fold. Without the findings count the issue count says nothing
about how much noise was removed, so the two are always shown
together. */}
<p className="text-text-neutral-tertiary text-xs">
{findings.toLocaleString()} failed findings folded into{" "}
{issues.toLocaleString()} issues
</p>
</CardContent>
</Card>
);
};
@@ -0,0 +1,19 @@
import { Card, CardContent, Skeleton } from "@/components/shadcn";
export function TopIssuesSkeleton() {
return (
<Card
variant="base"
className="flex w-full flex-col"
role="status"
aria-label="Loading top issues"
>
<Skeleton className="h-7 w-32 rounded-xl" />
<CardContent className="mt-4 flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-[52px] w-full rounded-xl" />
))}
</CardContent>
</Card>
);
}
@@ -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<string, string>;
}
export const TopIssues = ({ issues, scope }: TopIssuesProps) => (
<Card variant="base" className="w-full min-w-0">
<CardTitle>Top issues</CardTitle>
<CardContent className="flex flex-col gap-4">
{issues === null ? (
<IssuesLoadError />
) : issues.length > 0 ? (
<ul className="flex flex-col gap-2">
{issues.map(({ id, attributes, relationships }) => {
const verdict = VERDICTS.find(
(item) => item.id === attributes.verdict,
);
const providerId = relationships?.provider?.data?.id;
return (
<li key={id}>
<div className="flex flex-col gap-2 py-2">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<span className="text-text-neutral-secondary text-sm">
{attributes.findings_count}{" "}
{attributes.findings_count === 1 ? "finding" : "findings"}
</span>
</div>
<div className="min-w-0">
<Button
asChild
variant="link"
size="link-sm"
className="max-w-full"
>
<Link
href={issuesHref(scope, {
"filter[pattern]": attributes.pattern,
...(providerId
? { "filter[provider_id__in]": providerId }
: {}),
})}
title={attributes.title}
>
<span className="truncate">{attributes.title}</span>
</Link>
</Button>
<p className="text-text-neutral-tertiary truncate text-xs">
{attributes.account}
</p>
</div>
</div>
</li>
);
})}
</ul>
) : (
<p className="text-text-neutral-tertiary py-8 text-center text-sm">
No issues in this scope.
</p>
)}
<Button asChild variant="link" size="link-sm" className="self-end">
<Link href={issuesHref(scope)}>View all issues</Link>
</Button>
</CardContent>
</Card>
);
@@ -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 (
<Card variant="base" className="min-h-[372px] w-full lg:max-w-[312px]">
<IssuesLoadError message="Failed to load exploitability." />
</Card>
);
}
return (
<ExploitabilityChart
counts={verdictCounts(attributes)}
issues={attributes.issues ?? 0}
findings={attributes.findings ?? 0}
actionable={attributes.actionable ?? 0}
notExploitableFindings={attributes.not_exploitable_findings ?? 0}
scope={filters}
/>
);
};
@@ -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";
@@ -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();
});
});
@@ -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 (
<TopIssues
// Anything but a data array is a failed request, not an empty estate.
issues={Array.isArray(response?.data) ? response.data : null}
scope={scope}
/>
);
};
@@ -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";
@@ -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";
@@ -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";
@@ -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;
}
@@ -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;
@@ -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<PageFixture> {
@@ -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";
@@ -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";
@@ -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" },
});
});
});
@@ -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<string, string> = {};
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 ? (
<IssuesSummary overview={attributes} />
) : (
<IssuesLoadError message="Failed to load issue summary." />
)}
<VerdictFilter
summary={
attributes
? { counts: verdictCounts(attributes), total: attributes.issues }
: undefined
}
/>
{filterControls}
{rows === null ? (
<IssuesLoadError />
) : rows.length ? (
<IssuesTable issues={rows} metadata={issues?.meta} />
) : (
<p className="text-text-neutral-tertiary text-sm">
No issues match this filter.
</p>
)}
</>
);
};
+48
View File
@@ -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();
});
});
+48
View File
@@ -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<SearchParamsProps>;
}) {
if (!isCloud()) notFound();
const resolvedSearchParams = await searchParams;
const [providersData, providerGroupsData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
]);
return (
<ContentLayout title="Issues" icon={<ShieldAlert />}>
<Suspense
fallback={
<p className="text-text-neutral-tertiary text-sm">Loading issues…</p>
}
>
<IssuesList
searchParams={resolvedSearchParams}
filters={
// Same layout as the overview's selector row.
<div className="mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderAccountSelectors providers={providersData?.data ?? []} />
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
</div>
}
/>
</Suspense>
</ContentLayout>
);
}
+18 -1
View File
@@ -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({
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>
{isCloud() && (
<Suspense fallback={<ExploitabilityChartSkeleton />}>
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
)}
<Suspense fallback={<StatusChartSkeleton />}>
<CheckFindingsSSR searchParams={resolvedSearchParams} />
</Suspense>
@@ -136,11 +148,16 @@ export default async function Home({
</div>
</div>
{/* Charts column: Attack Surface on top, Findings Over Time below */}
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
<div className="flex flex-1 flex-col gap-6">
<Suspense fallback={<AttackSurfaceSkeleton />}>
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
</Suspense>
{isCloud() && (
<Suspense fallback={<TopIssuesSkeleton />}>
<TopIssuesSSR searchParams={resolvedSearchParams} />
</Suspense>
)}
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
@@ -0,0 +1 @@
Issues page and overview cards for Cloud deployments, with verdict filters, accessible issue details, and scoped finding evidence
@@ -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 ---
@@ -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(),
@@ -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" }),
}));
@@ -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,
@@ -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", () => {
@@ -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";
@@ -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,
}));
@@ -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}
</tspan>
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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,
}));
@@ -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}
</tspan>
+111
View File
@@ -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[] }) => (
<div data-testid="issue-graph">{resourceUids.length} resources</div>
),
}));
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(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
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(<IssueDetail id="issue-1" issue={issue} />);
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(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
// 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",
]);
});
});
+234
View File
@@ -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<LegName, string> = {
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<LegName, string> = {
reachability: "Reachability",
access: "Access",
target: "Target",
controls: "Controls",
verification: "Verification",
};
const STATE_LABELS: Record<LegState, string> = {
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<ResourcesState>({ 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 (
<div className="flex min-w-0 flex-col gap-4 py-4">
{state.status === "loaded" && state.findings === undefined && (
<IssuesLoadError
message="Could not load the findings for this issue."
onRetry={retry}
/>
)}
{state.status === "loaded" &&
state.findings !== undefined &&
state.resources.length > 0 && (
<IssueGraph
issue={issue}
resourceUids={state.resources}
findings={state.findings}
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
: { providerId: undefined, scanId: undefined })}
/>
)}
<section aria-label="Exploitability assessment">
<h4 className="mb-3 text-sm font-semibold">
Exploitability assessment
</h4>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(Object.keys(LEG_QUESTIONS) as LegName[]).map((name) => {
const leg = issue.legs[name];
if (!leg) return null;
return (
<Card key={name} variant="inner" className="min-w-0 gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h5 className="text-sm font-medium">{LEG_LABELS[name]}</h5>
<Badge variant={legBadgeVariant(name, leg.state)}>
{STATE_LABELS[leg.state]}
</Badge>
</div>
<p className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</p>
<p className="text-text-neutral-secondary text-sm">
{leg.text}
</p>
{leg.evidence.length > 0 && (
<ul className="flex flex-col gap-1">
{leg.evidence.map((line) => (
<li key={line}>
<CodeSnippet
value={line}
multiline
hideCopyButton
transparent
/>
</li>
))}
</ul>
)}
</Card>
);
})}
</div>
</section>
{state.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading issue details…
</p>
) : state.status === "error" ? (
<IssuesLoadError
message="Could not load the resources for this issue."
onRetry={retry}
/>
) : state.resources.length > 0 ? (
<section aria-label="Affected resources">
<h4 className="mb-3 text-sm font-semibold">
Affected resources ({state.resources.length})
</h4>
<ul className="flex flex-col gap-2">
{state.resources.slice(0, visibleResources).map((uid) => (
<li key={uid} className="min-w-0">
<CodeSnippet value={uid} multiline ariaLabel={`Copy ${uid}`} />
</li>
))}
</ul>
{visibleResources < state.resources.length && (
<Button
variant="link"
size="link-sm"
onClick={() =>
setVisibleResources((value) => value + RESOURCE_PAGE_SIZE)
}
>
Show more resources
</Button>
)}
{visibleResources > RESOURCE_PAGE_SIZE && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisibleResources(RESOURCE_PAGE_SIZE)}
>
Show fewer resources
</Button>
)}
</section>
) : null}
<section aria-label="Remediation">
<h4 className="mb-3 text-sm font-semibold">Remediation</h4>
<p className="text-text-neutral-secondary text-sm">{issue.fix}</p>
{activeScanId && (
<Button asChild variant="link" size="link-sm">
<Link
href={`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`}
>
Open this scan&apos;s failed findings
</Link>
</Button>
)}
</section>
</div>
);
};
@@ -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<IssueAttributes> = {}) =>
({
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<typeof buildIssueGraph>) =>
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([]);
},
);
});
+109
View File
@@ -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<string>();
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() };
};
+209
View File
@@ -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;
}) => (
<button
onClick={() =>
onNodeClick(
data.nodes.find((node) => node.labels.includes("ProwlerFinding")) ?? {
id: "finding",
labels: ["ProwlerFinding"],
properties: { check_id: "s3_public", resource_uid: "bucket" },
},
)
}
>
Open finding
</button>
),
}));
import { IssueGraph } from "./issue-graph";
const issue = { legs: {} } as IssueAttributes;
it("requires provider and scan identifiers together in the graph props", () => {
type Props = ComponentProps<typeof IssueGraph>;
type BaseProps = Pick<Props, "issue" | "resourceUids">;
expectTypeOf<BaseProps>().toExtend<Props>();
expectTypeOf<
BaseProps & { providerId: string; scanId: string }
>().toExtend<Props>();
expectTypeOf<BaseProps & { providerId: string }>().not.toExtend<Props>();
expectTypeOf<BaseProps & { scanId: string }>().not.toExtend<Props>();
});
describe("IssueGraph finding lookup", () => {
beforeEach(() => {
navigateToFinding.mockResolvedValue(undefined);
});
it("opens a folded finding by id without another lookup", async () => {
// Given
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
findings={[
{
id: "folded-finding",
checkId: "ssh",
checkTitle: "SSH exposed",
severity: "critical",
resourceUid: "bucket",
},
]}
providerId="provider-1"
scanId="scan-1"
/>,
);
// 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(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
/>,
);
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(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
/>,
);
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(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
/>,
);
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(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
/>,
);
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(<IssueGraph issue={issue} resourceUids={["bucket"]} />);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
expect(getFindings).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
"no provider or scan reference",
);
});
});
+176
View File
@@ -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<string | null>(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 (
<section
aria-label="Finding evidence"
className="flex min-w-0 flex-col gap-3"
>
<h4 className="text-sm font-semibold">Finding evidence</h4>
<p className="text-text-neutral-tertiary text-sm">
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.
</p>
{loading && (
<p role="status" className="text-sm">
Loading finding details…
</p>
)}
{(error || detailFailed) && (
<Alert variant="error">
<AlertDescription>
{error ??
"Could not load this finding. Select the finding to retry."}
</AlertDescription>
</Alert>
)}
<Card
variant="inner"
padding="none"
className="h-[420px] overflow-hidden"
>
<AttackPathGraph
data={{ nodes: view.nodes, edges: view.edges }}
view={view}
expandedResources={expandedResources}
onNodeClick={handleNodeClick}
/>
</Card>
{finding.findingDetails && (
<FindingDetailDrawer
key={finding.findingDetails.id}
finding={finding.findingDetails}
defaultOpen
onOpenChange={(open) => {
if (!open) {
setLookupComplete(false);
finding.resetFindingDetails();
}
}}
/>
)}
</section>
);
};
@@ -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 (
<Alert variant="error">
<AlertTitle>{message}</AlertTitle>
<AlertDescription>
<Button
variant="outline"
size="sm"
disabled={pending}
onClick={() =>
startTransition(() => (onRetry ? onRetry() : router.refresh()))
}
>
{pending ? "Retrying…" : "Retry"}
</Button>
</AlertDescription>
</Alert>
);
}
+59
View File
@@ -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 (
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
<ResourceStatsCard
badge={{ icon: Layers, count: overview.findings }}
label="Failed findings folded"
/>
<ResourceStatsCard
accent={CardVariant.info}
badge={{
icon: Layers,
count: overview.issues,
variant: CardVariant.info,
}}
label="Issues"
/>
<ResourceStatsCard
badge={{
icon: TriangleAlert,
count: overview.actionable,
variant: CardVariant.fail,
}}
label="Exploitable or likely"
/>
<ResourceStatsCard
badge={{
icon: ShieldCheck,
count: overview.not_exploitable,
variant: CardVariant.pass,
}}
label="Not exploitable"
stats={[
{
icon: Layers,
label: `${overview.not_exploitable_findings} findings`,
},
]}
/>
<ResourceStatsCard
badge={{
icon: CircleHelp,
count: overview.unknown,
variant: CardVariant.warning,
}}
label="Unknown"
/>
</div>
);
};
@@ -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(<IssuesTable issues={[issue]} />);
// 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(<IssuesTable issues={[issue]} />);
// 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();
});
});
+157
View File
@@ -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<IssueExpansion | null>(null);
function IssueExpandCell({ row }: { row: Row<IssueRow> }) {
const expansion = useContext(IssueExpansionContext);
const expanded = expansion?.openId === row.original.id;
return (
<Button
variant="ghost"
size="icon-sm"
aria-label={`${expanded ? "Collapse" : "Expand"} ${row.original.attributes.title}`}
aria-expanded={expanded}
aria-controls={expanded ? `issue-${row.original.id}` : undefined}
onClick={() => expansion?.toggle(row.original.id)}
>
{expanded ? (
<ChevronDown aria-hidden="true" />
) : (
<ChevronRight aria-hidden="true" />
)}
</Button>
);
}
const columns: ColumnDef<IssueRow>[] = [
{ id: "expand", header: "", cell: IssueExpandCell },
{
id: "issue",
header: "Issue",
cell: ({ row }) => (
<div className="max-w-[360px] min-w-0">
<p className="text-text-neutral-primary text-sm font-medium break-words">
{row.original.attributes.title}
</p>
<p className="text-text-neutral-tertiary truncate text-xs">
{row.original.attributes.pattern}
</p>
</div>
),
},
{
id: "provider",
header: "Provider",
cell: ({ row }) => (
<EntityInfo
cloudProvider={row.original.attributes.cloud}
entityAlias={row.original.attributes.account}
/>
),
},
{
id: "findings",
accessorKey: "attributes.findings_count",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Findings"
param="findings_count"
/>
),
cell: ({ row }) => (
<span className="text-text-neutral-secondary text-sm tabular-nums">
{row.original.attributes.findings_count}
</span>
),
},
{
id: "verdict",
accessorKey: "attributes.verdict",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Verdict"
param="verdict_rank"
/>
),
cell: ({ row }) => {
const verdict = VERDICTS.find(
(v) => v.id === row.original.attributes.verdict,
);
return (
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? row.original.attributes.verdict}
</Badge>
);
},
},
];
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<string | null>(null);
const toggleRow = (id: string) =>
setOpenId((current) => (current === id ? null : id));
const renderAfterRow = (row: Row<IssueRow>) =>
row.original.id === openId ? (
<TableRow>
<TableCell colSpan={columns.length}>
<div
id={`issue-${row.original.id}`}
className="sticky left-0 w-[calc(100cqw-4rem)] min-w-0"
>
<IssueDetail
key={`${row.original.id}-${row.original.attributes.updated_at}`}
id={row.original.id}
issue={row.original.attributes}
scanId={row.original.relationships?.scan?.data?.id}
providerId={row.original.relationships?.provider?.data?.id}
/>
</div>
</TableCell>
</TableRow>
) : null;
return (
<div className="@container min-w-0">
<IssueExpansionContext value={{ openId, toggle: toggleRow }}>
<DataTable
columns={columns}
data={issues}
metadata={metadata}
onRowClick={(row) => toggleRow(row.original.id)}
renderAfterRow={renderAfterRow}
/>
</IssueExpansionContext>
</div>
);
};
@@ -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(
<main className="min-w-0 p-4">
<h1 className="mb-6 text-2xl font-semibold">Issues</h1>
{content}
</main>,
);
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(
<main className="flex min-w-0 flex-col gap-6 p-4 lg:flex-row">
<ExploitabilityChart
counts={summary}
issues={1}
findings={3}
actionable={1}
notExploitableFindings={0}
scope={{}}
/>
<TopIssues issues={[issue]} scope={{}} />
</main>,
);
await expect
.element(
screen.getByRole("link", { name: "1 exploitable or likely issues" }),
)
.toBeVisible();
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
await screen.getByRole("main").screenshot();
},
);
});
@@ -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(<VerdictFilter summary={{ counts, total: 2 }} />);
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(<VerdictFilter summary={{ counts, total: 2 }} />);
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(<VerdictFilter summary={{ counts, total: 2 }} />);
// 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");
});
});
+104
View File
@@ -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<Verdict, number>;
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 (
<div className="mb-6 flex flex-col gap-4">
<div
className="flex flex-wrap gap-2"
role="group"
aria-label="Filter issues by verdict"
>
{options.map((option) => {
const isActive =
!multiple &&
(active === option.id || (!active && option.id === null));
return (
<Button
key={option.id ?? "all"}
asChild
variant={isActive ? "default" : "outline"}
size="sm"
>
<Link
href={hrefFor(option.id)}
aria-current={isActive ? "true" : undefined}
scroll={false}
>
{option.label}
{option.count === undefined ? "" : ` ${option.count}`}
</Link>
</Button>
);
})}
</div>
<FilterSummaryStrip
chips={chips}
onRemove={(key) => clearFilter(key)}
trailingContent={<ClearFiltersButton />}
/>
</div>
);
}
@@ -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" }),
@@ -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",
@@ -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<AddProviderFormValues>();
@@ -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));
@@ -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);
});
});
@@ -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) => {
@@ -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 = {
@@ -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<string>;
@@ -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", () => {
@@ -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
@@ -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,
@@ -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,
@@ -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";
@@ -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";
@@ -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"],
@@ -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<string, unknown> {
graphNode: GraphNode;
@@ -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", () => {
@@ -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,
@@ -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",
+27
View File
@@ -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<string, string> =>
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<string, string>,
extra: Record<string, string> = {},
) => {
const query = new URLSearchParams({ ...scope, ...extra }).toString();
return query ? `/issues?${query}` : "/issues";
};
+43
View File
@@ -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,
});
});
});
+46
View File
@@ -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<typeof Badge>["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<LegName, boolean> = {
reachability: false,
access: false,
target: false,
controls: true,
verification: true,
};
export const legBadgeVariant = (
leg: LegName,
state: LegState,
): ComponentProps<typeof Badge>["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<Record<Verdict, number>> | undefined,
): Record<Verdict, number> => ({
exploitable: attributes?.exploitable ?? 0,
likely: attributes?.likely ?? 0,
conditional: attributes?.conditional ?? 0,
not_exploitable: attributes?.not_exploitable ?? 0,
unknown: attributes?.unknown ?? 0,
});
+135
View File
@@ -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<LegName, LegAssessment>;
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<Verdict, number> {
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;
}