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:
Alejandro BailoandClaude Opus 5.5 authored and GitHub committed 2026-10-28 16:36:57 +01:00
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." />
+7 -23
View File
@@ -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">
+159
View File
@@ -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&apos;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>
);
};
+25 -26
View File
@@ -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$/,
);
});
});
+15 -81
View File
@@ -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>
);
+97 -39
View File
@@ -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();
});
});
+136 -107
View File
@@ -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>
);
};
+102 -40
View File
@@ -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) => {
+40 -1
View File
@@ -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,