mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
feat(ui): explain verdicts in the Issues list and open rows in a side panel (#13189)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e74a9a909e
commit
53b7b24e2b
9 files changed
+582
-318
No files matched your search
@@ -65,7 +65,7 @@ export const IssuesList = async ({
|
||||
<>
|
||||
{attributes ? (
|
||||
<div className="mb-6">
|
||||
<IssuesSummary summary={attributes} scope={scope} />
|
||||
<IssuesSummary summary={attributes} />
|
||||
</div>
|
||||
) : (
|
||||
<IssuesLoadError message="Failed to load issue summary." />
|
||||
|
||||
@@ -16,7 +16,12 @@ import { Card } from "@/components/shadcn/card/card";
|
||||
import { CodeSnippet } from "@/components/shadcn/code-snippet";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { riskFactors } from "@/lib/issues/risk-factors";
|
||||
import { legBadgeVariant } from "@/lib/issues/verdicts";
|
||||
import {
|
||||
LEG_LABELS,
|
||||
LEG_QUESTIONS,
|
||||
LEG_STATE_LABELS,
|
||||
legBadgeVariant,
|
||||
} from "@/lib/issues/verdicts";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
@@ -27,29 +32,8 @@ 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;
|
||||
|
||||
// The issue carries resource uids, not ids, so this opens the Resources list
|
||||
@@ -215,7 +199,7 @@ export const IssueDetail = ({
|
||||
<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]}
|
||||
{LEG_STATE_LABELS[leg.state]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { IssueTriage } from "@/components/issues/issue-triage";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { EntityInfo } from "@/components/shadcn/entities";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
|
||||
import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
|
||||
import {
|
||||
LEG_LABELS,
|
||||
LEG_QUESTIONS,
|
||||
LEG_STATE_LABELS,
|
||||
legBadgeVariant,
|
||||
VERDICTS,
|
||||
} from "@/lib/issues/verdicts";
|
||||
import {
|
||||
ATTACKER_START_LABEL,
|
||||
ISSUE_OUTCOME_LABEL,
|
||||
IssueResource,
|
||||
LegName,
|
||||
} from "@/types/issues";
|
||||
|
||||
interface IssuePanelProps {
|
||||
issue: IssueResource;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict and the reasoning behind it, from the list row alone (no
|
||||
* fetch). Graph, resources, findings and compliance stay on the issue page.
|
||||
*/
|
||||
export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
|
||||
const { attributes } = issue;
|
||||
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
|
||||
const scanId = issue.relationships?.scan?.data?.id;
|
||||
|
||||
return (
|
||||
<DetailSidePanel
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={attributes.title}
|
||||
description="Why this issue got its verdict and how to fix it"
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant={verdict?.variant ?? "tag"}>
|
||||
{verdict?.label ?? attributes.verdict}
|
||||
</Badge>
|
||||
<SeverityBadge severity={attributes.severity} />
|
||||
</div>
|
||||
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
|
||||
{attributes.title}
|
||||
</h3>
|
||||
<EntityInfo
|
||||
cloudProvider={attributes.cloud}
|
||||
entityAlias={attributes.account}
|
||||
/>
|
||||
<p className="text-text-neutral-tertiary text-xs break-all">
|
||||
{attributes.findings_count.toLocaleString()}{" "}
|
||||
{attributes.findings_count === 1 ? "finding" : "findings"} ·{" "}
|
||||
{attributes.pattern}
|
||||
</p>
|
||||
<Button asChild variant="link" size="link-sm" className="self-start">
|
||||
<Link href={`/issues/${issue.id}`}>Open the full path</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
defaultValue="exploitability"
|
||||
className="flex min-h-0 w-full flex-1 flex-col"
|
||||
>
|
||||
<div className="mb-4 shrink-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="exploitability">Exploitability</TabsTrigger>
|
||||
<TabsTrigger value="remediation">Remediation</TabsTrigger>
|
||||
<TabsTrigger value="triage">Triage</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
<TabsContent value="exploitability" className="flex flex-col gap-4">
|
||||
{attributes.attacker_start && attributes.outcome && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="Starts from">
|
||||
{ATTACKER_START_LABEL[attributes.attacker_start]}
|
||||
</InfoField>
|
||||
<InfoField label="Leads to">
|
||||
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
|
||||
</InfoField>
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
aria-label="Exploitability assessment"
|
||||
className="divide-border-neutral-secondary flex flex-col divide-y"
|
||||
>
|
||||
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
|
||||
const leg = attributes.legs[name];
|
||||
if (!leg) return null;
|
||||
return (
|
||||
<li key={name} className="flex items-start gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{LEG_LABELS[name]}
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
{LEG_QUESTIONS[name]}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
{leg.text}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={legBadgeVariant(name, leg.state)}>
|
||||
{LEG_STATE_LABELS[leg.state]}
|
||||
</Badge>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="remediation" className="flex flex-col gap-2">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{attributes.fix}
|
||||
</p>
|
||||
{scanId && (
|
||||
<Button
|
||||
asChild
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
className="self-start"
|
||||
>
|
||||
<Link
|
||||
href={`/findings?${new URLSearchParams({ "filter[scan__in]": scanId, "filter[status__in]": "FAIL" })}`}
|
||||
>
|
||||
Open this scan's failed findings
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="triage">
|
||||
<IssueTriage
|
||||
key={`${issue.id}-${attributes.updated_at}`}
|
||||
id={issue.id}
|
||||
status={attributes.triage_status}
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</DetailSidePanel>
|
||||
);
|
||||
};
|
||||
@@ -6,9 +6,9 @@ import { IssuesOverviewAttributes } from "@/types/issues";
|
||||
|
||||
const summary: IssuesOverviewAttributes = {
|
||||
issues: 13,
|
||||
findings: 41,
|
||||
findings: 1266,
|
||||
actionable: 4,
|
||||
not_exploitable_findings: 1,
|
||||
not_exploitable_findings: 283,
|
||||
exploitable: 1,
|
||||
likely: 3,
|
||||
conditional: 5,
|
||||
@@ -17,35 +17,34 @@ const summary: IssuesOverviewAttributes = {
|
||||
};
|
||||
|
||||
describe("IssuesSummary", () => {
|
||||
it("leads with the reduction and links every tile into the scoped list", () => {
|
||||
it("states the reduction and the findings a control already covers", () => {
|
||||
// Given / When
|
||||
render(<IssuesSummary summary={summary} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("paragraph")).toHaveTextContent(
|
||||
"13 issues folded from 1,266 failed findings · 283 findings already covered by a control",
|
||||
);
|
||||
// The per-verdict links live on the verdict tabs, not here.
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops the coverage clause when no finding is covered", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<IssuesSummary
|
||||
summary={summary}
|
||||
scope={{ "filter[provider_type__in]": "aws" }}
|
||||
summary={{
|
||||
...summary,
|
||||
issues: 1,
|
||||
findings: 1,
|
||||
not_exploitable_findings: 0,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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",
|
||||
// Then
|
||||
expect(screen.getByRole("paragraph")).toHaveTextContent(
|
||||
/^1 issue folded from 1 failed finding$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,92 +1,26 @@
|
||||
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>
|
||||
);
|
||||
const count = (value: number, noun: string) =>
|
||||
`${value.toLocaleString()} ${noun}${value === 1 ? "" : "s"}`;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The reduction the page makes, as one line under the title. Per-verdict
|
||||
* counts live on the verdict tabs, so they are not repeated here.
|
||||
*/
|
||||
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>
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
<span className="text-text-neutral-primary font-medium">
|
||||
{count(summary.issues, "issue")}
|
||||
</span>{" "}
|
||||
folded from{" "}
|
||||
<span className="text-text-neutral-primary font-medium">
|
||||
{count(summary.findings, "failed finding")}
|
||||
</span>
|
||||
{summary.not_exploitable_findings > 0 &&
|
||||
` · ${count(summary.not_exploitable_findings, "finding")} already covered by a control`}
|
||||
</p>
|
||||
);
|
||||
@@ -1,15 +1,13 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { 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(),
|
||||
}));
|
||||
const { push } = vi.hoisted(() => ({ push: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/issues",
|
||||
useRouter: () => ({ push, refresh: vi.fn() }),
|
||||
@@ -17,17 +15,29 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() }));
|
||||
vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
|
||||
vi.mock("@/actions/issues", () => ({
|
||||
getIssue,
|
||||
getIssueNotes: async () => [],
|
||||
vi.mock("@/actions/issues", () => ({ getIssueNotes: async () => [] }));
|
||||
// The real panel portals into the app shell's side panel, absent here.
|
||||
vi.mock("@/components/side-panel/detail-side-panel", () => ({
|
||||
DetailSidePanel: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<section aria-label={title}>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
Close panel
|
||||
</button>
|
||||
{children}
|
||||
</section>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-compliance", () => ({
|
||||
IssueCompliance: () => null,
|
||||
}));
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-graph", () => ({ IssueGraph: () => null }));
|
||||
|
||||
const issue: IssueResponse["data"][number] = {
|
||||
type: "issues",
|
||||
@@ -47,22 +57,18 @@ const issue: IssueResponse["data"][number] = {
|
||||
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"],
|
||||
legs: {
|
||||
reachability: { state: "yes", text: "Public", evidence: [] },
|
||||
access: { state: "partial", text: "Some controls", evidence: [] },
|
||||
target: { state: "unknown", text: "Not verified", evidence: [] },
|
||||
controls: { state: "no", text: "Block Public Access off", evidence: [] },
|
||||
verification: { state: "yes", text: "Logging on", evidence: [] },
|
||||
},
|
||||
},
|
||||
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
|
||||
};
|
||||
|
||||
describe("IssuesTable", () => {
|
||||
beforeEach(() => {
|
||||
getIssue.mockResolvedValue({ data: { attributes: { resource_uids: [] } } });
|
||||
});
|
||||
|
||||
it("sorts findings and verdicts through the URL", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -80,25 +86,77 @@ describe("IssuesTable", () => {
|
||||
scroll: false,
|
||||
});
|
||||
});
|
||||
it("opens and closes the detail with the keyboard", async () => {
|
||||
|
||||
it("says where the path starts, where it leads and why, in the row", () => {
|
||||
// Given / When
|
||||
render(<IssuesTable issues={[issue]} />);
|
||||
const row = screen.getByRole("row", { name: /Public bucket/ });
|
||||
|
||||
// Then
|
||||
expect(
|
||||
within(row).getByText("The internet → Data exposure · aws_public_bucket"),
|
||||
).toBeInTheDocument();
|
||||
expect(within(row).getByText("High")).toBeInTheDocument();
|
||||
const legs = within(row).getByRole("list", { name: "Attack path legs" });
|
||||
expect(
|
||||
within(legs)
|
||||
.getAllByRole("listitem")
|
||||
.map((item) => item.getAttribute("aria-label")),
|
||||
).toEqual([
|
||||
"Reachability: Yes",
|
||||
"Access: Partly",
|
||||
"Target: Unknown",
|
||||
"Controls: No",
|
||||
"Verification: Yes",
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens the issue in the side panel and closes it", 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
|
||||
await user.click(screen.getByRole("button", { name: "Public bucket" }));
|
||||
|
||||
// Then: the assessment tab leads, the full page stays one click away
|
||||
const panel = screen.getByRole("region", { name: "Public bucket" });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Collapse Public bucket" }),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
expect(await screen.findByText("Restrict access")).toBeInTheDocument();
|
||||
within(panel).getByRole("tab", { name: "Exploitability" }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(within(panel).getByText("Block Public Access off")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Open the full path" }),
|
||||
within(panel).getByRole("link", { name: "Open the full path" }),
|
||||
).toHaveAttribute("href", "/issues/issue-1");
|
||||
|
||||
// When
|
||||
await user.keyboard(" ");
|
||||
await user.click(within(panel).getByRole("tab", { name: "Remediation" }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByText("Restrict access")).not.toBeInTheDocument();
|
||||
expect(within(panel).getByText("Restrict access")).toBeVisible();
|
||||
expect(
|
||||
within(panel).getByRole("link", {
|
||||
name: "Open this scan's failed findings",
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(within(panel).getByRole("tab", { name: "Triage" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
within(panel).getByRole("textbox", { name: "Triage note" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Close panel" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Public bucket" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,93 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef, Row } from "@tanstack/react-table";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
import { IssueDetail } from "@/components/issues/issue-detail";
|
||||
import { IssuePanel } from "@/components/issues/issue-panel";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/shadcn";
|
||||
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 { SeverityBadge } from "@/components/shadcn/table/severity-badge";
|
||||
import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge";
|
||||
import { VERDICTS } from "@/lib/issues/verdicts";
|
||||
import {
|
||||
attackPathLabel,
|
||||
LEG_LABELS,
|
||||
LEG_STATE_LABELS,
|
||||
legBadgeVariant,
|
||||
VERDICTS,
|
||||
} from "@/lib/issues/verdicts";
|
||||
import { MetaDataProps } from "@/types";
|
||||
import { IssueResponse } from "@/types/issues";
|
||||
import { IssueResponse, LegName } from "@/types/issues";
|
||||
|
||||
type IssueRow = IssueResponse["data"][number];
|
||||
|
||||
interface IssueExpansion {
|
||||
openId: string | null;
|
||||
toggle: (id: string) => void;
|
||||
}
|
||||
/** One letter per leg, in the order the legs are asked. */
|
||||
const LEG_LETTERS: Record<LegName, string> = {
|
||||
reachability: "R",
|
||||
access: "A",
|
||||
target: "T",
|
||||
controls: "C",
|
||||
verification: "V",
|
||||
};
|
||||
|
||||
const IssueExpansionContext = createContext<IssueExpansion | null>(null);
|
||||
const OpenIssueContext = createContext<(id: string) => void>(() => {});
|
||||
|
||||
function IssueExpandCell({ row }: { row: Row<IssueRow> }) {
|
||||
const expansion = useContext(IssueExpansionContext);
|
||||
const expanded = expansion?.openId === row.original.id;
|
||||
function IssueCell({ issue }: { issue: IssueRow }) {
|
||||
const open = useContext(OpenIssueContext);
|
||||
const { title, pattern } = issue.attributes;
|
||||
const path = attackPathLabel(issue.attributes);
|
||||
// Single line like the finding groups title: ellipsis past the max width,
|
||||
// the full title in the tooltip.
|
||||
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>
|
||||
<div className="flex max-w-[500px] min-w-[120px] flex-col gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(issue.id)}
|
||||
className="text-text-neutral-primary hover:text-button-tertiary block cursor-pointer truncate border-none bg-transparent p-0 text-left text-sm hover:underline"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{title}</TooltipContent>
|
||||
</Tooltip>
|
||||
<p className="text-text-neutral-tertiary truncate text-xs">
|
||||
{path ? `${path} · ${pattern}` : pattern}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -109,13 +82,87 @@ const columns: ColumnDef<IssueRow>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "issue",
|
||||
header: "Issue",
|
||||
cell: ({ row }) => <IssueCell issue={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "legs",
|
||||
header: "Why",
|
||||
cell: ({ row }) => (
|
||||
<ul aria-label="Attack path legs" className="flex gap-1">
|
||||
{(Object.keys(LEG_LETTERS) as LegName[]).map((name) => {
|
||||
const leg = row.original.attributes.legs[name];
|
||||
if (!leg) return null;
|
||||
const label = `${LEG_LABELS[name]}: ${LEG_STATE_LABELS[leg.state]}`;
|
||||
return (
|
||||
<li key={name} aria-label={label}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={legBadgeVariant(name, leg.state)}
|
||||
size="sm"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{LEG_LETTERS[name]}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="font-medium">{label}</p>
|
||||
<p>{leg.text}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "severity",
|
||||
header: "Severity",
|
||||
cell: ({ row }) => (
|
||||
<SeverityBadge severity={row.original.attributes.severity} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "provider",
|
||||
header: "Account",
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[240px]">
|
||||
<EntityInfo
|
||||
cloudProvider={row.original.attributes.cloud}
|
||||
entityAlias={row.original.attributes.account}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "triage",
|
||||
header: "Triage",
|
||||
cell: ({ row }) => {
|
||||
const triage = row.original.attributes.triage_status;
|
||||
return <TriageStatusBadge status={triage} showLabel={false} />;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<TriageStatusBadge
|
||||
status={row.original.attributes.triage_status}
|
||||
showLabel={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -126,47 +173,29 @@ export const IssuesTable = ({
|
||||
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"
|
||||
>
|
||||
<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}
|
||||
issue={row.original.attributes}
|
||||
scanId={row.original.relationships?.scan?.data?.id}
|
||||
providerId={row.original.relationships?.provider?.data?.id}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null;
|
||||
const openIssue = issues.find((issue) => issue.id === openId);
|
||||
|
||||
return (
|
||||
<div className="@container min-w-0">
|
||||
<IssueExpansionContext value={{ openId, toggle: toggleRow }}>
|
||||
<OpenIssueContext value={setOpenId}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={issues}
|
||||
metadata={metadata}
|
||||
onRowClick={(row) => toggleRow(row.original.id)}
|
||||
renderAfterRow={renderAfterRow}
|
||||
onRowClick={(row) => setOpenId(row.original.id)}
|
||||
/>
|
||||
</IssueExpansionContext>
|
||||
</OpenIssueContext>
|
||||
{openIssue && (
|
||||
<IssuePanel
|
||||
key={openIssue.id}
|
||||
issue={openIssue}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOpenId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { page, userEvent } from "vitest/browser";
|
||||
import { page } from "vitest/browser";
|
||||
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
@@ -12,6 +12,8 @@ import { getScan } from "@/actions/scans";
|
||||
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 { IssueDetail } from "@/components/issues/issue-detail";
|
||||
import { GlobalSidePanel } from "@/components/side-panel/global-side-panel";
|
||||
import { applyDefaultMutedFilter, extractFiltersAndQuery } from "@/lib";
|
||||
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
|
||||
import { IssueResource, IssuesOverviewAttributes } from "@/types/issues";
|
||||
@@ -191,10 +193,14 @@ describe("Issues responsive layout", () => {
|
||||
});
|
||||
}),
|
||||
);
|
||||
const screen = await render(await IssuesList({ searchParams: {} }));
|
||||
await screen
|
||||
.getByRole("button", { name: `Expand ${issue.attributes.title}` })
|
||||
.click();
|
||||
const screen = await render(
|
||||
<IssueDetail
|
||||
id={issue.id}
|
||||
issue={issue.attributes}
|
||||
scanId="scan-1"
|
||||
providerId="provider-1"
|
||||
/>,
|
||||
);
|
||||
const link = screen.getByRole("link", { name: "S3 bucket is public" });
|
||||
await expect.element(link).toBeVisible();
|
||||
|
||||
@@ -227,6 +233,83 @@ describe("Issues responsive layout", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a clicked row in the app's Details side panel", async () => {
|
||||
// Given: the list next to the real side panel host the app shell mounts.
|
||||
setup();
|
||||
const content = await IssuesList({ searchParams: {} });
|
||||
const screen = await render(
|
||||
<>
|
||||
{content}
|
||||
<GlobalSidePanel />
|
||||
</>,
|
||||
);
|
||||
|
||||
// When: a plain cell of the row is clicked, not the title button.
|
||||
await screen
|
||||
.getByRole("row", { name: /Public bucket exposes customer data/ })
|
||||
.getByText("Production")
|
||||
.click();
|
||||
|
||||
// Then
|
||||
await expect
|
||||
.element(screen.getByRole("link", { name: "Open the full path" }))
|
||||
.toHaveAttribute("href", "/issues/issue-1");
|
||||
await expect
|
||||
.element(screen.getByText("Block Public Access is disabled."))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it("resets the Details tab when another row opens", async () => {
|
||||
// Given: the first issue's panel is open on the Remediation tab. The
|
||||
// panel overlays the right edge here (no app shell pushes the page), so
|
||||
// rows are clicked on their leftmost cell.
|
||||
await page.viewport(1440, 900);
|
||||
setup();
|
||||
const secondIssue: IssueResource = {
|
||||
...issue,
|
||||
id: "issue-2",
|
||||
attributes: {
|
||||
...issue.attributes,
|
||||
title: "Admin role assumable from any account",
|
||||
account: "Staging",
|
||||
},
|
||||
};
|
||||
worker.use(
|
||||
http.get("*/issues", () =>
|
||||
HttpResponse.json({ data: [issue, secondIssue] }),
|
||||
),
|
||||
);
|
||||
const content = await IssuesList({ searchParams: {} });
|
||||
const screen = await render(
|
||||
<>
|
||||
{content}
|
||||
<GlobalSidePanel />
|
||||
</>,
|
||||
);
|
||||
await screen
|
||||
.getByRole("row", { name: /Public bucket exposes customer data/ })
|
||||
.getByText("Production")
|
||||
.click();
|
||||
await screen.getByRole("tab", { name: "Remediation" }).click();
|
||||
await expect
|
||||
.element(screen.getByRole("tab", { name: "Remediation" }))
|
||||
.toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// When
|
||||
await screen
|
||||
.getByRole("row", { name: /Admin role assumable from any account/ })
|
||||
.getByText("Likely")
|
||||
.click();
|
||||
|
||||
// Then: the panel remounted on the new issue, back on its first tab
|
||||
await expect
|
||||
.element(screen.getByRole("link", { name: "Open the full path" }))
|
||||
.toHaveAttribute("href", "/issues/issue-2");
|
||||
await expect
|
||||
.element(screen.getByRole("tab", { name: "Exploitability" }))
|
||||
.toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ width: 375, theme: "light" },
|
||||
{ width: 768, theme: "light" },
|
||||
@@ -234,7 +317,7 @@ describe("Issues responsive layout", () => {
|
||||
{ width: 375, theme: "dark" },
|
||||
{ width: 1440, theme: "dark" },
|
||||
])(
|
||||
"keeps the filters and expanded detail usable at $width px in $theme mode",
|
||||
"keeps the list and the issue detail usable at $width px in $theme mode",
|
||||
async ({ width, theme }) => {
|
||||
await page.viewport(width, 1000);
|
||||
localStorage.setItem("theme", theme);
|
||||
@@ -243,19 +326,25 @@ describe("Issues responsive layout", () => {
|
||||
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>
|
||||
<main className="flex min-w-0 flex-col gap-6 p-4">
|
||||
<h1 className="text-2xl font-semibold">Issues</h1>
|
||||
{content}
|
||||
<IssueDetail
|
||||
id={issue.id}
|
||||
issue={issue.attributes}
|
||||
scanId="scan-1"
|
||||
providerId="provider-1"
|
||||
/>
|
||||
</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.getByText("The internet → Data exposure · aws_public_bucket"),
|
||||
)
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("region", { name: "Affected resources" }))
|
||||
.toBeVisible();
|
||||
@@ -300,36 +389,9 @@ describe("Issues responsive layout", () => {
|
||||
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) => {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { LegName, LegState, Verdict } from "@/types/issues";
|
||||
import {
|
||||
ATTACKER_START_LABEL,
|
||||
IssueAttributes,
|
||||
ISSUE_OUTCOME_LABEL,
|
||||
LegName,
|
||||
LegState,
|
||||
Verdict,
|
||||
} from "@/types/issues";
|
||||
|
||||
/** The colour family a verdict reads in, shared by the overview card and the tabs. */
|
||||
export const VERDICT_TONE = {
|
||||
@@ -48,6 +55,38 @@ export const VERDICTS: VerdictDisplay[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const LEG_LABELS: Record<LegName, string> = {
|
||||
reachability: "Reachability",
|
||||
access: "Access",
|
||||
target: "Target",
|
||||
controls: "Controls",
|
||||
verification: "Verification",
|
||||
};
|
||||
|
||||
export 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?",
|
||||
};
|
||||
|
||||
export const LEG_STATE_LABELS: Record<LegState, string> = {
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
partial: "Partly",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
/** "The internet → Data exposure", or null for issues that are not one path. */
|
||||
export const attackPathLabel = ({
|
||||
attacker_start,
|
||||
outcome,
|
||||
}: Pick<IssueAttributes, "attacker_start" | "outcome">) =>
|
||||
attacker_start && outcome
|
||||
? `${ATTACKER_START_LABEL[attacker_start]} → ${ISSUE_OUTCOME_LABEL[outcome]}`
|
||||
: null;
|
||||
|
||||
const LEG_GOOD_WHEN_YES: Record<LegName, boolean> = {
|
||||
reachability: false,
|
||||
access: false,
|
||||
|
||||
Reference in new issue
Block a user