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.
|
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
|
### 🔐 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 {
|
import {
|
||||||
AttackPathQueriesResponse,
|
AttackPathQueriesResponse,
|
||||||
AttackPathQuery,
|
AttackPathQuery,
|
||||||
|
AttackPathQueryError,
|
||||||
AttackPathQueryResult,
|
AttackPathQueryResult,
|
||||||
ExecuteQueryRequest,
|
ExecuteQueryRequest,
|
||||||
} from "@/types/attack-paths";
|
} from "@/types/attack-paths";
|
||||||
@@ -59,7 +60,7 @@ export const executeQuery = async (
|
|||||||
scanId: string,
|
scanId: string,
|
||||||
queryId: string,
|
queryId: string,
|
||||||
parameters?: Record<string, string | number | boolean>,
|
parameters?: Record<string, string | number | boolean>,
|
||||||
): Promise<AttackPathQueryResult | undefined> => {
|
): Promise<AttackPathQueryResult | AttackPathQueryError | undefined> => {
|
||||||
// Validate scanId is a valid UUID format to prevent request forgery
|
// Validate scanId is a valid UUID format to prevent request forgery
|
||||||
const validatedScanId = UUIDSchema.safeParse(scanId);
|
const validatedScanId = UUIDSchema.safeParse(scanId);
|
||||||
if (!validatedScanId.success) {
|
if (!validatedScanId.success) {
|
||||||
@@ -89,9 +90,15 @@ export const executeQuery = async (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return handleApiResponse(response);
|
return (await handleApiResponse(response)) as
|
||||||
|
| AttackPathQueryResult
|
||||||
|
| AttackPathQueryError;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error executing query on scan:", 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) => {
|
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) => {
|
const getSelectButtonLabel = (scan: AttackPathScan) => {
|
||||||
@@ -133,7 +137,7 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
|||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Progress</TableHead>
|
<TableHead>Progress</TableHead>
|
||||||
<TableHead>Duration</TableHead>
|
<TableHead>Duration</TableHead>
|
||||||
<TableHead className="text-right">Action</TableHead>
|
<TableHead className="text-right"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -344,10 +348,6 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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]: {
|
[SCAN_STATES.FAILED]: {
|
||||||
className: "bg-bg-fail-secondary text-text-error-primary",
|
className: "bg-bg-fail-secondary text-text-error-primary",
|
||||||
label: "Failed",
|
label: "Failed",
|
||||||
showGraphDot: true,
|
showGraphDot: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { useSearchParams } from "next/navigation";
|
||||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
@@ -12,7 +13,14 @@ import {
|
|||||||
} from "@/actions/attack-paths";
|
} from "@/actions/attack-paths";
|
||||||
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
||||||
import { AutoRefresh } from "@/components/scans";
|
import { AutoRefresh } from "@/components/scans";
|
||||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from "@/components/shadcn";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -23,6 +31,7 @@ import {
|
|||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import type {
|
import type {
|
||||||
AttackPathQuery,
|
AttackPathQuery,
|
||||||
|
AttackPathQueryError,
|
||||||
AttackPathScan,
|
AttackPathScan,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
} from "@/types/attack-paths";
|
} from "@/types/attack-paths";
|
||||||
@@ -201,7 +210,7 @@ export default function AttackPathAnalysisPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result && "error" in result) {
|
if (result && "error" in result) {
|
||||||
const apiError = result as unknown as { error: string; status: number };
|
const apiError = result as AttackPathQueryError;
|
||||||
graphState.resetGraph();
|
graphState.resetGraph();
|
||||||
|
|
||||||
if (apiError.status === 404) {
|
if (apiError.status === 404) {
|
||||||
@@ -213,6 +222,11 @@ export default function AttackPathAnalysisPage() {
|
|||||||
"Error",
|
"Error",
|
||||||
"Not enough permissions to execute this query",
|
"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 {
|
} else {
|
||||||
graphState.setError(apiError.error);
|
graphState.setError(apiError.error);
|
||||||
showErrorToast("Error", apiError.error);
|
showErrorToast("Error", apiError.error);
|
||||||
@@ -242,8 +256,11 @@ export default function AttackPathAnalysisPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg =
|
const rawErrorMsg =
|
||||||
error instanceof Error ? error.message : "Failed to execute query";
|
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.resetGraph();
|
||||||
graphState.setError(errorMsg);
|
graphState.setError(errorMsg);
|
||||||
showErrorToast("Error", errorMsg);
|
showErrorToast("Error", errorMsg);
|
||||||
@@ -318,355 +335,377 @@ export default function AttackPathAnalysisPage() {
|
|||||||
Select a scan, build a query, and visualize Attack Paths in your
|
Select a scan, build a query, and visualize Attack Paths in your
|
||||||
infrastructure.
|
infrastructure.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Top Section - Scans Table and Query Builder (2 columns) */}
|
{scansLoading ? (
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<Suspense fallback={<div>Loading scans...</div>}>
|
|
||||||
<ScanListTable scans={scans} />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Query Builder Section - Right Column */}
|
|
||||||
<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">
|
<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-sm">Loading scans...</p>
|
||||||
<p className="text-text-info dark:text-text-info text-sm">
|
</div>
|
||||||
Select a scan from the table on the left to begin.
|
) : scans.length === 0 ? (
|
||||||
</p>
|
<Alert variant="info">
|
||||||
) : queriesLoading ? (
|
<Info className="size-4" />
|
||||||
<p className="text-sm">Loading queries...</p>
|
<AlertTitle>No scans available</AlertTitle>
|
||||||
) : queriesError ? (
|
<AlertDescription>
|
||||||
<p className="text-text-danger dark:text-text-danger text-sm">
|
<span>
|
||||||
{queriesError}
|
You need to run a scan before you can analyze attack paths.{" "}
|
||||||
</p>
|
<Link href="/scans" className="font-medium underline">
|
||||||
) : (
|
Go to Scan Jobs
|
||||||
<>
|
</Link>
|
||||||
<FormProvider {...queryBuilder.form}>
|
</span>
|
||||||
<QuerySelector
|
</AlertDescription>
|
||||||
queries={queries}
|
</Alert>
|
||||||
selectedQueryId={queryBuilder.selectedQuery}
|
) : (
|
||||||
onQueryChange={handleQueryChange}
|
<>
|
||||||
/>
|
{/* Scans Table */}
|
||||||
|
<Suspense fallback={<div>Loading scans...</div>}>
|
||||||
|
<ScanListTable scans={scans} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{queryBuilder.selectedQueryData && (
|
{/* Query Builder Section - shown only after selecting a scan */}
|
||||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary dark:text-text-neutral-secondary rounded-md p-3 text-sm">
|
{scanId && (
|
||||||
<p className="whitespace-pre-line">
|
<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">
|
||||||
{queryBuilder.selectedQueryData.attributes.description}
|
{queriesLoading ? (
|
||||||
</p>
|
<p className="text-sm">Loading queries...</p>
|
||||||
{queryBuilder.selectedQueryData.attributes.attribution && (
|
) : queriesError ? (
|
||||||
<p className="mt-2 text-xs">
|
<p className="text-text-danger dark:text-text-danger text-sm">
|
||||||
Source:{" "}
|
{queriesError}
|
||||||
<a
|
</p>
|
||||||
href={
|
) : (
|
||||||
queryBuilder.selectedQueryData.attributes
|
<>
|
||||||
.attribution.link
|
<FormProvider {...queryBuilder.form}>
|
||||||
}
|
<QuerySelector
|
||||||
target="_blank"
|
queries={queries}
|
||||||
rel="noopener noreferrer"
|
selectedQueryId={queryBuilder.selectedQuery}
|
||||||
className="underline"
|
onQueryChange={handleQueryChange}
|
||||||
>
|
/>
|
||||||
|
|
||||||
|
{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
|
queryBuilder.selectedQueryData.attributes
|
||||||
.attribution.text
|
.description
|
||||||
}
|
}
|
||||||
</a>
|
</p>
|
||||||
</p>
|
{queryBuilder.selectedQueryData.attributes
|
||||||
)}
|
.attribution && (
|
||||||
</div>
|
<p className="mt-2 text-xs">
|
||||||
)}
|
Source:{" "}
|
||||||
|
<a
|
||||||
{queryBuilder.selectedQuery && (
|
href={
|
||||||
<QueryParametersForm
|
queryBuilder.selectedQueryData.attributes
|
||||||
selectedQuery={queryBuilder.selectedQueryData}
|
.attribution.link
|
||||||
/>
|
}
|
||||||
)}
|
target="_blank"
|
||||||
</FormProvider>
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
<div className="flex gap-3">
|
>
|
||||||
<ExecuteButton
|
{
|
||||||
isLoading={graphState.loading}
|
queryBuilder.selectedQueryData.attributes
|
||||||
isDisabled={!queryBuilder.selectedQuery}
|
.attribution.text
|
||||||
onExecute={handleExecuteQuery}
|
}
|
||||||
/>
|
</a>
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
{graphState.error && (
|
|
||||||
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
|
|
||||||
{graphState.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Section - Graph Visualization (Full Width) */}
|
|
||||||
<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 />
|
|
||||||
) : graphState.data &&
|
|
||||||
graphState.data.nodes &&
|
|
||||||
graphState.data.nodes.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{/* Info message and controls */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
{graphState.isFilteredView ? (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
onClick={handleBackToFullView}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
aria-label="Return to full graph view"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Back to Full View
|
|
||||||
</Button>
|
|
||||||
<div
|
|
||||||
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="Filtered view active"
|
|
||||||
>
|
|
||||||
<span className="flex-shrink-0" aria-hidden="true">
|
|
||||||
🔍
|
|
||||||
</span>
|
|
||||||
<span className="flex-1">
|
|
||||||
Showing paths for:{" "}
|
|
||||||
<strong>
|
|
||||||
{graphState.filteredNode?.properties?.name ||
|
|
||||||
graphState.filteredNode?.properties?.id ||
|
|
||||||
"Selected node"}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</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"
|
|
||||||
role="status"
|
|
||||||
aria-label="Graph interaction instructions"
|
|
||||||
>
|
|
||||||
<span className="flex-shrink-0" aria-hidden="true">
|
|
||||||
💡
|
|
||||||
</span>
|
|
||||||
<span className="flex-1">
|
|
||||||
Click on any node to filter and view its connected paths
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Graph controls and fullscreen button together */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<GraphControls
|
|
||||||
onZoomIn={() => graphRef.current?.zoomIn()}
|
|
||||||
onZoomOut={() => graphRef.current?.zoomOut()}
|
|
||||||
onFitToScreen={() => graphRef.current?.resetZoom()}
|
|
||||||
onExport={() =>
|
|
||||||
handleGraphExport(graphRef.current?.getSVGElement() || null)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Fullscreen button */}
|
|
||||||
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
|
|
||||||
<Dialog
|
|
||||||
open={isFullscreenOpen}
|
|
||||||
onOpenChange={setIsFullscreenOpen}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
aria-label="Fullscreen"
|
|
||||||
>
|
|
||||||
<Maximize2 size={18} />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 p-0">
|
|
||||||
<DialogHeader className="px-4 pt-4 sm:px-6 sm:pt-6">
|
|
||||||
<DialogTitle className="text-lg">
|
|
||||||
Graph Fullscreen View
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
|
|
||||||
<GraphControls
|
|
||||||
onZoomIn={() => fullscreenGraphRef.current?.zoomIn()}
|
|
||||||
onZoomOut={() =>
|
|
||||||
fullscreenGraphRef.current?.zoomOut()
|
|
||||||
}
|
|
||||||
onFitToScreen={() =>
|
|
||||||
fullscreenGraphRef.current?.resetZoom()
|
|
||||||
}
|
|
||||||
onExport={() =>
|
|
||||||
handleGraphExport(
|
|
||||||
fullscreenGraphRef.current?.getSVGElement() ||
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<AttackPathGraph
|
|
||||||
ref={fullscreenGraphRef}
|
|
||||||
data={graphState.data}
|
|
||||||
onNodeClick={handleNodeClick}
|
|
||||||
selectedNodeId={graphState.selectedNodeId}
|
|
||||||
isFilteredView={graphState.isFilteredView}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Node Detail Panel - Side by side */}
|
|
||||||
{graphState.selectedNode && (
|
|
||||||
<section aria-labelledby="node-details-heading">
|
|
||||||
<Card className="w-96 overflow-y-auto">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<h3
|
|
||||||
id="node-details-heading"
|
|
||||||
className="text-sm font-semibold"
|
|
||||||
>
|
|
||||||
Node Details
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
onClick={handleCloseDetails}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
aria-label="Close node details"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
|
|
||||||
{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"}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-xs font-semibold">
|
|
||||||
Type
|
|
||||||
</h4>
|
|
||||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
|
|
||||||
{graphState.selectedNode?.labels
|
|
||||||
.map(formatNodeLabel)
|
|
||||||
.join(", ")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
)}
|
||||||
</Dialog>
|
|
||||||
|
{queryBuilder.selectedQuery && (
|
||||||
|
<QueryParametersForm
|
||||||
|
selectedQuery={queryBuilder.selectedQueryData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormProvider>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<ExecuteButton
|
||||||
|
isLoading={graphState.loading}
|
||||||
|
isDisabled={!queryBuilder.selectedQuery}
|
||||||
|
onExecute={handleExecuteQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{graphState.error && (
|
||||||
|
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
|
||||||
|
{graphState.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 />
|
||||||
|
) : graphState.data &&
|
||||||
|
graphState.data.nodes &&
|
||||||
|
graphState.data.nodes.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Info message and controls */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
{graphState.isFilteredView ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleBackToFullView}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
aria-label="Return to full graph view"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back to Full View
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
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="Filtered view active"
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0" aria-hidden="true">
|
||||||
|
🔍
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
Showing paths for:{" "}
|
||||||
|
<strong>
|
||||||
|
{graphState.filteredNode?.properties?.name ||
|
||||||
|
graphState.filteredNode?.properties?.id ||
|
||||||
|
"Selected node"}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0" aria-hidden="true">
|
||||||
|
💡
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
Click on any node to filter and view its connected
|
||||||
|
paths
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Graph controls and fullscreen button together */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GraphControls
|
||||||
|
onZoomIn={() => graphRef.current?.zoomIn()}
|
||||||
|
onZoomOut={() => graphRef.current?.zoomOut()}
|
||||||
|
onFitToScreen={() => graphRef.current?.resetZoom()}
|
||||||
|
onExport={() =>
|
||||||
|
handleGraphExport(
|
||||||
|
graphRef.current?.getSVGElement() || null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fullscreen button */}
|
||||||
|
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
|
||||||
|
<Dialog
|
||||||
|
open={isFullscreenOpen}
|
||||||
|
onOpenChange={setIsFullscreenOpen}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
aria-label="Fullscreen"
|
||||||
|
>
|
||||||
|
<Maximize2 size={18} />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 p-0">
|
||||||
|
<DialogHeader className="px-4 pt-4 sm:px-6 sm:pt-6">
|
||||||
|
<DialogTitle className="text-lg">
|
||||||
|
Graph Fullscreen View
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
|
||||||
|
<GraphControls
|
||||||
|
onZoomIn={() =>
|
||||||
|
fullscreenGraphRef.current?.zoomIn()
|
||||||
|
}
|
||||||
|
onZoomOut={() =>
|
||||||
|
fullscreenGraphRef.current?.zoomOut()
|
||||||
|
}
|
||||||
|
onFitToScreen={() =>
|
||||||
|
fullscreenGraphRef.current?.resetZoom()
|
||||||
|
}
|
||||||
|
onExport={() =>
|
||||||
|
handleGraphExport(
|
||||||
|
fullscreenGraphRef.current?.getSVGElement() ||
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<AttackPathGraph
|
||||||
|
ref={fullscreenGraphRef}
|
||||||
|
data={graphState.data}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
selectedNodeId={graphState.selectedNodeId}
|
||||||
|
isFilteredView={graphState.isFilteredView}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Node Detail Panel - Side by side */}
|
||||||
|
{graphState.selectedNode && (
|
||||||
|
<section aria-labelledby="node-details-heading">
|
||||||
|
<Card className="w-96 overflow-y-auto">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3
|
||||||
|
id="node-details-heading"
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
>
|
||||||
|
Node Details
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
onClick={handleCloseDetails}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
aria-label="Close node details"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
|
||||||
|
{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"}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-xs font-semibold">
|
||||||
|
Type
|
||||||
|
</h4>
|
||||||
|
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
|
||||||
|
{graphState.selectedNode?.labels
|
||||||
|
.map(formatNodeLabel)
|
||||||
|
.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graph in the middle */}
|
||||||
|
<div
|
||||||
|
ref={graphContainerRef}
|
||||||
|
className="h-[calc(100vh-22rem)]"
|
||||||
|
>
|
||||||
|
<AttackPathGraph
|
||||||
|
ref={graphRef}
|
||||||
|
data={graphState.data}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
selectedNodeId={graphState.selectedNodeId}
|
||||||
|
isFilteredView={graphState.isFilteredView}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend below */}
|
||||||
|
<div className="hidden justify-center lg:flex">
|
||||||
|
<GraphLegend data={graphState.data} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node Detail Panel - Below Graph */}
|
||||||
|
{graphState.selectedNode && graphState.data && (
|
||||||
|
<div
|
||||||
|
ref={nodeDetailsRef}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold">Node Details</h3>
|
||||||
|
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-sm">
|
||||||
|
{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",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{graphState.selectedNode.labels.some((label) =>
|
||||||
|
label.toLowerCase().includes("finding"),
|
||||||
|
) && (
|
||||||
|
<Button asChild variant="default" size="sm">
|
||||||
|
<a
|
||||||
|
href={`/findings?id=${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`View finding ${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
|
||||||
|
>
|
||||||
|
View Finding →
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleCloseDetails}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
aria-label="Close node details"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Graph in the middle */}
|
<NodeDetailContent
|
||||||
<div ref={graphContainerRef} className="h-[calc(100vh-22rem)]">
|
node={graphState.selectedNode}
|
||||||
<AttackPathGraph
|
allNodes={graphState.data.nodes}
|
||||||
ref={graphRef}
|
|
||||||
data={graphState.data}
|
|
||||||
onNodeClick={handleNodeClick}
|
|
||||||
selectedNodeId={graphState.selectedNodeId}
|
|
||||||
isFilteredView={graphState.isFilteredView}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Legend below */}
|
</>
|
||||||
<div className="hidden justify-center lg:flex">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Node Detail Panel - Below Graph */}
|
|
||||||
{graphState.selectedNode && graphState.data && (
|
|
||||||
<div
|
|
||||||
ref={nodeDetailsRef}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-semibold">Node Details</h3>
|
|
||||||
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-sm">
|
|
||||||
{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",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{graphState.selectedNode.labels.some((label) =>
|
|
||||||
label.toLowerCase().includes("finding"),
|
|
||||||
) && (
|
|
||||||
<Button asChild variant="default" size="sm">
|
|
||||||
<a
|
|
||||||
href={`/findings?id=${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={`View finding ${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
|
|
||||||
>
|
|
||||||
View Finding →
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={handleCloseDetails}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
aria-label="Close node details"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NodeDetailContent
|
|
||||||
node={graphState.selectedNode}
|
|
||||||
allNodes={graphState.data.nodes}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -175,6 +175,11 @@ export interface AttackPathQueryResult {
|
|||||||
data: QueryResultData;
|
data: QueryResultData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttackPathQueryError {
|
||||||
|
error: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Finding severity and status constants
|
// Finding severity and status constants
|
||||||
export const FINDING_SEVERITIES = {
|
export const FINDING_SEVERITIES = {
|
||||||
CRITICAL: "critical",
|
CRITICAL: "critical",
|
||||||
|
|||||||
Reference in New Issue
Block a user