mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-23 20:04:27 +00:00
feat(ui): add issues views using the shared design system (#13068)
This commit is contained in:
1 parent
f45173997b
commit
f46b86101a
85 files changed
+3255
-152
No files matched your search
@@ -0,0 +1 @@
|
||||
export * from "./issues";
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
+19
@@ -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";
|
||||
|
||||
+1
-2
@@ -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";
|
||||
|
||||
+1
-2
@@ -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> {
|
||||
|
||||
+2
-2
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
+15
-17
@@ -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 ---
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
File renamed without changes.
+1
-2
@@ -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" }),
|
||||
}));
|
||||
+9
-7
@@ -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
-1
@@ -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", () => {
|
||||
File renamed without changes.
@@ -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";
|
||||
+2
-3
@@ -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,
|
||||
}));
|
||||
|
||||
+6
-9
@@ -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>
|
||||
+1
-2
@@ -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;
|
||||
+6
-8
@@ -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;
|
||||
File renamed without changes.
+2
-4
@@ -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;
|
||||
+6
-8
@@ -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;
|
||||
+2
-3
@@ -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,
|
||||
}));
|
||||
|
||||
+6
-9
@@ -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>
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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'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([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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() };
|
||||
};
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+1
-2
@@ -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 = {
|
||||
+9
-10
@@ -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>;
|
||||
File renamed without changes.
+2
-2
@@ -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
-2
@@ -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
|
||||
+1
-2
@@ -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,
|
||||
+6
-7
@@ -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,
|
||||
+6
-4
@@ -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";
|
||||
+14
-7
@@ -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
-2
@@ -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"],
|
||||
+8
-6
@@ -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;
|
||||
File renamed without changes.
+1
-1
@@ -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", () => {
|
||||
File renamed without changes.
File renamed without changes.
+4
-2
@@ -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,
|
||||
+2
-3
@@ -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",
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user