= {
- 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 = ({
{LEG_LABELS[name]}
- {STATE_LABELS[leg.state]}
+ {LEG_STATE_LABELS[leg.state]}
diff --git a/ui/components/issues/issue-panel.tsx b/ui/components/issues/issue-panel.tsx
new file mode 100644
index 0000000000..84624f45b9
--- /dev/null
+++ b/ui/components/issues/issue-panel.tsx
@@ -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 (
+
+
+
+
+
+ {verdict?.label ?? attributes.verdict}
+
+
+
+
+ {attributes.title}
+
+
+
+ {attributes.findings_count.toLocaleString()}{" "}
+ {attributes.findings_count === 1 ? "finding" : "findings"} ·{" "}
+ {attributes.pattern}
+
+
+
+
+
+
+
+ Exploitability
+ Remediation
+ Triage
+
+
+
+
+
+ {attributes.attacker_start && attributes.outcome && (
+
+
+ {ATTACKER_START_LABEL[attributes.attacker_start]}
+
+
+ {ISSUE_OUTCOME_LABEL[attributes.outcome]}
+
+
+ )}
+
+
+
+
+
+ {attributes.fix}
+
+ {scanId && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/ui/components/issues/issues-summary.test.tsx b/ui/components/issues/issues-summary.test.tsx
index ffcb7a0f34..0782f576fa 100644
--- a/ui/components/issues/issues-summary.test.tsx
+++ b/ui/components/issues/issues-summary.test.tsx
@@ -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();
+
+ // 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(
,
);
- 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$/,
);
});
});
diff --git a/ui/components/issues/issues-summary.tsx b/ui/components/issues/issues-summary.tsx
index 80bd209c42..aa77608c39 100644
--- a/ui/components/issues/issues-summary.tsx
+++ b/ui/components/issues/issues-summary.tsx
@@ -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) => (
-
-
-
-);
+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;
}) => (
-
-
-
- 0 ? CardVariant.fail : CardVariant.pass}
- />
-
-
-
+
+
+ {count(summary.issues, "issue")}
+ {" "}
+ folded from{" "}
+
+ {count(summary.findings, "failed finding")}
+
+ {summary.not_exploitable_findings > 0 &&
+ ` · ${count(summary.not_exploitable_findings, "finding")} already covered by a control`}
+
);
diff --git a/ui/components/issues/issues-table.test.tsx b/ui/components/issues/issues-table.test.tsx
index adc9092632..4694496fdd 100644
--- a/ui/components/issues/issues-table.test.tsx
+++ b/ui/components/issues/issues-table.test.tsx
@@ -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 ? (
+
+
+ {children}
+
+ ) : 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();
+ 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();
+
// 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();
});
});
diff --git a/ui/components/issues/issues-table.tsx b/ui/components/issues/issues-table.tsx
index f9b0fca20b..35b42d809a 100644
--- a/ui/components/issues/issues-table.tsx
+++ b/ui/components/issues/issues-table.tsx
@@ -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 = {
+ reachability: "R",
+ access: "A",
+ target: "T",
+ controls: "C",
+ verification: "V",
+};
-const IssueExpansionContext = createContext(null);
+const OpenIssueContext = createContext<(id: string) => void>(() => {});
-function IssueExpandCell({ row }: { row: Row }) {
- 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 (
-
+
+
+
+
+
+ {title}
+
+
+ {path ? `${path} · ${pattern}` : pattern}
+
+
);
}
const columns: ColumnDef[] = [
- { id: "expand", header: "", cell: IssueExpandCell },
- {
- id: "issue",
- header: "Issue",
- cell: ({ row }) => (
-
-
- {row.original.attributes.title}
-
-
- {row.original.attributes.pattern}
-
-
- ),
- },
- {
- id: "provider",
- header: "Provider",
- cell: ({ row }) => (
-
- ),
- },
- {
- id: "findings",
- accessorKey: "attributes.findings_count",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- {row.original.attributes.findings_count}
-
- ),
- },
{
id: "verdict",
accessorKey: "attributes.verdict",
@@ -109,13 +82,87 @@ const columns: ColumnDef[] = [
);
},
},
+ {
+ id: "issue",
+ header: "Issue",
+ cell: ({ row }) => ,
+ },
+ {
+ id: "legs",
+ header: "Why",
+ cell: ({ row }) => (
+
+ {(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 (
+ -
+
+
+
+ {LEG_LETTERS[name]}
+
+
+
+
{label}
+ {leg.text}
+
+
+
+ );
+ })}
+
+ ),
+ },
+ {
+ id: "severity",
+ header: "Severity",
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ id: "provider",
+ header: "Account",
+ cell: ({ row }) => (
+
+
+
+ ),
+ },
+ {
+ id: "findings",
+ accessorKey: "attributes.findings_count",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {row.original.attributes.findings_count.toLocaleString()}
+
+ ),
+ },
{
id: "triage",
header: "Triage",
- cell: ({ row }) => {
- const triage = row.original.attributes.triage_status;
- return ;
- },
+ cell: ({ row }) => (
+
+ ),
},
];
@@ -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(null);
-
- const toggleRow = (id: string) =>
- setOpenId((current) => (current === id ? null : id));
- const renderAfterRow = (row: Row) =>
- row.original.id === openId ? (
-
-
-
-
-
-
-
-
- ) : null;
+ const openIssue = issues.find((issue) => issue.id === openId);
return (
-
+
toggleRow(row.original.id)}
- renderAfterRow={renderAfterRow}
+ onRowClick={(row) => setOpenId(row.original.id)}
/>
-
+
+ {openIssue && (
+ {
+ if (!open) setOpenId(null);
+ }}
+ />
+ )}
);
};
diff --git a/ui/components/issues/issues.integration.test.tsx b/ui/components/issues/issues.integration.test.tsx
index c82da74268..7d3057b06f 100644
--- a/ui/components/issues/issues.integration.test.tsx
+++ b/ui/components/issues/issues.integration.test.tsx
@@ -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(
+ ,
+ );
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}
+
+ >,
+ );
+
+ // 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}
+
+ >,
+ );
+ 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(
-
- Issues
+
+ Issues
{content}
+
,
);
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) => {
diff --git a/ui/lib/issues/verdicts.ts b/ui/lib/issues/verdicts.ts
index de26688096..f8cfe54c46 100644
--- a/ui/lib/issues/verdicts.ts
+++ b/ui/lib/issues/verdicts.ts
@@ -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 = {
+ reachability: "Reachability",
+ access: "Access",
+ target: "Target",
+ controls: "Controls",
+ verification: "Verification",
+};
+
+export const LEG_QUESTIONS: Record = {
+ 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 = {
+ 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) =>
+ attacker_start && outcome
+ ? `${ATTACKER_START_LABEL[attacker_start]} → ${ISSUE_OUTCOME_LABEL[outcome]}`
+ : null;
+
const LEG_GOOD_WHEN_YES: Record = {
reachability: false,
access: false,