From 53b7b24e2bf339a69908dded1806f18b262b044b Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:36:57 +0100 Subject: [PATCH] feat(ui): explain verdicts in the Issues list and open rows in a side panel (#13189) Co-authored-by: Claude Opus 5.5 --- .../issues/_components/issues-list.tsx | 2 +- ui/components/issues/issue-detail.tsx | 30 +-- ui/components/issues/issue-panel.tsx | 159 ++++++++++++ ui/components/issues/issues-summary.test.tsx | 51 ++-- ui/components/issues/issues-summary.tsx | 96 ++----- ui/components/issues/issues-table.test.tsx | 136 +++++++--- ui/components/issues/issues-table.tsx | 243 ++++++++++-------- .../issues/issues.integration.test.tsx | 142 +++++++--- ui/lib/issues/verdicts.ts | 41 ++- 9 files changed, 582 insertions(+), 318 deletions(-) create mode 100644 ui/components/issues/issue-panel.tsx diff --git a/ui/app/(prowler)/issues/_components/issues-list.tsx b/ui/app/(prowler)/issues/_components/issues-list.tsx index ce7ce9d3ea..87c17f2dfc 100644 --- a/ui/app/(prowler)/issues/_components/issues-list.tsx +++ b/ui/app/(prowler)/issues/_components/issues-list.tsx @@ -65,7 +65,7 @@ export const IssuesList = async ({ <> {attributes ? (
- +
) : ( diff --git a/ui/components/issues/issue-detail.tsx b/ui/components/issues/issue-detail.tsx index 585d35c94a..f67cf9127f 100644 --- a/ui/components/issues/issue-detail.tsx +++ b/ui/components/issues/issue-detail.tsx @@ -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 = { - 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 = { - reachability: "Reachability", - access: "Access", - target: "Target", - controls: "Controls", - verification: "Verification", -}; -const STATE_LABELS: Record = { - 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]} + +
+ )} +
    + {(Object.keys(LEG_LABELS) as LegName[]).map((name) => { + const leg = attributes.legs[name]; + if (!leg) return null; + return ( +
  • +
    + + {LEG_LABELS[name]} + + + {LEG_QUESTIONS[name]} + + + {leg.text} + +
    + + {LEG_STATE_LABELS[leg.state]} + +
  • + ); + })} +
+
+ + +

+ {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 ( +
  • + + + + + +

    {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,