"use client"; import "@xyflow/react/dist/style.css"; import { Background, MiniMap, type Node, ReactFlow, ReactFlowProvider, type Rect, useReactFlow, } from "@xyflow/react"; import { useTheme } from "next-themes"; import { type MouseEvent, type Ref, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from "react"; import { FindingNode } from "@/components/attack-paths/graph/nodes/finding-node"; import { GroupNode } from "@/components/attack-paths/graph/nodes/group-node"; import { InternetNode } from "@/components/attack-paths/graph/nodes/internet-node"; import { OutcomeNode } from "@/components/attack-paths/graph/nodes/outcome-node"; import { ResourceNode } from "@/components/attack-paths/graph/nodes/resource-node"; import { computeFilteredSubgraph, getNodeBorderColor, getNodeColor, getPathEdges, GRAPH_EDGE_HIGHLIGHT_COLOR, isProwlerFindingNode, resolveHiddenFindingIds, } from "@/lib/attack-paths"; import { type AttackPathView, buildAttackPathView, } from "@/lib/attack-paths/group-graph"; import { layoutWithDagre, NODE_TYPE } from "@/lib/attack-paths/layout"; import { isCloud } from "@/lib/shared/env"; import { cn } from "@/lib/utils"; import type { AttackPathGraphData, AttackPathOutcome, GraphNode, } from "@/types/attack-paths"; // --- Types --- export interface GraphHandle { zoomIn: () => void; zoomOut: () => void; resetZoom: () => void; getZoomLevel: () => number; getContainerElement: () => HTMLDivElement | null; getNodesBounds: () => Rect | null; } interface AttackPathGraphProps { data: AttackPathGraphData; selectedNodeId?: string | null; isFilteredView?: boolean; initialNodeId?: string; expandedResources?: Set; // Which resource-class groups are expanded to their members. expandedClasses?: Set; // Terminal outcome for the current query; injected as the graph's end node. outcome?: AttackPathOutcome | null; // Pre-built attack-path view shared with the PNG export so both render the // same grouped/outcome graph. Falls back to an internal build when omitted. view?: AttackPathView | null; onNodeClick?: (node: GraphNode) => void; onNodeDoubleClick?: (node: GraphNode) => void; onInitialFilter?: (filteredData: AttackPathGraphData) => void; ref?: Ref; className?: string; } const EMPTY_EXPANDED: ReadonlySet = new Set(); // --- Node type registry (stable reference) --- const NODE_TYPES = { [NODE_TYPE.FINDING]: FindingNode, [NODE_TYPE.INTERNET]: InternetNode, [NODE_TYPE.RESOURCE]: ResourceNode, [NODE_TYPE.GROUP]: GroupNode, [NODE_TYPE.OUTCOME]: OutcomeNode, } as const; // --- CSS for animated dashed edges, selected node pulse, and edge highlight --- const GRAPH_STYLES = ` @keyframes dash { to { stroke-dashoffset: -20; } } .react-flow .finding-edge .react-flow__edge-path { stroke-dasharray: 8 6; animation: dash 1s linear infinite; } @keyframes selectedPulse { 0%, 100% { stroke-opacity: 1; } 50% { stroke-opacity: 0.6; } } .selected-node { animation: selectedPulse 1.2s ease-in-out infinite; } .react-flow .highlighted .react-flow__edge-path { stroke: ${GRAPH_EDGE_HIGHLIGHT_COLOR}; stroke-width: 3; filter: drop-shadow(0 0 4px ${GRAPH_EDGE_HIGHLIGHT_COLOR}); } `; // --- SVG filter color constants --- const GRAPH_FINDING_GLOW_COLOR = "#ef4444"; const GRAPH_SELECTED_GLOW_COLOR = GRAPH_EDGE_HIGHLIGHT_COLOR; // --- SVG filter defs (shared by all node components) --- const GraphDefs = () => ( ); // --- Inner component: calls useReactFlow(), owns layout derivation --- type GraphCanvasProps = Omit; // Mask covers the area NOT currently in view; the cut-out rectangle is the // viewport. To make the viewport rectangle stand out we darken the mask and // give its border high contrast against the minimap background. const MINIMAP_COLORS = { light: { bg: "#f8fafc", mask: "rgba(15, 23, 42, 0.45)", maskStroke: "#0f172a", }, dark: { bg: "#0f172a", mask: "rgba(0, 0, 0, 0.7)", maskStroke: "#cbd5e1", }, } as const; const MINIMAP_VIEWPORT_STROKE_WIDTH = 3; // Animated re-fit shared by every auto-fit trigger. We deliberately do not // cap maxZoom — for small subgraphs (e.g. a finding's filtered view with 3 // nodes) the user expects the result to fill the canvas; an artificial cap // looks like a layout error. // // Padding is asymmetric: the minimap sits in the bottom-right corner of the // canvas (default 200×150 panel + offset), so a fit with uniform padding // can drop nodes underneath it. Generous bottom/right padding keeps the // fitted graph clear of the minimap. const AUTO_FIT_OPTIONS = { padding: { top: "32px", left: "32px", right: "240px", bottom: "180px" }, duration: 300, } as const; const MEASURED_FIT_MAX_ATTEMPTS = 30; const scheduleMeasuredFit = ( isMeasured: () => boolean, onMeasured: () => void, ) => { let frame = 0; let attempts = 0; const tryFit = () => { if (isMeasured() || attempts >= MEASURED_FIT_MAX_ATTEMPTS) { onMeasured(); return; } attempts += 1; frame = requestAnimationFrame(tryFit); }; frame = requestAnimationFrame(tryFit); return () => cancelAnimationFrame(frame); }; const GraphCanvas = ({ data, selectedNodeId, isFilteredView = false, initialNodeId, expandedResources, expandedClasses, outcome, view, onNodeClick, onNodeDoubleClick, onInitialFilter, ref, }: GraphCanvasProps) => { const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } = useReactFlow(); const { resolvedTheme } = useTheme(); const containerRef = useRef(null); const hasInitialized = useRef(false); const minimapColors = resolvedTheme === "dark" ? MINIMAP_COLORS.dark : MINIMAP_COLORS.light; // Path highlight state — local to the canvas, must reset on data swaps // (otherwise we'd keep highlighting an edge that no longer exists). const [hoveredNodeId, setHoveredNodeId] = useState(null); useEffect(() => { setHoveredNodeId(null); }, [data]); // Tier 1 expansion is controlled by the parent (zustand store) so it // survives the data-swaps that happen on filtered-view enter/exit. const expanded = expandedResources ?? EMPTY_EXPANDED; // Re-frame when a class group is expanded/collapsed so the new member set // fits smoothly instead of overflowing the viewport. Newly revealed members // are measured asynchronously, so poll until they have real dimensions rather // than fitting on a single frame where measured.width can still be 0. useEffect(() => { return scheduleMeasuredFit( () => { const visibleNodes = getNodes().filter((n) => !n.hidden); return ( visibleNodes.length > 0 && visibleNodes.every((n) => (n.measured?.width ?? 0) > 0) ); }, () => fitView(AUTO_FIT_OPTIONS), ); }, [expandedClasses, fitView, getNodes]); // --- initialNodeId: synchronous filtered-view derivation on first render --- // Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph let effectiveData = data; if ( initialNodeId && !hasInitialized.current && data.nodes.some((n) => n.id === initialNodeId) ) { effectiveData = computeFilteredSubgraph(data, initialNodeId); } // Sync store flags via useLayoutEffect (runs before paint) useLayoutEffect(() => { if (hasInitialized.current) return; hasInitialized.current = true; if ( initialNodeId && data.nodes.some((n) => n.id === initialNodeId) && onInitialFilter ) { onInitialFilter(effectiveData); } }, []); // eslint-disable-line react-hooks/exhaustive-deps -- one-time init // --- Auto-fit triggers --- // // Filter toggles (finding click → filtered view, "Back to Full View" → // full graph) swap the data prop, which leaves the previous viewport // pointing at coordinates that no longer contain the new layout. Re-fit // once React Flow has applied the new layout (next animation frame). // // Resource expansion re-fits after newly revealed finding nodes have been // measured. This restores the original Show findings behavior: expanding a // resource should frame the selected resource plus its findings, not the // entire graph. Collapsing re-fits the remaining visible graph so the user // does not stay zoomed into empty space after hiding finding children. const filteredFitInitRef = useRef(false); const previousFilteredRef = useRef(isFilteredView); const previousExpandedRef = useRef>(expanded); // Captured every render so the expand-fit effect can resolve a resource // ID to its connected finding IDs without recomputing the lookup. // The map itself is built later in this render — see assignment below // the layout-derivation block. const resourceToFindingsRef = useRef>>(new Map()); useEffect(() => { if (!filteredFitInitRef.current) { filteredFitInitRef.current = true; previousFilteredRef.current = isFilteredView; return; } if (previousFilteredRef.current === isFilteredView) return; const wasFilteredView = previousFilteredRef.current; previousFilteredRef.current = isFilteredView; // React Flow measures node sizes asynchronously via ResizeObserver after // the data swap. A single rAF runs while measured.width is still 0, so // fitView computes a degenerate bbox and the viewport keeps the user's // previous zoom — most visibly when leaving a filtered view in which the // user had zoomed in. Poll until visible nodes are measured (or give up // after ~500ms so we never block on a stuck observer). return scheduleMeasuredFit( () => { const visibleNodes = getNodes().filter((n) => !n.hidden); return ( visibleNodes.length > 0 && visibleNodes.every((n) => (n.measured?.width ?? 0) > 0) ); }, () => { if (wasFilteredView && !isFilteredView && expanded.size > 0) { const contextualNodeIds = new Set(); for (const resourceId of Array.from(expanded)) { contextualNodeIds.add(resourceId); resourceToFindingsRef.current .get(resourceId) ?.forEach((findingId) => contextualNodeIds.add(findingId)); } const contextualNodes = getNodes().filter( (node) => !node.hidden && contextualNodeIds.has(node.id), ); if (contextualNodes.length > 0) { fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes }); return; } } fitView(AUTO_FIT_OPTIONS); }, ); }, [expanded, isFilteredView, fitView, getNodes]); useEffect(() => { const previous = previousExpandedRef.current; previousExpandedRef.current = expanded; if (previous === expanded) return; const newResourceIds = Array.from(expanded).filter( (id) => !previous.has(id), ); const collapsedResourceIds = Array.from(previous).filter( (id) => !expanded.has(id), ); if (newResourceIds.length === 0) { if (collapsedResourceIds.length === 0) return; return scheduleMeasuredFit( () => { const visibleNodes = getNodes().filter((n) => !n.hidden); return ( visibleNodes.length > 0 && visibleNodes.every((n) => (n.measured?.width ?? 0) > 0) ); }, () => fitView(AUTO_FIT_OPTIONS), ); } const contextualFitNodeIds = new Set(newResourceIds); const newFindingIds = new Set(); for (const resourceId of newResourceIds) { const findings = resourceToFindingsRef.current.get(resourceId); if (!findings) continue; findings.forEach((id) => { newFindingIds.add(id); contextualFitNodeIds.add(id); }); } if (newFindingIds.size === 0) return; // Findings transition from hidden to visible on expand, and React Flow // measures them asynchronously. Poll before fitting so fitView uses real // dimensions instead of the zero-size hidden-node measurements. return scheduleMeasuredFit( () => { const targets = getNodes().filter((n) => newFindingIds.has(n.id)); return ( targets.length === newFindingIds.size && targets.every((n) => (n.measured?.width ?? 0) > 0) ); }, () => { const contextualNodes = getNodes().filter((n) => contextualFitNodeIds.has(n.id), ); fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes }); }, ); }, [expanded, fitView, getNodes]); // Cloud reshapes into the attack-path view: drop the account hub, collapse // each resource class into one expandable node, and inject the outcome node. // OSS keeps the original flat graph, so the grouped/outcome view is Cloud-only. // Findings pass through either way; the finding-hide logic below reveals them // per-resource, preserving existing behaviour. // // The parent builds and shares this view with the PNG export so both stay in // sync; only fall back to an internal build when it is not provided. const resolvedView = view ?? (isCloud() ? buildAttackPathView({ data: effectiveData, expandedClasses: expandedClasses ?? EMPTY_EXPANDED, outcome, }) : { nodes: effectiveData.nodes ?? [], edges: effectiveData.edges ?? [] }); const nodes = resolvedView.nodes; const edges = resolvedView.edges; // Pre-compute which resources have findings connected (O(n+e)) const findingNodeIds = new Set(); const resourceToFindings = new Map>(); const findingToResources = new Map>(); nodes.forEach((n) => { if (isProwlerFindingNode(n.labels)) findingNodeIds.add(n.id); }); const resourcesWithFindings = new Set(); edges.forEach((edge) => { const sourceIsFinding = findingNodeIds.has(edge.source); const targetIsFinding = findingNodeIds.has(edge.target); if (sourceIsFinding) { resourcesWithFindings.add(edge.target); // Map resource → its findings const findings = resourceToFindings.get(edge.target) ?? new Set(); findings.add(edge.source); resourceToFindings.set(edge.target, findings); // Map finding → its resources const resources = findingToResources.get(edge.source) ?? new Set(); resources.add(edge.target); findingToResources.set(edge.source, resources); } if (targetIsFinding) { resourcesWithFindings.add(edge.source); const findings = resourceToFindings.get(edge.source) ?? new Set(); findings.add(edge.target); resourceToFindings.set(edge.source, findings); const resources = findingToResources.get(edge.target) ?? new Set(); resources.add(edge.source); findingToResources.set(edge.target, resources); } }); // Refresh the expand-fit effect's lookup with the latest mapping. resourceToFindingsRef.current = resourceToFindings; // Tier 1: compute which finding nodes are hidden (not expanded) const hiddenFindingIds = resolveHiddenFindingIds({ expandedResources: expanded, findingNodeIds, findingToResources, isFilteredView, }); const layoutNodeIds = new Set( nodes .filter((node) => !hiddenFindingIds.has(node.id)) .map((node) => node.id), ); const layoutNodes = nodes.filter((node) => layoutNodeIds.has(node.id)); const layoutEdges = edges.filter( (edge) => layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target), ); // Derive RF nodes and edges from visible data only. Hidden finding nodes are // excluded before Dagre runs so the initial tier-1 graph does not reserve // empty space for findings that the user has not expanded yet. const { rfNodes, rfEdges } = layoutWithDagre(layoutNodes, layoutEdges); // Path highlight: compute highlighted edge IDs const activeHighlightNodeId = hoveredNodeId ?? selectedNodeId; const highlightedEdgeIds = activeHighlightNodeId ? getPathEdges( activeHighlightNodeId, rfEdges.map((e) => ({ sourceId: e.source, targetId: e.target })), ) : new Set(); // Enrich nodes with selection, hasFindings, and hidden state const enrichedNodes = rfNodes.map((node) => ({ ...node, selected: node.id === selectedNodeId, hidden: hiddenFindingIds.has(node.id), className: cn( node.className, isProwlerFindingNode(node.data.graphNode.labels) || resourcesWithFindings.has(node.id) ? "cursor-pointer" : "cursor-default", ), data: { ...node.data, hasFindings: resourcesWithFindings.has(node.id), }, })); // Enrich edges with hidden state (hide edges to hidden findings) and highlight const enrichedEdges = rfEdges.map((edge) => { const sourceHidden = hiddenFindingIds.has(edge.source); const targetHidden = hiddenFindingIds.has(edge.target); const pathKey = typeof edge.data === "object" && edge.data && "pathKey" in edge.data ? String((edge.data as { pathKey?: string }).pathKey) : `${edge.source}-${edge.target}`; const isHighlighted = highlightedEdgeIds.has(edge.id) || highlightedEdgeIds.has(pathKey); return { ...edge, hidden: sourceHidden || targetHidden, className: cn(edge.className, isHighlighted && "highlighted"), }; }); useImperativeHandle(ref, () => ({ zoomIn: () => zoomIn({ duration: 300 }), zoomOut: () => zoomOut({ duration: 300 }), resetZoom: () => fitView({ duration: 300 }), getZoomLevel: () => getZoom(), getContainerElement: () => containerRef.current, getNodesBounds: () => { const rfNodes = getNodes(); if (rfNodes.length === 0) return null; return getNodesBounds(rfNodes); }, })); const handleNodeClick = (_event: MouseEvent, node: Node) => { const graphNode = (node.data as { graphNode: GraphNode }).graphNode; // Always fire parent callback (handles selection + Tier 2 filtered view) onNodeClick?.(graphNode); }; const handleNodeDoubleClick = (_event: MouseEvent, node: Node) => { const graphNode = (node.data as { graphNode: GraphNode }).graphNode; onNodeDoubleClick?.(graphNode); }; // Path highlight on hover const handleNodeMouseEnter = (_event: MouseEvent, node: Node) => { setHoveredNodeId(node.id); }; const handleNodeMouseLeave = () => { setHoveredNodeId(null); }; return (
{ const graphNode = (node.data as { graphNode?: GraphNode }) .graphNode; if (!graphNode) return MINIMAP_COLORS.light.maskStroke; return getNodeColor(graphNode.labels, graphNode.properties); }} nodeStrokeColor={(node) => { const graphNode = (node.data as { graphNode?: GraphNode }) .graphNode; if (!graphNode) return "transparent"; return getNodeBorderColor(graphNode.labels, graphNode.properties); }} />
); }; // --- Outer component: renders ReactFlowProvider --- export const AttackPathGraph = ({ data, selectedNodeId, isFilteredView, initialNodeId, expandedResources, expandedClasses, outcome, view, onNodeClick, onNodeDoubleClick, onInitialFilter, ref, className, }: AttackPathGraphProps) => { return (
); };