Files
prowler/ui/components/attack-paths/graph/attack-path-graph.tsx
T

682 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string>;
// Which resource-class groups are expanded to their members.
expandedClasses?: Set<string>;
// 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<GraphHandle>;
className?: string;
}
const EMPTY_EXPANDED: ReadonlySet<string> = 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 = () => (
<svg width={0} height={0} className="absolute" aria-hidden="true">
<defs>
{/* Glow filter for finding nodes */}
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Red glow for resources with findings */}
<filter id="redGlow">
<feDropShadow
dx="0"
dy="0"
stdDeviation="4"
floodColor={GRAPH_FINDING_GLOW_COLOR}
floodOpacity="0.6"
/>
</filter>
{/* Prowler green glow for selected nodes */}
<filter id="selectedGlow">
<feDropShadow
dx="0"
dy="0"
stdDeviation="6"
floodColor={GRAPH_SELECTED_GLOW_COLOR}
floodOpacity="0.8"
/>
</filter>
</defs>
</svg>
);
// --- Inner component: calls useReactFlow(), owns layout derivation ---
type GraphCanvasProps = Omit<AttackPathGraphProps, "className">;
// 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<HTMLDivElement>(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<string | null>(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<ReadonlySet<string>>(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<Map<string, Set<string>>>(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<string>();
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<string>(newResourceIds);
const newFindingIds = new Set<string>();
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<string>();
const resourceToFindings = new Map<string, Set<string>>();
const findingToResources = new Map<string, Set<string>>();
nodes.forEach((n) => {
if (isProwlerFindingNode(n.labels)) findingNodeIds.add(n.id);
});
const resourcesWithFindings = new Set<string>();
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<string>();
// 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 (
<div ref={containerRef} className="h-full w-full">
<ReactFlow
nodes={enrichedNodes}
edges={enrichedEdges}
nodeTypes={NODE_TYPES}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeMouseEnter={handleNodeMouseEnter}
onNodeMouseLeave={handleNodeMouseLeave}
fitView
fitViewOptions={{ ...AUTO_FIT_OPTIONS, includeHiddenNodes: true }}
// Supported React Flow behavior: wheel over the graph zooms the
// viewport. The surrounding UX avoids using node details as inline
// content, so this no longer fights a below-graph details section.
zoomOnScroll={true}
zoomOnPinch={true}
zoomOnDoubleClick={false}
panOnScroll={false}
preventScrolling={true}
minZoom={0.1}
maxZoom={10}
proOptions={{ hideAttribution: true }}
>
<Background />
<MiniMap
pannable
zoomable
ariaLabel="Graph minimap"
bgColor={minimapColors.bg}
maskColor={minimapColors.mask}
maskStrokeColor={minimapColors.maskStroke}
maskStrokeWidth={MINIMAP_VIEWPORT_STROKE_WIDTH}
nodeColor={(node) => {
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);
}}
/>
</ReactFlow>
</div>
);
};
// --- Outer component: renders ReactFlowProvider ---
export const AttackPathGraph = ({
data,
selectedNodeId,
isFilteredView,
initialNodeId,
expandedResources,
expandedClasses,
outcome,
view,
onNodeClick,
onNodeDoubleClick,
onInitialFilter,
ref,
className,
}: AttackPathGraphProps) => {
return (
<div
role="img"
aria-label="Attack path graph"
className={cn(
"dark:bg-bg-neutral-secondary bg-bg-neutral-secondary h-full w-full rounded-lg",
className,
)}
>
<style>{GRAPH_STYLES}</style>
<GraphDefs />
<ReactFlowProvider>
<GraphCanvas
ref={ref}
data={data}
selectedNodeId={selectedNodeId}
isFilteredView={isFilteredView}
initialNodeId={initialNodeId}
expandedResources={expandedResources}
expandedClasses={expandedClasses}
outcome={outcome}
view={view}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onInitialFilter={onInitialFilter}
/>
</ReactFlowProvider>
</div>
);
};