mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
feat(ui): improve attack paths page layout and UX (#10249)
This commit is contained in:
@@ -2,7 +2,12 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.19.1] (Prowler v5.19.1)
|
||||
|
||||
## [1.19.1] (Prowler v5.19.1 UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths: Improved error handling for server errors (5xx) and network failures with user-friendly messages instead of raw internal errors
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
|
||||
67
ui/actions/attack-paths/queries.test.ts
Normal file
67
ui/actions/attack-paths/queries.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
import { executeQuery } from "./queries";
|
||||
|
||||
describe("executeQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("returns a friendly message when API response handling throws", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 500,
|
||||
}),
|
||||
);
|
||||
handleApiResponseMock.mockRejectedValue(
|
||||
new Error("Server error (500): backend database unavailable"),
|
||||
);
|
||||
|
||||
// When
|
||||
const result = await executeQuery(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"aws-iam-statements-allow-all-actions",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(handleApiResponseMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
error:
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.",
|
||||
status: 503,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined and skips fetch for invalid scan ids", async () => {
|
||||
// When
|
||||
const result = await executeQuery(
|
||||
"not-a-uuid",
|
||||
"aws-iam-statements-allow-all-actions",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(handleApiResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
AttackPathQueriesResponse,
|
||||
AttackPathQuery,
|
||||
AttackPathQueryError,
|
||||
AttackPathQueryResult,
|
||||
ExecuteQueryRequest,
|
||||
} from "@/types/attack-paths";
|
||||
@@ -59,7 +60,7 @@ export const executeQuery = async (
|
||||
scanId: string,
|
||||
queryId: string,
|
||||
parameters?: Record<string, string | number | boolean>,
|
||||
): Promise<AttackPathQueryResult | undefined> => {
|
||||
): Promise<AttackPathQueryResult | AttackPathQueryError | undefined> => {
|
||||
// Validate scanId is a valid UUID format to prevent request forgery
|
||||
const validatedScanId = UUIDSchema.safeParse(scanId);
|
||||
if (!validatedScanId.success) {
|
||||
@@ -89,9 +90,15 @@ export const executeQuery = async (
|
||||
},
|
||||
);
|
||||
|
||||
return handleApiResponse(response);
|
||||
return (await handleApiResponse(response)) as
|
||||
| AttackPathQueryResult
|
||||
| AttackPathQueryError;
|
||||
} catch (error) {
|
||||
console.error("Error executing query on scan:", error);
|
||||
return undefined;
|
||||
return {
|
||||
error:
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.",
|
||||
status: 503,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +82,11 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
};
|
||||
|
||||
const isSelectDisabled = (scan: AttackPathScan) => {
|
||||
return !scan.attributes.graph_data_ready || selectedScanId === scan.id;
|
||||
return (
|
||||
!scan.attributes.graph_data_ready ||
|
||||
scan.attributes.state === SCAN_STATES.FAILED ||
|
||||
selectedScanId === scan.id
|
||||
);
|
||||
};
|
||||
|
||||
const getSelectButtonLabel = (scan: AttackPathScan) => {
|
||||
@@ -133,7 +137,7 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Progress</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
<TableHead className="text-right"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -344,10 +348,6 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-6 text-xs">
|
||||
Scans can be selected when data is available. A new scan does not
|
||||
interrupt access to existing data.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ const BADGE_CONFIG: Record<
|
||||
[SCAN_STATES.FAILED]: {
|
||||
className: "bg-bg-fail-secondary text-text-error-primary",
|
||||
label: "Failed",
|
||||
showGraphDot: true,
|
||||
showGraphDot: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Maximize2, X } from "lucide-react";
|
||||
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
@@ -12,7 +13,14 @@ import {
|
||||
} from "@/actions/attack-paths";
|
||||
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
||||
import { AutoRefresh } from "@/components/scans";
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,6 +31,7 @@ import {
|
||||
} from "@/components/ui";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
AttackPathQueryError,
|
||||
AttackPathScan,
|
||||
GraphNode,
|
||||
} from "@/types/attack-paths";
|
||||
@@ -201,7 +210,7 @@ export default function AttackPathAnalysisPage() {
|
||||
);
|
||||
|
||||
if (result && "error" in result) {
|
||||
const apiError = result as unknown as { error: string; status: number };
|
||||
const apiError = result as AttackPathQueryError;
|
||||
graphState.resetGraph();
|
||||
|
||||
if (apiError.status === 404) {
|
||||
@@ -213,6 +222,11 @@ export default function AttackPathAnalysisPage() {
|
||||
"Error",
|
||||
"Not enough permissions to execute this query",
|
||||
);
|
||||
} else if (apiError.status >= 500) {
|
||||
const serverDownMessage =
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.";
|
||||
graphState.setError(serverDownMessage);
|
||||
showErrorToast("Error", serverDownMessage);
|
||||
} else {
|
||||
graphState.setError(apiError.error);
|
||||
showErrorToast("Error", apiError.error);
|
||||
@@ -242,8 +256,11 @@ export default function AttackPathAnalysisPage() {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
const rawErrorMsg =
|
||||
error instanceof Error ? error.message : "Failed to execute query";
|
||||
const errorMsg = rawErrorMsg.includes("Server Components render")
|
||||
? "Server is temporarily unavailable. Please try again in a few minutes."
|
||||
: rawErrorMsg;
|
||||
graphState.resetGraph();
|
||||
graphState.setError(errorMsg);
|
||||
showErrorToast("Error", errorMsg);
|
||||
@@ -318,34 +335,40 @@ export default function AttackPathAnalysisPage() {
|
||||
Select a scan, build a query, and visualize Attack Paths in your
|
||||
infrastructure.
|
||||
</p>
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-xs">
|
||||
Scans can be selected when data is available. A new scan does not
|
||||
interrupt access to existing data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Top Section - Scans Table and Query Builder (2 columns) */}
|
||||
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
|
||||
{/* Scans Table Section - Left Column */}
|
||||
<div>
|
||||
{scansLoading ? (
|
||||
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
|
||||
<p className="text-sm">Loading scans...</p>
|
||||
</div>
|
||||
) : scans.length === 0 ? (
|
||||
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
|
||||
<p className="text-sm">No scans available</p>
|
||||
</div>
|
||||
<Alert variant="info">
|
||||
<Info className="size-4" />
|
||||
<AlertTitle>No scans available</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span>
|
||||
You need to run a scan before you can analyze attack paths.{" "}
|
||||
<Link href="/scans" className="font-medium underline">
|
||||
Go to Scan Jobs
|
||||
</Link>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{/* Scans Table */}
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
<ScanListTable scans={scans} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Query Builder Section - Right Column */}
|
||||
{/* Query Builder Section - shown only after selecting a scan */}
|
||||
{scanId && (
|
||||
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
|
||||
{!scanId ? (
|
||||
<p className="text-text-info dark:text-text-info text-sm">
|
||||
Select a scan from the table on the left to begin.
|
||||
</p>
|
||||
) : queriesLoading ? (
|
||||
{queriesLoading ? (
|
||||
<p className="text-sm">Loading queries...</p>
|
||||
) : queriesError ? (
|
||||
<p className="text-text-danger dark:text-text-danger text-sm">
|
||||
@@ -363,9 +386,13 @@ export default function AttackPathAnalysisPage() {
|
||||
{queryBuilder.selectedQueryData && (
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary dark:text-text-neutral-secondary rounded-md p-3 text-sm">
|
||||
<p className="whitespace-pre-line">
|
||||
{queryBuilder.selectedQueryData.attributes.description}
|
||||
{
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.description
|
||||
}
|
||||
</p>
|
||||
{queryBuilder.selectedQueryData.attributes.attribution && (
|
||||
{queryBuilder.selectedQueryData.attributes
|
||||
.attribution && (
|
||||
<p className="mt-2 text-xs">
|
||||
Source:{" "}
|
||||
<a
|
||||
@@ -410,9 +437,13 @@ export default function AttackPathAnalysisPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Section - Graph Visualization (Full Width) */}
|
||||
{/* Graph Visualization (Full Width) */}
|
||||
{(graphState.loading ||
|
||||
(graphState.data &&
|
||||
graphState.data.nodes &&
|
||||
graphState.data.nodes.length > 0)) && (
|
||||
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
|
||||
{graphState.loading ? (
|
||||
<GraphLoading />
|
||||
@@ -454,7 +485,7 @@ export default function AttackPathAnalysisPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-button-primary inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm sm:px-4 sm:text-sm"
|
||||
className="bg-bg-info-secondary text-text-info inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium shadow-sm sm:px-4 sm:text-sm"
|
||||
role="status"
|
||||
aria-label="Graph interaction instructions"
|
||||
>
|
||||
@@ -462,7 +493,8 @@ export default function AttackPathAnalysisPage() {
|
||||
💡
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
Click on any node to filter and view its connected paths
|
||||
Click on any node to filter and view its connected
|
||||
paths
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -474,7 +506,9 @@ export default function AttackPathAnalysisPage() {
|
||||
onZoomOut={() => graphRef.current?.zoomOut()}
|
||||
onFitToScreen={() => graphRef.current?.resetZoom()}
|
||||
onExport={() =>
|
||||
handleGraphExport(graphRef.current?.getSVGElement() || null)
|
||||
handleGraphExport(
|
||||
graphRef.current?.getSVGElement() || null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -502,7 +536,9 @@ export default function AttackPathAnalysisPage() {
|
||||
</DialogHeader>
|
||||
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
|
||||
<GraphControls
|
||||
onZoomIn={() => fullscreenGraphRef.current?.zoomIn()}
|
||||
onZoomIn={() =>
|
||||
fullscreenGraphRef.current?.zoomIn()
|
||||
}
|
||||
onZoomOut={() =>
|
||||
fullscreenGraphRef.current?.zoomOut()
|
||||
}
|
||||
@@ -552,15 +588,19 @@ export default function AttackPathAnalysisPage() {
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
|
||||
{graphState.selectedNode?.labels.some(
|
||||
(label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
label
|
||||
.toLowerCase()
|
||||
.includes("finding"),
|
||||
)
|
||||
? graphState.selectedNode?.properties
|
||||
?.check_title ||
|
||||
graphState.selectedNode?.properties?.id ||
|
||||
graphState.selectedNode?.properties
|
||||
?.id ||
|
||||
"Unknown Finding"
|
||||
: graphState.selectedNode?.properties
|
||||
?.name ||
|
||||
graphState.selectedNode?.properties?.id ||
|
||||
graphState.selectedNode?.properties
|
||||
?.id ||
|
||||
"Unknown Resource"}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -587,7 +627,10 @@ export default function AttackPathAnalysisPage() {
|
||||
</div>
|
||||
|
||||
{/* Graph in the middle */}
|
||||
<div ref={graphContainerRef} className="h-[calc(100vh-22rem)]">
|
||||
<div
|
||||
ref={graphContainerRef}
|
||||
className="h-[calc(100vh-22rem)]"
|
||||
>
|
||||
<AttackPathGraph
|
||||
ref={graphRef}
|
||||
data={graphState.data}
|
||||
@@ -602,15 +645,9 @@ export default function AttackPathAnalysisPage() {
|
||||
<GraphLegend data={graphState.data} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-center">
|
||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-sm">
|
||||
Select a query and click "Execute Query" to visualize
|
||||
the Attack Paths graph
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Node Detail Panel - Below Graph */}
|
||||
{graphState.selectedNode && graphState.data && (
|
||||
@@ -668,6 +705,8 @@ export default function AttackPathAnalysisPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,6 +175,11 @@ export interface AttackPathQueryResult {
|
||||
data: QueryResultData;
|
||||
}
|
||||
|
||||
export interface AttackPathQueryError {
|
||||
error: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
// Finding severity and status constants
|
||||
export const FINDING_SEVERITIES = {
|
||||
CRITICAL: "critical",
|
||||
|
||||
Reference in New Issue
Block a user