mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-09 04:38:49 +00:00
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:
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 })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user