"use client"; import { ArrowLeft, Maximize2, X } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { FormProvider } from "react-hook-form"; import { executeQuery, getAttackPathScans, getAvailableQueries, } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, useToast, } from "@/components/ui"; import type { AttackPathQuery, AttackPathScan, GraphNode, } from "@/types/attack-paths"; import { AttackPathGraph, ExecuteButton, GraphControls, GraphLegend, GraphLoading, NodeDetailContent, QueryParametersForm, QuerySelector, ScanListTable, } from "./_components"; import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph"; import { useGraphState } from "./_hooks/use-graph-state"; import { useQueryBuilder } from "./_hooks/use-query-builder"; import { exportGraphAsSVG, formatNodeLabel } from "./_lib"; /** * Attack Paths Analysis * Allows users to select a scan, build a query, and visualize the Attack Paths graph */ export default function AttackPathAnalysisPage() { const searchParams = useSearchParams(); const scanId = searchParams.get("scanId"); const graphState = useGraphState(); const { toast } = useToast(); const [scansLoading, setScansLoading] = useState(true); const [scans, setScans] = useState([]); const [queriesLoading, setQueriesLoading] = useState(true); const [queriesError, setQueriesError] = useState(null); const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); const graphRef = useRef(null); const fullscreenGraphRef = useRef(null); const hasResetRef = useRef(false); const nodeDetailsRef = useRef(null); const graphContainerRef = useRef(null); const [queries, setQueries] = useState([]); // Use custom hook for query builder form state and validation const queryBuilder = useQueryBuilder(queries); // Reset graph state when component mounts useEffect(() => { if (!hasResetRef.current) { hasResetRef.current = true; graphState.resetGraph(); } }, [graphState]); // Load available scans on mount useEffect(() => { const loadScans = async () => { setScansLoading(true); try { const scansData = await getAttackPathScans(); if (scansData?.data) { setScans(scansData.data); } else { setScans([]); } } catch (error) { console.error("Failed to load scans:", error); setScans([]); } finally { setScansLoading(false); } }; loadScans(); }, []); // Check if there's an executing scan for auto-refresh const hasExecutingScan = scans.some( (scan) => scan.attributes.state === "executing" || scan.attributes.state === "scheduled", ); // Callback to refresh scans (used by AutoRefresh component) const refreshScans = useCallback(async () => { try { const scansData = await getAttackPathScans(); if (scansData?.data) { setScans(scansData.data); } } catch (error) { console.error("Failed to refresh scans:", error); } }, []); // Load available queries on mount useEffect(() => { const loadQueries = async () => { if (!scanId) { setQueriesError("No scan selected"); setQueriesLoading(false); return; } setQueriesLoading(true); try { const queriesData = await getAvailableQueries(scanId); if (queriesData?.data) { setQueries(queriesData.data); setQueriesError(null); } else { setQueriesError("Failed to load available queries"); toast({ title: "Error", description: "Failed to load queries for this scan", variant: "destructive", }); } } catch (error) { const errorMsg = error instanceof Error ? error.message : "Unknown error"; setQueriesError(errorMsg); toast({ title: "Error", description: "Failed to load queries", variant: "destructive", }); } finally { setQueriesLoading(false); } }; loadQueries(); }, [scanId, toast]); const handleQueryChange = (queryId: string) => { queryBuilder.handleQueryChange(queryId); }; const showErrorToast = (title: string, description: string) => { toast({ title, description, variant: "destructive", }); }; const handleExecuteQuery = async () => { if (!scanId || !queryBuilder.selectedQuery) { showErrorToast("Error", "Please select both a scan and a query"); return; } // Validate form before executing query const isValid = await queryBuilder.form.trigger(); if (!isValid) { showErrorToast( "Validation Error", "Please fill in all required parameters", ); return; } graphState.startLoading(); graphState.setError(null); try { const parameters = queryBuilder.getQueryParameters() as Record< string, string | number | boolean >; const result = await executeQuery( scanId, queryBuilder.selectedQuery, parameters, ); if (result && "error" in result) { const apiError = result as unknown as { error: string; status: number }; graphState.resetGraph(); if (apiError.status === 404) { graphState.setError("No data found"); showErrorToast("No data found", "The query returned no data"); } else if (apiError.status === 403) { graphState.setError("Not enough permissions to execute this query"); showErrorToast( "Error", "Not enough permissions to execute this query", ); } else { graphState.setError(apiError.error); showErrorToast("Error", apiError.error); } } else if (result?.data?.attributes) { const graphData = adaptQueryResultToGraphData(result.data.attributes); graphState.updateGraphData(graphData); toast({ title: "Success", description: "Query executed successfully", variant: "default", }); // Scroll to graph after successful query execution setTimeout(() => { graphContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "start", }); }, 100); } else { graphState.resetGraph(); graphState.setError("Failed to execute query due to an unknown error"); showErrorToast( "Error", "Failed to execute query due to an unknown error", ); } } catch (error) { const errorMsg = error instanceof Error ? error.message : "Failed to execute query"; graphState.resetGraph(); graphState.setError(errorMsg); showErrorToast("Error", errorMsg); } finally { graphState.stopLoading(); } }; const handleNodeClick = (node: GraphNode) => { // Enter filtered view showing only paths containing this node graphState.enterFilteredView(node.id); // For findings, also scroll to the details section const isFinding = node.labels.some((label) => label.toLowerCase().includes("finding"), ); if (isFinding) { setTimeout(() => { nodeDetailsRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", }); }, 100); } }; const handleBackToFullView = () => { graphState.exitFilteredView(); }; const handleCloseDetails = () => { graphState.selectNode(null); }; const handleGraphExport = (svgElement: SVGSVGElement | null) => { try { if (svgElement) { exportGraphAsSVG(svgElement, "attack-path-graph.svg"); toast({ title: "Success", description: "Graph exported as SVG", variant: "default", }); } else { throw new Error("Could not find graph element"); } } catch (error) { toast({ title: "Error", description: error instanceof Error ? error.message : "Failed to export graph", variant: "destructive", }); } }; return (
{/* Auto-refresh scans when there's an executing scan */} {/* Header */}

Attack Paths Analysis

Select a scan, build a query, and visualize Attack Paths in your infrastructure.

{/* 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 */}
{!scanId ? (

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

) : queriesLoading ? (

Loading queries...

) : queriesError ? (

{queriesError}

) : ( <> {queryBuilder.selectedQueryData && (

{queryBuilder.selectedQueryData.attributes.description}

{queryBuilder.selectedQueryData.attributes.attribution && (

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

)}
)} {queryBuilder.selectedQuery && ( )}
{graphState.error && (
{graphState.error}
)} )}
{/* 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(", ")}

)}
{/* 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"), ) && ( )}
)} ); }