diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 0737ece790..dba8f17aab 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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 diff --git a/ui/actions/attack-paths/queries.test.ts b/ui/actions/attack-paths/queries.test.ts new file mode 100644 index 0000000000..6c2be5f15d --- /dev/null +++ b/ui/actions/attack-paths/queries.test.ts @@ -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(); + }); +}); diff --git a/ui/actions/attack-paths/queries.ts b/ui/actions/attack-paths/queries.ts index 0332e6ec22..bc9068d52e 100644 --- a/ui/actions/attack-paths/queries.ts +++ b/ui/actions/attack-paths/queries.ts @@ -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, -): Promise => { +): Promise => { // 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, + }; } }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx index 5cf3749265..09d208f125 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx @@ -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) => { Status Progress Duration - Action + @@ -344,10 +348,6 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => { )} -

- Scans can be selected when data is available. A new scan does not - interrupt access to existing data. -

); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx index de8833749e..71c8488e00 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx @@ -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, }, }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx index f02b1205ab..22f931d6d6 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx @@ -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,355 +335,377 @@ export default function AttackPathAnalysisPage() { Select a scan, build a query, and visualize Attack Paths in your infrastructure.

+

+ Scans can be selected when data is available. A new scan does not + interrupt access to existing data. +

- {/* Top Section - Scans Table and Query Builder (2 columns) */} -
- {/* Scans Table Section - Left Column */} -
- {scansLoading ? ( -
-

Loading scans...

-
- ) : scans.length === 0 ? ( -
-

No scans available

-
- ) : ( - Loading scans...
}> - - - )} -
- - {/* Query Builder Section - Right Column */} + {scansLoading ? (
- {!scanId ? ( -

- Select a scan from the table on the left to begin. -

- ) : queriesLoading ? ( -

Loading queries...

- ) : queriesError ? ( -

- {queriesError} -

- ) : ( - <> - - +

Loading scans...

+
+ ) : scans.length === 0 ? ( + + + No scans available + + + You need to run a scan before you can analyze attack paths.{" "} + + Go to Scan Jobs + + + + + ) : ( + <> + {/* Scans Table */} + Loading scans...}> + + - {queryBuilder.selectedQueryData && ( -
-

- {queryBuilder.selectedQueryData.attributes.description} -

- {queryBuilder.selectedQueryData.attributes.attribution && ( -

- Source:{" "} - + {/* Query Builder Section - shown only after selecting a scan */} + {scanId && ( +

-
- - {/* Bottom Section - Graph Visualization (Full Width) */} -
- {graphState.loading ? ( - - ) : graphState.data && - graphState.data.nodes && - graphState.data.nodes.length > 0 ? ( - <> - {/* Info message and controls */} -
- {graphState.isFilteredView ? ( -
- -
- - - Showing paths for:{" "} - - {graphState.filteredNode?.properties?.name || - graphState.filteredNode?.properties?.id || - "Selected node"} - - -
-
- ) : ( -
- - - Click on any node to filter and view its connected paths - -
- )} - - {/* Graph controls and fullscreen button together */} -
- graphRef.current?.zoomIn()} - onZoomOut={() => graphRef.current?.zoomOut()} - onFitToScreen={() => graphRef.current?.resetZoom()} - onExport={() => - handleGraphExport(graphRef.current?.getSVGElement() || null) - } - /> - - {/* Fullscreen button */} -
- - - - - - - - Graph Fullscreen View - - -
- fullscreenGraphRef.current?.zoomIn()} - onZoomOut={() => - fullscreenGraphRef.current?.zoomOut() - } - onFitToScreen={() => - fullscreenGraphRef.current?.resetZoom() - } - onExport={() => - handleGraphExport( - fullscreenGraphRef.current?.getSVGElement() || - null, - ) - } - /> -
-
-
- -
- {/* Node Detail Panel - Side by side */} - {graphState.selectedNode && ( -
- - -
-

- Node Details -

- -
-

- {graphState.selectedNode?.labels.some( - (label) => - label.toLowerCase().includes("finding"), - ) - ? graphState.selectedNode?.properties - ?.check_title || - graphState.selectedNode?.properties?.id || - "Unknown Finding" - : graphState.selectedNode?.properties - ?.name || - graphState.selectedNode?.properties?.id || - "Unknown Resource"} -

-
-
-

- Type -

-

- {graphState.selectedNode?.labels - .map(formatNodeLabel) - .join(", ")} -

-
-
-
-
-
+

+ {queryBuilder.selectedQueryData.attributes + .attribution && ( +

+ Source:{" "} + + { + queryBuilder.selectedQueryData.attributes + .attribution.text + } + +

)}
-
-
+ )} + + {queryBuilder.selectedQuery && ( + + )} + + +
+ +
+ + {graphState.error && ( +
+ {graphState.error} +
+ )} + + )} +
+ )} + + {/* Graph Visualization (Full Width) */} + {(graphState.loading || + (graphState.data && + graphState.data.nodes && + graphState.data.nodes.length > 0)) && ( +
+ {graphState.loading ? ( + + ) : graphState.data && + graphState.data.nodes && + graphState.data.nodes.length > 0 ? ( + <> + {/* Info message and controls */} +
+ {graphState.isFilteredView ? ( +
+ +
+ + + Showing paths for:{" "} + + {graphState.filteredNode?.properties?.name || + graphState.filteredNode?.properties?.id || + "Selected node"} + + +
+
+ ) : ( +
+ + + Click on any node to filter and view its connected + paths + +
+ )} + + {/* Graph controls and fullscreen button together */} +
+ graphRef.current?.zoomIn()} + onZoomOut={() => graphRef.current?.zoomOut()} + onFitToScreen={() => graphRef.current?.resetZoom()} + onExport={() => + handleGraphExport( + graphRef.current?.getSVGElement() || null, + ) + } + /> + + {/* Fullscreen button */} +
+ + + + + + + + Graph Fullscreen View + + +
+ + fullscreenGraphRef.current?.zoomIn() + } + onZoomOut={() => + fullscreenGraphRef.current?.zoomOut() + } + onFitToScreen={() => + fullscreenGraphRef.current?.resetZoom() + } + onExport={() => + handleGraphExport( + fullscreenGraphRef.current?.getSVGElement() || + null, + ) + } + /> +
+
+
+ +
+ {/* Node Detail Panel - Side by side */} + {graphState.selectedNode && ( +
+ + +
+

+ Node Details +

+ +
+

+ {graphState.selectedNode?.labels.some( + (label) => + label + .toLowerCase() + .includes("finding"), + ) + ? graphState.selectedNode?.properties + ?.check_title || + graphState.selectedNode?.properties + ?.id || + "Unknown Finding" + : graphState.selectedNode?.properties + ?.name || + graphState.selectedNode?.properties + ?.id || + "Unknown Resource"} +

+
+
+

+ Type +

+

+ {graphState.selectedNode?.labels + .map(formatNodeLabel) + .join(", ")} +

+
+
+
+
+
+ )} +
+
+
+
+
+
+ + {/* Graph in the middle */} +
+ +
+ + {/* Legend below */} +
+ +
+ + ) : null} +
+ )} + + {/* Node Detail Panel - Below Graph */} + {graphState.selectedNode && graphState.data && ( +
+
+
+

Node Details

+

+ {String( + graphState.selectedNode.labels.some((label) => + label.toLowerCase().includes("finding"), + ) + ? graphState.selectedNode.properties?.check_title || + graphState.selectedNode.properties?.id || + "Unknown Finding" + : graphState.selectedNode.properties?.name || + graphState.selectedNode.properties?.id || + "Unknown Resource", + )} +

+
+
+ {graphState.selectedNode.labels.some((label) => + label.toLowerCase().includes("finding"), + ) && ( + + )} +
-
- {/* Graph in the middle */} -
-
- - {/* Legend below */} -
- -
- - ) : ( -
-

- Select a query and click "Execute Query" to visualize - the Attack Paths graph -

-
- )} -
- - {/* Node Detail Panel - Below Graph */} - {graphState.selectedNode && graphState.data && ( -
-
-
-

Node Details

-

- {String( - graphState.selectedNode.labels.some((label) => - label.toLowerCase().includes("finding"), - ) - ? graphState.selectedNode.properties?.check_title || - graphState.selectedNode.properties?.id || - "Unknown Finding" - : graphState.selectedNode.properties?.name || - graphState.selectedNode.properties?.id || - "Unknown Resource", - )} -

-
-
- {graphState.selectedNode.labels.some((label) => - label.toLowerCase().includes("finding"), - ) && ( - - )} - -
-
- - -
+ )} + )}
); diff --git a/ui/types/attack-paths.ts b/ui/types/attack-paths.ts index 33a02e1178..d0f35c96f7 100644 --- a/ui/types/attack-paths.ts +++ b/ui/types/attack-paths.ts @@ -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",