feat(ui): show the attack path in the Issues panel and rename the menu entry (#13195)

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-29 10:43:42 +01:00
1 parent 814ae54d6a
commit ca6ed48664
20 files changed
+461 -109

No files matched your search

@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { IssueAttributes } from "@/types/issues";
const { getIssue, getIssueFindings } = vi.hoisted(() => ({
getIssue: vi.fn(),
getIssueFindings: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/actions/issues", () => ({ getIssue, getIssueFindings }));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: () => <p>graph</p>,
}));
import { IssueAttackPath } from "./issue-attack-path";
const issue = { legs: {} } as IssueAttributes;
describe("IssueAttackPath", () => {
it("says there is nothing to draw, not a load error, without resources", async () => {
// Given: no resources, and findings that cannot load without a scan
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: [], finding_uids: ["f1"] } },
});
// When
render(<IssueAttackPath id="issue-1" issue={issue} />);
// Then
expect(
await screen.findByText("This issue has no affected resources to draw."),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("offers a retry when the findings for the drawn resources fail", async () => {
// Given
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: ["bucket"], finding_uids: ["f1"] } },
});
// When
render(<IssueAttackPath id="issue-1" issue={issue} />);
// Then
expect(
await screen.findByText("Could not load the attack path for this issue."),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
"use client";
import { IssueGraph } from "@/components/issues/issue-graph";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { IssueAttributes } from "@/types/issues";
interface IssueAttackPathProps {
id: string;
issue: IssueAttributes;
scanId?: string;
providerId?: string;
}
/** The issue's graph for the side panel; findings open on the full page. */
export const IssueAttackPath = ({
id,
issue,
scanId,
providerId,
}: IssueAttackPathProps) => {
const { state, retry } = useIssueEvidence(id, scanId, providerId);
if (state.status === "loading") {
return (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading the attack path…
</p>
);
}
// Nothing to draw beats a findings error: without resources there is no
// graph for the findings to land on.
if (state.status === "loaded" && state.resources.length === 0) {
return (
<p className="text-text-neutral-tertiary text-sm">
This issue has no affected resources to draw.
</p>
);
}
if (state.status === "error" || state.findings === undefined) {
return (
<IssuesLoadError
message="Could not load the attack path for this issue."
onRetry={retry}
/>
);
}
return (
<IssueGraph
issue={issue}
resourceUids={state.resources}
findings={state.findings}
readOnly
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
: { providerId: undefined, scanId: undefined })}
/>
);
};
+3 -63
View File
@@ -2,9 +2,8 @@
import { Send } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useState } from "react";
import { getIssue, getIssueFindings } from "@/actions/issues";
import { IssueCompliance } from "@/components/issues/issue-compliance";
import { IssueFindings } from "@/components/issues/issue-findings";
import { IssueGraph } from "@/components/issues/issue-graph";
@@ -15,6 +14,7 @@ import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { riskFactors } from "@/lib/issues/risk-factors";
import {
LEG_LABELS,
@@ -27,10 +27,8 @@ import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_FINDINGS_LIMIT,
ISSUE_OUTCOME_LABEL,
IssueAttributes,
IssueFinding,
LegName,
} from "@/types/issues";
@@ -52,19 +50,6 @@ interface IssueDetailProps {
providerId?: string;
}
type ResourcesState =
| { status: "loading" }
| { status: "error" }
| {
status: "loaded";
resources: string[];
findings: IssueFinding[] | undefined;
/** `getIssueFindings` capped the preview at ISSUE_FINDINGS_LIMIT. */
findingsTruncated: boolean;
scanId?: string;
providerId?: string;
};
export const IssueDetail = ({
id,
issue,
@@ -75,52 +60,7 @@ export const IssueDetail = ({
(state) => state.openJiraDispatch,
);
const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE);
const [attempt, setAttempt] = useState(0);
// Resource UIDs are only included by the detail endpoint, fetched on expansion.
const [state, setState] = useState<ResourcesState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
getIssue(id)
.then(async (detail) => {
if (cancelled) return;
const attributes = detail?.data?.attributes;
if (!attributes?.resource_uids) {
setState({ status: "error" });
return;
}
const detailScanId =
detail?.data.relationships?.scan?.data?.id ?? scanId;
const detailProviderId =
detail?.data.relationships?.provider?.data?.id ?? providerId;
const findingUids = attributes.finding_uids ?? [];
const findings = detailScanId
? await getIssueFindings(detailScanId, findingUids)
: findingUids.length === 0
? []
: undefined;
if (!cancelled)
setState({
status: "loaded",
resources: attributes.resource_uids,
findings,
findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
scanId: detailScanId,
providerId: detailProviderId,
});
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [id, scanId, providerId, attempt]);
const retry = () => {
setState({ status: "loading" });
setAttempt((value) => value + 1);
};
const { state, retry } = useIssueEvidence(id, scanId, providerId);
const activeScanId = state.status === "loaded" ? state.scanId : scanId;
const factors = riskFactors(issue);
const uniqueFindings =
+25 -2
View File
@@ -28,11 +28,11 @@ vi.mock("@/components/attack-paths/graph/attack-path-graph", () => ({
onNodeClick,
}: {
data: { nodes: GraphNode[] };
onNodeClick: (node: GraphNode) => void;
onNodeClick?: (node: GraphNode) => void;
}) => (
<button
onClick={() =>
onNodeClick(
onNodeClick?.(
data.nodes.find((node) => node.labels.includes("ProwlerFinding")) ?? {
id: "finding",
labels: ["ProwlerFinding"],
@@ -67,6 +67,29 @@ describe("IssueGraph finding lookup", () => {
navigateToFinding.mockResolvedValue(undefined);
});
it("leaves findings closed in a read-only graph", async () => {
// Given: a host that cannot hold a second detail view, such as the panel
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
readOnly
/>,
);
// When
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
// Then
expect(navigateToFinding).not.toHaveBeenCalled();
expect(getFindings).not.toHaveBeenCalled();
expect(
screen.getByText(/Open the full path to inspect a finding/),
).toBeInTheDocument();
});
it("opens a folded finding by id without another lookup", async () => {
// Given
render(
+8 -1
View File
@@ -21,6 +21,11 @@ interface IssueGraphBaseProps {
issue: IssueAttributes;
resourceUids: string[];
findings?: IssueFinding[];
/**
* No finding drawer on click: for hosts inside the side panel, where a
* second detail view would replace the one holding the graph.
*/
readOnly?: boolean;
}
interface ScopedIssueGraphProps extends IssueGraphBaseProps {
@@ -46,6 +51,7 @@ export const IssueGraph = ({
findings = [],
providerId,
scanId,
readOnly = false,
}: IssueGraphProps) => {
const finding = useFindingDetails();
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
@@ -139,6 +145,7 @@ export const IssueGraph = ({
{resourceUids.length} affected resources, plus supporting checks cited
by the assessment. These links show evidence associations, not confirmed
attack paths.
{readOnly && " Open the full path to inspect a finding."}
</p>
{loading && (
<p role="status" className="text-sm">
@@ -162,7 +169,7 @@ export const IssueGraph = ({
data={{ nodes: view.nodes, edges: view.edges }}
view={view}
expandedResources={expandedResources}
onNodeClick={handleNodeClick}
onNodeClick={readOnly ? undefined : handleNodeClick}
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
/>
</Card>
+14
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { IssueAttackPath } from "@/components/issues/issue-attack-path";
import { IssueTriage } from "@/components/issues/issue-triage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { Badge } from "@/components/shadcn/badge/badge";
@@ -38,6 +39,7 @@ 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;
const providerId = issue.relationships?.provider?.data?.id;
return (
<DetailSidePanel
@@ -78,6 +80,7 @@ export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
<div className="mb-4 shrink-0">
<TabsList>
<TabsTrigger value="exploitability">Exploitability</TabsTrigger>
<TabsTrigger value="attack-path">Attack path</TabsTrigger>
<TabsTrigger value="remediation">Remediation</TabsTrigger>
<TabsTrigger value="triage">Triage</TabsTrigger>
</TabsList>
@@ -124,6 +127,17 @@ export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
</ul>
</TabsContent>
{/* Mounted only while selected, so the graph's requests run when
someone asks for it. */}
<TabsContent value="attack-path">
<IssueAttackPath
id={issue.id}
issue={attributes}
scanId={scanId}
providerId={providerId}
/>
</TabsContent>
<TabsContent value="remediation" className="flex flex-col gap-2">
<p className="text-text-neutral-secondary text-sm">
{attributes.fix}
+38 -2
View File
@@ -7,7 +7,10 @@ import type { IssueResponse } from "@/types/issues";
import { IssuesTable } from "./issues-table";
const { push } = vi.hoisted(() => ({ push: vi.fn() }));
const { push, getIssue } = vi.hoisted(() => ({
push: vi.fn(),
getIssue: vi.fn(),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/issues",
useRouter: () => ({ push, refresh: vi.fn() }),
@@ -15,7 +18,25 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() }));
vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
vi.mock("@/actions/issues", () => ({ getIssueNotes: async () => [] }));
vi.mock("@/actions/issues", () => ({
getIssue,
getIssueFindings: async () => [],
getIssueNotes: async () => [],
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({
resourceUids,
readOnly,
}: {
resourceUids: string[];
readOnly?: boolean;
}) => (
<p>
Graph of {resourceUids.join(", ")}
{readOnly ? " (read-only)" : ""}
</p>
),
}));
// The real panel portals into the app shell's side panel, absent here.
vi.mock("@/components/side-panel/detail-side-panel", () => ({
DetailSidePanel: ({
@@ -143,6 +164,21 @@ describe("IssuesTable", () => {
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// When
getIssue.mockResolvedValue({
data: {
...issue,
attributes: { ...issue.attributes, resource_uids: ["bucket"] },
},
});
await user.click(within(panel).getByRole("tab", { name: "Attack path" }));
// Then: the graph loads on demand and leaves finding drill-down to the page
expect(
await within(panel).findByText("Graph of bucket (read-only)"),
).toBeInTheDocument();
expect(getIssue).toHaveBeenCalledWith("issue-1");
// When
await user.click(within(panel).getByRole("tab", { name: "Triage" }));
@@ -257,6 +257,17 @@ describe("Issues responsive layout", () => {
await expect
.element(screen.getByText("Block Public Access is disabled."))
.toBeVisible();
// When: the attack path tab asks for the graph
await screen.getByRole("tab", { name: "Attack path" }).click();
// Then
await expect
.element(screen.getByRole("region", { name: "Finding evidence" }))
.toBeVisible();
await expect
.element(screen.getByTestId("attack-path-finding-node"))
.toBeVisible();
});
it("resets the Details tab when another row opens", async () => {