mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 15:24:22 +00:00
feat(ui): bring the issues UI closer to the original prototype (#13154)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
1 parent
63b8c8969c
commit
41a2bf605a
17 files changed
+495
-43
No files matched your search
@@ -17,6 +17,7 @@ describe("ExploitabilityChart", () => {
|
||||
issues={12}
|
||||
findings={37}
|
||||
actionable={3}
|
||||
coveredFindings={0}
|
||||
scope={{ "filter[provider_type__in]": "aws" }}
|
||||
/>,
|
||||
);
|
||||
@@ -45,4 +46,38 @@ describe("ExploitabilityChart", () => {
|
||||
screen.getByText("37 failed findings folded into 12 issues"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("draws each verdict's share and names the findings a control covers", () => {
|
||||
render(
|
||||
<ExploitabilityChart
|
||||
counts={{
|
||||
exploitable: 1,
|
||||
likely: 2,
|
||||
conditional: 0,
|
||||
not_exploitable: 4,
|
||||
unknown: 1,
|
||||
}}
|
||||
issues={8}
|
||||
findings={30}
|
||||
actionable={3}
|
||||
coveredFindings={9}
|
||||
scope={{}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Sized by share, so the bar reads the same whatever the estate's size.
|
||||
expect(screen.getByTitle("4 not exploitable")).toHaveStyle({
|
||||
width: "50%",
|
||||
});
|
||||
expect(screen.getByTitle("1 exploitable")).toHaveAttribute(
|
||||
"href",
|
||||
"/issues?filter%5Bverdict%5D=exploitable",
|
||||
);
|
||||
// A verdict with no issues gets no segment.
|
||||
expect(screen.queryByTitle("0 conditional")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "9 findings" })).toHaveAttribute(
|
||||
"href",
|
||||
"/issues?filter%5Bverdict%5D=not_exploitable",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/shadcn";
|
||||
import { issuesHref } from "@/lib/issues/scope";
|
||||
import { VERDICT_TONE, VERDICTS, VerdictTone } from "@/lib/issues/verdicts";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -12,6 +18,8 @@ interface ExploitabilityChartProps {
|
||||
issues: number;
|
||||
findings: number;
|
||||
actionable: number;
|
||||
/** Findings folded into not-exploitable issues: a control already covers them. */
|
||||
coveredFindings: number;
|
||||
/** Overview scope, carried into every drill-down so the counts still match. */
|
||||
scope: Record<string, string>;
|
||||
}
|
||||
@@ -27,6 +35,15 @@ const TONE_DOT: Record<VerdictTone, string> = {
|
||||
[VERDICT_TONE.neutral]: "bg-bg-data-muted",
|
||||
};
|
||||
|
||||
/** The share bar runs worst to best; likely is a lighter exploitable. */
|
||||
const BAR_SEGMENT: Record<Verdict, string> = {
|
||||
exploitable: "bg-bg-fail",
|
||||
likely: "bg-bg-fail/60",
|
||||
conditional: TONE_DOT[VERDICT_TONE.warning],
|
||||
unknown: TONE_DOT[VERDICT_TONE.neutral],
|
||||
not_exploitable: TONE_DOT[VERDICT_TONE.pass],
|
||||
};
|
||||
|
||||
/**
|
||||
* The fold, sized for the ThreatScore row.
|
||||
*
|
||||
@@ -40,8 +57,11 @@ export const ExploitabilityChart = ({
|
||||
issues,
|
||||
findings,
|
||||
actionable,
|
||||
coveredFindings,
|
||||
scope,
|
||||
}: ExploitabilityChartProps) => {
|
||||
const barTotal = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
@@ -76,6 +96,32 @@ export const ExploitabilityChart = ({
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* The ladder's shape: whether this estate is mostly open or mostly
|
||||
blocked, sized by share and never by severity. The rows around it
|
||||
carry the same links for keyboard and screen-reader users. */}
|
||||
{barTotal > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="bg-bg-neutral-tertiary flex h-2 w-full overflow-hidden rounded-full"
|
||||
>
|
||||
{(Object.keys(BAR_SEGMENT) as Verdict[])
|
||||
.filter((id) => counts[id] > 0)
|
||||
.map((id) => (
|
||||
<Link
|
||||
key={id}
|
||||
tabIndex={-1}
|
||||
href={issuesHref(scope, { "filter[verdict]": id })}
|
||||
title={`${counts[id]} ${(VERDICTS.find((item) => item.id === id)?.label ?? id).toLowerCase()}`}
|
||||
style={{ width: `${(counts[id] / barTotal) * 100}%` }}
|
||||
className={cn(
|
||||
"h-full transition-opacity hover:opacity-80",
|
||||
BAR_SEGMENT[id],
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card variant="inner" padding="none" className="gap-0 px-4 py-0.5">
|
||||
<ul className="divide-border-neutral-secondary flex flex-col divide-y">
|
||||
{REST.map((id) => {
|
||||
@@ -111,6 +157,22 @@ export const ExploitabilityChart = ({
|
||||
<p className="border-border-neutral-secondary text-text-neutral-tertiary mt-auto border-t pt-3 text-xs">
|
||||
{findings.toLocaleString()} failed findings folded into{" "}
|
||||
{issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
|
||||
{coveredFindings > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<Button asChild variant="link" size="link-xs">
|
||||
<Link
|
||||
href={issuesHref(scope, {
|
||||
"filter[verdict]": "not_exploitable",
|
||||
})}
|
||||
>
|
||||
{coveredFindings.toLocaleString()}{" "}
|
||||
{coveredFindings === 1 ? "finding" : "findings"}
|
||||
</Link>
|
||||
</Button>{" "}
|
||||
already covered by a control
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -29,6 +29,7 @@ export const ExploitabilityChartSSR = async ({
|
||||
issues={attributes.issues ?? 0}
|
||||
findings={attributes.findings ?? 0}
|
||||
actionable={attributes.actionable ?? 0}
|
||||
coveredFindings={attributes.not_exploitable_findings ?? 0}
|
||||
scope={filters}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getIssue, notFound } = vi.hoisted(() => ({
|
||||
getIssue: vi.fn(),
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error("NOT_FOUND");
|
||||
}),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({ notFound }));
|
||||
vi.mock("@/actions/issues", () => ({ getIssue }));
|
||||
vi.mock("@/components/shadcn/content-layout", () => ({
|
||||
ContentLayout: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/components/shared/feature-flag-gate", () => ({
|
||||
FeatureFlagGate: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-detail", () => ({
|
||||
IssueDetail: ({ scanId }: { scanId?: string }) => (
|
||||
<div data-testid="issue-detail">{scanId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import IssuePage from "./page";
|
||||
|
||||
const params = Promise.resolve({ id: "issue-1" });
|
||||
|
||||
describe("IssuePage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
getIssue.mockReset();
|
||||
});
|
||||
|
||||
it("returns not found in Local Server without asking the API", async () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
|
||||
expect(getIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns not found when the API has no such issue", async () => {
|
||||
getIssue.mockResolvedValue({ error: "Not found", status: 404 });
|
||||
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("shows the issue with the detail the list opens in place", async () => {
|
||||
getIssue.mockResolvedValue({
|
||||
data: {
|
||||
type: "issues",
|
||||
id: "issue-1",
|
||||
attributes: {
|
||||
pattern: "aws_public_bucket",
|
||||
cloud: "aws",
|
||||
account: "prod",
|
||||
title: "Public bucket",
|
||||
verdict: "likely",
|
||||
},
|
||||
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
|
||||
},
|
||||
});
|
||||
|
||||
render(await IssuePage({ params }));
|
||||
|
||||
expect(getIssue).toHaveBeenCalledWith("issue-1");
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Public bucket" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Likely")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("issue-detail")).toHaveTextContent("scan-1");
|
||||
expect(screen.getByRole("link", { name: "← All issues" })).toHaveAttribute(
|
||||
"href",
|
||||
"/issues",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getIssue } from "@/actions/issues";
|
||||
import { IssueDetail } from "@/components/issues/issue-detail";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { EntityInfo } from "@/components/shadcn/entities";
|
||||
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { VERDICTS } from "@/lib/issues/verdicts";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
/**
|
||||
* One issue on its own URL, so a path can be shared or linked from a ticket.
|
||||
* The body is the same detail the list opens in place.
|
||||
*/
|
||||
export default async function IssuePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
if (!isCloud()) notFound();
|
||||
const { id } = await params;
|
||||
const issue = (await getIssue(id))?.data;
|
||||
// handleApiResponse resolves {error, status} on a 4xx: no data is no issue.
|
||||
if (!issue?.attributes) notFound();
|
||||
|
||||
const { attributes, relationships } = issue;
|
||||
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
|
||||
|
||||
return (
|
||||
<ContentLayout title="Issue" icon={<ShieldAlert />}>
|
||||
<FeatureFlagGate
|
||||
flag={FEATURE_FLAG.ISSUES}
|
||||
fallback={
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
Issues is not available for this account yet.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Button asChild variant="link" size="link-sm" className="self-start">
|
||||
<Link href="/issues">← All issues</Link>
|
||||
</Button>
|
||||
<Card variant="base" className="min-w-0 gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant={verdict?.variant ?? "tag"}>
|
||||
{verdict?.label ?? attributes.verdict}
|
||||
</Badge>
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
{attributes.pattern}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-text-neutral-primary text-lg font-semibold break-words">
|
||||
{attributes.title}
|
||||
</h2>
|
||||
<EntityInfo
|
||||
cloudProvider={attributes.cloud}
|
||||
entityAlias={attributes.account}
|
||||
/>
|
||||
<IssueDetail
|
||||
id={issue.id}
|
||||
issue={attributes}
|
||||
scanId={relationships?.scan?.data?.id}
|
||||
providerId={relationships?.provider?.data?.id}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</FeatureFlagGate>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -36,17 +37,16 @@ export const IssuesList = async ({
|
||||
if (typeof value === "string" && value) filters[key] = value;
|
||||
});
|
||||
|
||||
// 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.
|
||||
const scope = Object.fromEntries(
|
||||
Object.entries(filters).filter(
|
||||
([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
|
||||
),
|
||||
);
|
||||
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]",
|
||||
),
|
||||
),
|
||||
}),
|
||||
getIssuesOverview({ filters: scope }),
|
||||
getIssues({
|
||||
filters,
|
||||
page: Number(searchParams.page) || 1,
|
||||
@@ -63,7 +63,11 @@ export const IssuesList = async ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{attributes ? null : (
|
||||
{attributes ? (
|
||||
<div className="mb-6">
|
||||
<IssuesSummary summary={attributes} scope={scope} />
|
||||
</div>
|
||||
) : (
|
||||
<IssuesLoadError message="Failed to load issue summary." />
|
||||
)}
|
||||
|
||||
@@ -75,7 +79,6 @@ export const IssuesList = async ({
|
||||
? {
|
||||
counts: verdictCounts(attributes),
|
||||
total: attributes.issues,
|
||||
findings: attributes.findings,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ interface AttackPathGraphProps {
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
onNodeDoubleClick?: (node: GraphNode) => void;
|
||||
onInitialFilter?: (filteredData: AttackPathGraphData) => void;
|
||||
// Floor for the automatic fit: a graph that would shrink below it is shown
|
||||
// at this zoom and panned instead, so its labels stay readable.
|
||||
minFitZoom?: number;
|
||||
ref?: Ref<GraphHandle>;
|
||||
className?: string;
|
||||
}
|
||||
@@ -194,6 +197,13 @@ const AUTO_FIT_OPTIONS = {
|
||||
duration: 300,
|
||||
} as const;
|
||||
|
||||
// With a zoom floor the graph can be larger than the canvas and is centred on
|
||||
// it, so the minimap's lopsided padding would only push the top out of view.
|
||||
const fitOptionsFor = (minFitZoom?: number) =>
|
||||
minFitZoom === undefined
|
||||
? AUTO_FIT_OPTIONS
|
||||
: { ...AUTO_FIT_OPTIONS, padding: "32px" as const, minZoom: minFitZoom };
|
||||
|
||||
const MEASURED_FIT_MAX_ATTEMPTS = 30;
|
||||
|
||||
const scheduleMeasuredFit = (
|
||||
@@ -230,6 +240,7 @@ const GraphCanvas = ({
|
||||
onNodeClick,
|
||||
onNodeDoubleClick,
|
||||
onInitialFilter,
|
||||
minFitZoom,
|
||||
ref,
|
||||
}: GraphCanvasProps) => {
|
||||
const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } =
|
||||
@@ -265,9 +276,9 @@ const GraphCanvas = ({
|
||||
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
|
||||
);
|
||||
},
|
||||
() => fitView(AUTO_FIT_OPTIONS),
|
||||
() => fitView(fitOptionsFor(minFitZoom)),
|
||||
);
|
||||
}, [expandedClasses, fitView, getNodes]);
|
||||
}, [expandedClasses, fitView, getNodes, minFitZoom]);
|
||||
|
||||
// --- initialNodeId: synchronous filtered-view derivation on first render ---
|
||||
// Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph
|
||||
@@ -352,15 +363,15 @@ const GraphCanvas = ({
|
||||
);
|
||||
|
||||
if (contextualNodes.length > 0) {
|
||||
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
|
||||
fitView({ ...fitOptionsFor(minFitZoom), nodes: contextualNodes });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fitView(AUTO_FIT_OPTIONS);
|
||||
fitView(fitOptionsFor(minFitZoom));
|
||||
},
|
||||
);
|
||||
}, [expanded, isFilteredView, fitView, getNodes]);
|
||||
}, [expanded, isFilteredView, fitView, getNodes, minFitZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousExpandedRef.current;
|
||||
@@ -384,7 +395,7 @@ const GraphCanvas = ({
|
||||
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
|
||||
);
|
||||
},
|
||||
() => fitView(AUTO_FIT_OPTIONS),
|
||||
() => fitView(fitOptionsFor(minFitZoom)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -415,10 +426,10 @@ const GraphCanvas = ({
|
||||
const contextualNodes = getNodes().filter((n) =>
|
||||
contextualFitNodeIds.has(n.id),
|
||||
);
|
||||
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
|
||||
fitView({ ...fitOptionsFor(minFitZoom), nodes: contextualNodes });
|
||||
},
|
||||
);
|
||||
}, [expanded, fitView, getNodes]);
|
||||
}, [expanded, fitView, getNodes, minFitZoom]);
|
||||
|
||||
// Cloud reshapes into the attack-path view: drop the account hub, collapse
|
||||
// each resource class into one expandable node, and inject the outcome node.
|
||||
@@ -592,7 +603,10 @@ const GraphCanvas = ({
|
||||
onNodeMouseEnter={handleNodeMouseEnter}
|
||||
onNodeMouseLeave={handleNodeMouseLeave}
|
||||
fitView
|
||||
fitViewOptions={{ ...AUTO_FIT_OPTIONS, includeHiddenNodes: true }}
|
||||
fitViewOptions={{
|
||||
...fitOptionsFor(minFitZoom),
|
||||
includeHiddenNodes: true,
|
||||
}}
|
||||
// Supported React Flow behavior: wheel over the graph zooms the
|
||||
// viewport. The surrounding UX avoids using node details as inline
|
||||
// content, so this no longer fights a below-graph details section.
|
||||
@@ -646,6 +660,7 @@ export const AttackPathGraph = ({
|
||||
onNodeClick,
|
||||
onNodeDoubleClick,
|
||||
onInitialFilter,
|
||||
minFitZoom,
|
||||
ref,
|
||||
className,
|
||||
}: AttackPathGraphProps) => {
|
||||
@@ -674,6 +689,7 @@ export const AttackPathGraph = ({
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onInitialFilter={onInitialFilter}
|
||||
minFitZoom={minFitZoom}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
|
||||
@@ -149,6 +149,22 @@ describe("IssueDetail", () => {
|
||||
expect(screen.queryByText("Outcome")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links each affected resource to it on the Resources page", async () => {
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: { attributes: { resource_uids: ["arn:aws:s3:::bucket"] } },
|
||||
});
|
||||
render(<IssueDetail id="issue-1" issue={issue} providerId="provider-1" />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("link", {
|
||||
name: "Open in Resources: arn:aws:s3:::bucket",
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/resources?filter%5Buid%5D=arn%3Aaws%3As3%3A%3A%3Abucket&filter%5Bprovider_id__in%5D=provider-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when the resources cannot be loaded", async () => {
|
||||
getIssueMock.mockResolvedValue(undefined);
|
||||
render(<IssueDetail id="issue-1" issue={issue} />);
|
||||
|
||||
@@ -52,6 +52,15 @@ const STATE_LABELS: Record<LegState, string> = {
|
||||
};
|
||||
const RESOURCE_PAGE_SIZE = 12;
|
||||
|
||||
// The issue carries resource uids, not ids, so this opens the Resources list
|
||||
// on that one resource rather than its drawer.
|
||||
const resourceHref = (uid: string, providerId?: string) =>
|
||||
`/resources?${new URLSearchParams({
|
||||
"filter[uid]": uid,
|
||||
// A uid is unique within an account, not across them.
|
||||
...(providerId && { "filter[provider_id__in]": providerId }),
|
||||
})}`;
|
||||
|
||||
interface IssueDetailProps {
|
||||
id: string;
|
||||
issue: IssueAttributes;
|
||||
@@ -250,8 +259,19 @@ export const IssueDetail = ({
|
||||
</h4>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{state.resources.slice(0, visibleResources).map((uid) => (
|
||||
<li key={uid} className="min-w-0">
|
||||
<li
|
||||
key={uid}
|
||||
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
|
||||
>
|
||||
<CodeSnippet value={uid} multiline ariaLabel={`Copy ${uid}`} />
|
||||
<Button asChild variant="link" size="link-sm">
|
||||
<Link
|
||||
href={resourceHref(uid, state.providerId)}
|
||||
aria-label={`Open in Resources: ${uid}`}
|
||||
>
|
||||
Open in Resources
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { useFindingDetails } from "@/hooks/use-finding-details";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
|
||||
import { GraphNode } from "@/types/attack-paths";
|
||||
import { IssueAttributes, IssueFinding } from "@/types/issues";
|
||||
@@ -34,6 +35,11 @@ interface UnscopedIssueGraphProps extends IssueGraphBaseProps {
|
||||
|
||||
type IssueGraphProps = ScopedIssueGraphProps | UnscopedIssueGraphProps;
|
||||
|
||||
/** Below this the node labels (11px) stop being legible; past it, pan. */
|
||||
const ISSUE_GRAPH_MIN_ZOOM = 0.8;
|
||||
/** A phone cannot fit even one resource and its finding at that zoom: fit all. */
|
||||
const READABLE_ZOOM_MEDIA_QUERY = "(min-width: 768px)";
|
||||
|
||||
export const IssueGraph = ({
|
||||
issue,
|
||||
resourceUids,
|
||||
@@ -42,6 +48,7 @@ export const IssueGraph = ({
|
||||
scanId,
|
||||
}: IssueGraphProps) => {
|
||||
const finding = useFindingDetails();
|
||||
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
|
||||
const view = buildIssueGraph(issue, resourceUids, findings);
|
||||
const shownFindings = new Set(
|
||||
view.nodes.flatMap((node) =>
|
||||
@@ -149,13 +156,14 @@ export const IssueGraph = ({
|
||||
<Card
|
||||
variant="inner"
|
||||
padding="none"
|
||||
className="h-[420px] overflow-hidden"
|
||||
className="h-[560px] overflow-hidden"
|
||||
>
|
||||
<AttackPathGraph
|
||||
data={{ nodes: view.nodes, edges: view.edges }}
|
||||
view={view}
|
||||
expandedResources={expandedResources}
|
||||
onNodeClick={handleNodeClick}
|
||||
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
|
||||
/>
|
||||
</Card>
|
||||
{finding.findingDetails && (
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { IssuesSummary } from "@/components/issues/issues-summary";
|
||||
import { IssuesOverviewAttributes } from "@/types/issues";
|
||||
|
||||
const summary: IssuesOverviewAttributes = {
|
||||
issues: 13,
|
||||
findings: 41,
|
||||
actionable: 4,
|
||||
not_exploitable_findings: 1,
|
||||
exploitable: 1,
|
||||
likely: 3,
|
||||
conditional: 5,
|
||||
not_exploitable: 2,
|
||||
unknown: 2,
|
||||
};
|
||||
|
||||
describe("IssuesSummary", () => {
|
||||
it("leads with the reduction and links every tile into the scoped list", () => {
|
||||
render(
|
||||
<IssuesSummary
|
||||
summary={summary}
|
||||
scope={{ "filter[provider_type__in]": "aws" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: "41 Failed findings folded" }),
|
||||
).toHaveAttribute("href", "/issues?filter%5Bprovider_type__in%5D=aws");
|
||||
expect(screen.getByRole("link", { name: "13 Issues" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "4 Exploitable or likely" }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict__in%5D=exploitable%2Clikely",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: "2 Not exploitable, 1 finding covered by a control",
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=not_exploitable",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "2 Unknown" })).toHaveAttribute(
|
||||
"href",
|
||||
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=unknown",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
CircleHelp,
|
||||
Layers,
|
||||
type LucideIcon,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { CardVariant, ResourceStatsCard } from "@/components/shadcn";
|
||||
import { issuesHref } from "@/lib/issues/scope";
|
||||
import { IssuesOverviewAttributes } from "@/types/issues";
|
||||
|
||||
interface TileProps {
|
||||
value: number;
|
||||
label: string;
|
||||
detail?: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
variant: CardVariant;
|
||||
}
|
||||
|
||||
const Tile = ({ value, label, detail, href, icon, variant }: TileProps) => (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={[`${value.toLocaleString()} ${label}`, detail]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
className="focus-visible:ring-ring/50 min-w-0 rounded-xl focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ResourceStatsCard
|
||||
badge={{ icon, count: value.toLocaleString(), variant }}
|
||||
label={label}
|
||||
stats={detail ? [{ icon: ShieldCheck, label: detail }] : []}
|
||||
className="hover:border-border-neutral-primary h-full transition-colors"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
/**
|
||||
* The reduction, as the first row of the page: findings and issues side by
|
||||
* side because the ratio between them is the claim the page makes. Every tile
|
||||
* keeps the scope it was counted in.
|
||||
*/
|
||||
export const IssuesSummary = ({
|
||||
summary,
|
||||
scope,
|
||||
}: {
|
||||
summary: IssuesOverviewAttributes;
|
||||
/** The filters in force other than the verdict, so the counts still match. */
|
||||
scope: Record<string, string>;
|
||||
}) => (
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3 2xl:grid-cols-5">
|
||||
<Tile
|
||||
value={summary.findings}
|
||||
label="Failed findings folded"
|
||||
href={issuesHref(scope)}
|
||||
icon={Layers}
|
||||
variant={CardVariant.fail}
|
||||
/>
|
||||
<Tile
|
||||
value={summary.issues}
|
||||
label="Issues"
|
||||
href={issuesHref(scope)}
|
||||
icon={Shield}
|
||||
variant={CardVariant.default}
|
||||
/>
|
||||
<Tile
|
||||
value={summary.actionable}
|
||||
label="Exploitable or likely"
|
||||
href={issuesHref(scope, { "filter[verdict__in]": "exploitable,likely" })}
|
||||
icon={TriangleAlert}
|
||||
variant={summary.actionable > 0 ? CardVariant.fail : CardVariant.pass}
|
||||
/>
|
||||
<Tile
|
||||
value={summary.not_exploitable}
|
||||
label="Not exploitable"
|
||||
detail={`${summary.not_exploitable_findings.toLocaleString()} ${summary.not_exploitable_findings === 1 ? "finding" : "findings"} covered by a control`}
|
||||
href={issuesHref(scope, { "filter[verdict]": "not_exploitable" })}
|
||||
icon={ShieldCheck}
|
||||
variant={CardVariant.pass}
|
||||
/>
|
||||
<Tile
|
||||
value={summary.unknown}
|
||||
label="Unknown"
|
||||
href={issuesHref(scope, { "filter[verdict]": "unknown" })}
|
||||
icon={CircleHelp}
|
||||
variant={CardVariant.warning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -93,6 +93,9 @@ describe("IssuesTable", () => {
|
||||
screen.getByRole("button", { name: "Collapse Public bucket" }),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
expect(await screen.findByText("Restrict access")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Open the full path" }),
|
||||
).toHaveAttribute("href", "/issues/issue-1");
|
||||
// When
|
||||
await user.keyboard(" ");
|
||||
// Then
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ColumnDef, Row } from "@tanstack/react-table";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
import { IssueDetail } from "@/components/issues/issue-detail";
|
||||
@@ -138,6 +139,11 @@ export const IssuesTable = ({
|
||||
id={`issue-${row.original.id}`}
|
||||
className="sticky left-0 w-[calc(100cqw-4rem)] min-w-0"
|
||||
>
|
||||
<Button asChild variant="link" size="link-sm">
|
||||
<Link href={`/issues/${row.original.id}`}>
|
||||
Open the full path
|
||||
</Link>
|
||||
</Button>
|
||||
<IssueDetail
|
||||
key={`${row.original.id}-${row.original.attributes.updated_at}`}
|
||||
id={row.original.id}
|
||||
|
||||
@@ -341,6 +341,7 @@ describe("Issues responsive layout", () => {
|
||||
issues={1}
|
||||
findings={3}
|
||||
actionable={1}
|
||||
coveredFindings={0}
|
||||
scope={{}}
|
||||
/>
|
||||
<TopIssues issues={[issue]} scope={{}} />
|
||||
|
||||
@@ -30,9 +30,9 @@ const lastPushedParams = () =>
|
||||
.searchParams;
|
||||
|
||||
describe("VerdictFilter", () => {
|
||||
it("renders one tab per verdict with its count and the fold", () => {
|
||||
it("renders one tab per verdict with its count", () => {
|
||||
navigationState.searchParams = new URLSearchParams();
|
||||
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
|
||||
render(<VerdictFilter summary={{ counts, total: 2 }} />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "All, 2 issues" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
@@ -42,9 +42,6 @@ describe("VerdictFilter", () => {
|
||||
screen.getByRole("tab", { name: "Exploitable, 2 issues" }),
|
||||
).toHaveAttribute("aria-selected", "false");
|
||||
expect(screen.getAllByRole("tab")).toHaveLength(6);
|
||||
expect(
|
||||
screen.getByText("9 failed findings folded into 2 issues"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects the verdict tab and resets the page, keeping other params", async () => {
|
||||
@@ -52,7 +49,7 @@ describe("VerdictFilter", () => {
|
||||
navigationState.searchParams = new URLSearchParams(
|
||||
"page=3&pageSize=10&filter[verdict]=likely",
|
||||
);
|
||||
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
|
||||
render(<VerdictFilter summary={{ counts, total: 2 }} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Likely, 0 issues" }),
|
||||
@@ -72,7 +69,7 @@ describe("VerdictFilter", () => {
|
||||
navigationState.searchParams = new URLSearchParams(
|
||||
"filter[verdict__in]=exploitable,likely",
|
||||
);
|
||||
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
|
||||
render(<VerdictFilter summary={{ counts, total: 2 }} />);
|
||||
|
||||
// No single tab owns the combined filter, so none is selected...
|
||||
expect(
|
||||
@@ -106,7 +103,7 @@ describe("VerdictFilter", () => {
|
||||
navigationState.searchParams = new URLSearchParams(
|
||||
"filter[verdict]=likely",
|
||||
);
|
||||
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
|
||||
render(<VerdictFilter summary={{ counts, total: 2 }} />);
|
||||
|
||||
expect(screen.queryByRole("region", { name: "Active filters" })).toBeNull();
|
||||
});
|
||||
@@ -117,7 +114,7 @@ describe("VerdictFilter", () => {
|
||||
navigationState.searchParams = new URLSearchParams(
|
||||
"page=3&filter[pattern]=aws_public_bucket&filter[provider_id__in]=provider-1",
|
||||
);
|
||||
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
|
||||
render(<VerdictFilter summary={{ counts, total: 2 }} />);
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
|
||||
@@ -15,8 +15,6 @@ interface VerdictSummary {
|
||||
counts: Record<Verdict, number>;
|
||||
/** Issues in scope, whatever their verdict. */
|
||||
total: number;
|
||||
/** Failed findings those issues fold. */
|
||||
findings: number;
|
||||
}
|
||||
|
||||
const ALL_TAB = "all";
|
||||
@@ -142,14 +140,6 @@ export function VerdictFilter({ summary }: { summary?: VerdictSummary }) {
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
{summary ? (
|
||||
// The fold: how much noise the issue count removed.
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{summary.findings.toLocaleString()} failed findings folded into{" "}
|
||||
{summary.total.toLocaleString()}{" "}
|
||||
{summary.total === 1 ? "issue" : "issues"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Tabs>
|
||||
{/* Only filters that arrived by link get a chip; the tabs and the scope
|
||||
|
||||
Reference in new issue
Block a user