From e4ef4bfd4d9d31a927458dc10b2d815f9aff1c51 Mon Sep 17 00:00:00 2001 From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:32:24 +0100 Subject: [PATCH] feat(attack-paths): add filtered view for graph nodes (#9784) --- .../_components/graph/attack-path-graph.tsx | 152 ++++++++++++------ .../query-builder/_hooks/use-graph-state.ts | 68 +++++++- .../query-builder/_lib/graph-utils.ts | 147 ++++++++++++++++- .../(workflow)/query-builder/_lib/index.ts | 7 +- .../(workflow)/query-builder/page.tsx | 70 ++++++-- 5 files changed, 364 insertions(+), 80 deletions(-) diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx index 680fab22a2..b7632d090e 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx @@ -37,6 +37,7 @@ interface AttackPathGraphProps { data: AttackPathGraphData; onNodeClick?: (node: GraphNode) => void; selectedNodeId?: string | null; + isFilteredView?: boolean; ref?: Ref; } @@ -59,7 +60,7 @@ const HEXAGON_HEIGHT = 55; // Height for finding hexagons const AttackPathGraphComponent = forwardRef< AttackPathGraphRef, AttackPathGraphProps ->(({ data, onNodeClick, selectedNodeId }, ref) => { +>(({ data, onNodeClick, selectedNodeId, isFilteredView = false }, ref) => { const svgRef = useRef(null); const [zoomLevel, setZoomLevel] = useState(1); const zoomBehaviorRef = useRef | null>( @@ -124,12 +125,26 @@ const AttackPathGraphComponent = forwardRef< label.toLowerCase().includes("finding"), ); const hasFindings = resourcesWithFindingsRef.current.has(d.id); + const isSelected = d.id === selectedNodeId; - // Resources with findings keep their wider stroke - if (!isFinding && hasFindings) { - return 2.5; - } - return d.id === selectedNodeId ? 3 : isFinding ? 2 : 1.5; + if (isSelected) return 4; + if (!isFinding && hasFindings) return 2.5; + return isFinding ? 2 : 1.5; + }) + .attr("filter", (d: NodeData) => { + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const hasFindings = resourcesWithFindingsRef.current.has(d.id); + const isSelected = d.id === selectedNodeId; + + if (isSelected) return "url(#selectedGlow)"; + if (!isFinding && hasFindings) return "url(#redGlow)"; + return isFinding ? "url(#glow)" : null; + }) + .attr("class", (d: NodeData) => { + const isSelected = d.id === selectedNodeId; + return isSelected ? "node-shape selected-node" : "node-shape"; }); } @@ -245,16 +260,19 @@ const AttackPathGraphComponent = forwardRef< }); g.setDefaultEdgeLabel(() => ({})); - // Initially hide finding nodes + // Initially hide finding nodes - they are shown when user clicks on a node + // In filtered view, show all nodes since they're already filtered to the selected path const initialHiddenNodes = new Set(); - data.nodes.forEach((node) => { - const isFinding = node.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - if (isFinding) { - initialHiddenNodes.add(node.id); - } - }); + if (!isFilteredView) { + data.nodes.forEach((node) => { + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + if (isFinding) { + initialHiddenNodes.add(node.id); + } + }); + } hiddenNodeIdsRef.current = initialHiddenNodes; // Create a map to store original node data @@ -373,6 +391,16 @@ const AttackPathGraphComponent = forwardRef< .attr("flood-color", GRAPH_ALERT_BORDER_COLOR) .attr("flood-opacity", "0.6"); + // Orange glow filter for selected/filtered node + const selectedGlowFilter = defs.append("filter").attr("id", "selectedGlow"); + selectedGlowFilter + .append("feDropShadow") + .attr("dx", "0") + .attr("dy", "0") + .attr("stdDeviation", "6") + .attr("flood-color", GRAPH_EDGE_HIGHLIGHT_COLOR) + .attr("flood-opacity", "0.8"); + // Arrow marker (default white) - refX=10 places the arrow tip exactly at the line endpoint defs .append("marker") @@ -401,7 +429,7 @@ const AttackPathGraphComponent = forwardRef< .attr("d", "M 0 0 L 10 5 L 0 10 z") .attr("fill", GRAPH_EDGE_HIGHLIGHT_COLOR); - // Add CSS animation for dashed lines and resource edge styles + // Add CSS animation for dashed lines, resource edge styles, and selected node pulse svg.append("style").text(` @keyframes dash { to { @@ -414,6 +442,20 @@ const AttackPathGraphComponent = forwardRef< .resource-edge { stroke-opacity: 1; } + @keyframes selectedPulse { + 0%, 100% { + stroke-opacity: 1; + stroke-width: 4px; + } + 50% { + stroke-opacity: 0.6; + stroke-width: 6px; + } + } + .selected-node { + animation: selectedPulse 1.2s ease-in-out infinite; + filter: url(#selectedGlow); + } `); const linkGroup = container.append("g").attr("class", "links"); @@ -510,20 +552,18 @@ const AttackPathGraphComponent = forwardRef< return hasFinding ? "animated-edge" : "resource-edge"; }) .attr("marker-end", "url(#arrowhead)") - .each(function (d) { - // Resource-to-resource edges are ALWAYS visible - // Finding edges are only visible when the finding node is visible + .style("visibility", (d) => { const sourceIsFinding = isNodeFinding(d.sourceId); const targetIsFinding = isNodeFinding(d.targetId); - let visibility = "visible"; - if (sourceIsFinding || targetIsFinding) { - const sourceHidden = hiddenNodeIdsRef.current.has(d.sourceId); - const targetHidden = hiddenNodeIdsRef.current.has(d.targetId); - visibility = sourceHidden || targetHidden ? "hidden" : "visible"; + // Hide edges connected to findings in full view (shown when user clicks on a node or in filtered view) + if ( + !isFilteredView && + (sourceIsFinding || targetIsFinding) + ) { + return "hidden"; } - - select(this).style("visibility", visibility); + return "visible"; }); // Store linkElements reference for hover interactions @@ -553,9 +593,10 @@ const AttackPathGraphComponent = forwardRef< .attr("class", "node") .attr("transform", (d) => `translate(${d.x},${d.y})`) .attr("cursor", "pointer") - .style("display", (d) => - hiddenNodeIdsRef.current.has(d.id) ? "none" : null, - ) + .style("display", (d) => { + // Hide findings in full view (they are shown when user clicks on a node or in filtered view) + return hiddenNodeIdsRef.current.has(d.id) ? "none" : null; + }) .on("mouseenter", function (_event: PointerEvent, d) { // Highlight entire path from this node const pathEdges = getPathEdges(d.id, edgesData); @@ -852,31 +893,41 @@ const AttackPathGraphComponent = forwardRef< L ${-w / 2} 0 Z `; + const isSelected = d.id === selectedNodeId; group .append("path") .attr("d", hexPath) .attr("fill", nodeColor) .attr("fill-opacity", 0.85) - .attr( - "stroke", - d.id === selectedNodeId ? GRAPH_SELECTION_COLOR : borderColor, - ) - .attr("stroke-width", d.id === selectedNodeId ? 3 : 2) - .attr("filter", "url(#glow)") - .attr("class", "node-shape"); + .attr("stroke", isSelected ? GRAPH_EDGE_HIGHLIGHT_COLOR : borderColor) + .attr("stroke-width", isSelected ? 4 : 2) + .attr("filter", isSelected ? "url(#selectedGlow)" : "url(#glow)") + .attr("class", isSelected ? "node-shape selected-node" : "node-shape"); } else { // Check if this is an Internet node const isInternet = d.data.labels.some( (label) => label.toLowerCase() === "internet", ); + const isSelected = d.id === selectedNodeId; + // Resources with findings get red border and red glow (even when selected) + // Selected nodes get orange border const strokeColor = hasFindings ? GRAPH_ALERT_BORDER_COLOR - : d.id === selectedNodeId - ? GRAPH_SELECTION_COLOR + : isSelected + ? GRAPH_EDGE_HIGHLIGHT_COLOR : borderColor; + // Determine filter: selected takes priority, then hasFindings, then default + const nodeFilter = isSelected + ? "url(#selectedGlow)" + : hasFindings + ? "url(#redGlow)" + : "url(#glow)"; + + const nodeClass = isSelected ? "node-shape selected-node" : "node-shape"; + if (isInternet) { // Globe shape for Internet nodes - larger than regular nodes const radius = NODE_HEIGHT * 0.8; @@ -890,12 +941,9 @@ const AttackPathGraphComponent = forwardRef< .attr("fill", nodeColor) .attr("fill-opacity", 0.85) .attr("stroke", strokeColor) - .attr( - "stroke-width", - hasFindings ? 2.5 : d.id === selectedNodeId ? 3 : 1.5, - ) - .attr("filter", hasFindings ? "url(#redGlow)" : "url(#glow)") - .attr("class", "node-shape"); + .attr("stroke-width", isSelected ? 4 : hasFindings ? 2.5 : 1.5) + .attr("filter", nodeFilter) + .attr("class", nodeClass); // Horizontal ellipse (equator) group @@ -933,17 +981,14 @@ const AttackPathGraphComponent = forwardRef< .attr("fill", nodeColor) .attr("fill-opacity", 0.85) .attr("stroke", strokeColor) - .attr( - "stroke-width", - hasFindings ? 2.5 : d.id === selectedNodeId ? 3 : 1.5, - ) - .attr("filter", hasFindings ? "url(#redGlow)" : null) - .attr("class", "node-shape"); + .attr("stroke-width", isSelected ? 4 : hasFindings ? 2.5 : 1.5) + .attr("filter", nodeFilter) + .attr("class", nodeClass); } } }); - // Store reference for updating selection later (select all shapes) + // Store references for updating selection later const nodeShapes = nodeElements.selectAll(".node-shape"); nodeShapesRef.current = nodeShapes as unknown as ReturnType< typeof select @@ -1100,8 +1145,11 @@ const AttackPathGraphComponent = forwardRef< ); } }, 100); + // D3's imperative rendering model requires controlled re-renders. + // We intentionally only re-render on data/view changes, not on callback refs + // (onNodeClick, selectedNodeId) which would cause unnecessary D3 re-renders. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data]); + }, [data, isFilteredView]); return ( void; setSelectedNodeId: (nodeId: string | null) => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; setZoom: (zoomLevel: number) => void; setPan: (panX: number, panY: number) => void; + setFilteredView: ( + isFiltered: boolean, + nodeId: string | null, + filteredData: AttackPathGraphData | null, + fullData: AttackPathGraphData | null, + ) => void; reset: () => void; } -const initialState: GraphState = { +const initialState: GraphState & FilteredViewState = { data: null, selectedNodeId: null, loading: false, @@ -26,22 +40,27 @@ const initialState: GraphState = { zoomLevel: 1, panX: 0, panY: 0, + isFilteredView: false, + filteredNodeId: null, + fullData: null, }; const useGraphStore = create((set) => ({ ...initialState, - setGraphData: (data) => set({ data, error: null }), + setGraphData: (data) => set({ data, fullData: null, error: null, isFilteredView: false, filteredNodeId: null }), setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }), setLoading: (loading) => set({ loading }), setError: (error) => set({ error }), setZoom: (zoomLevel) => set({ zoomLevel }), setPan: (panX, panY) => set({ panX, panY }), + setFilteredView: (isFiltered, nodeId, filteredData, fullData) => + set({ isFilteredView: isFiltered, filteredNodeId: nodeId, data: filteredData, fullData, selectedNodeId: nodeId }), reset: () => set(initialState), })); /** * Custom hook for managing graph visualization state - * Handles graph data, node selection, zoom/pan, and loading states + * Handles graph data, node selection, zoom/pan, loading states, and filtered view */ export const useGraphState = () => { const store = useGraphStore(); @@ -86,10 +105,46 @@ export const useGraphState = () => { const clearGraph = () => { store.setGraphData({ nodes: [], edges: [] }); store.setSelectedNodeId(null); + store.setFilteredView(false, null, null, null); + }; + + /** + * Enter filtered view mode - redraws graph with only the selected path + * Stores full data so we can restore it when exiting filtered view + */ + const enterFilteredView = (nodeId: string) => { + if (!store.data) return; + + // Use fullData if we're already in filtered view, otherwise use current data + const sourceData = store.fullData || store.data; + const filteredData = computeFilteredSubgraph(sourceData, nodeId); + store.setFilteredView(true, nodeId, filteredData, sourceData); + }; + + /** + * Exit filtered view mode - restore full graph data + */ + const exitFilteredView = () => { + if (!store.isFilteredView || !store.fullData) return; + store.setFilteredView(false, null, store.fullData, null); + }; + + /** + * Get the node that was used to filter the view + */ + const getFilteredNode = (): GraphNode | null => { + if (!store.isFilteredView || !store.filteredNodeId) return null; + // Look in fullData since that's where the original node data is + const sourceData = store.fullData || store.data; + if (!sourceData) return null; + return ( + sourceData.nodes.find((node) => node.id === store.filteredNodeId) || null + ); }; return { data: store.data, + fullData: store.fullData, selectedNodeId: store.selectedNodeId, selectedNode: getSelectedNode(), loading: store.loading, @@ -97,6 +152,9 @@ export const useGraphState = () => { zoomLevel: store.zoomLevel, panX: store.panX, panY: store.panY, + isFilteredView: store.isFilteredView, + filteredNodeId: store.filteredNodeId, + filteredNode: getFilteredNode(), updateGraphData, selectNode, startLoading, @@ -105,5 +163,7 @@ export const useGraphState = () => { updateZoomAndPan, resetGraph, clearGraph, + enterFilteredView, + exitFilteredView, }; }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts index b8a5970a2f..59ae7ca462 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts @@ -2,11 +2,129 @@ * Utility functions for attack path graph operations */ +import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; + +/** + * Type for edge node reference - can be a string ID or an object with id property + */ +export type EdgeNodeRef = string | { id: string }; + +/** + * Helper to get edge source/target ID from string or object + */ +export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => { + if (typeof nodeRef === "string") { + return nodeRef; + } + return nodeRef.id; +}; + +/** + * Compute a filtered subgraph containing only the path through the target node. + * This follows the directed graph structure of attack paths: + * - Upstream: traces back to the root (AWS Account) + * - Downstream: traces forward to leaf nodes + * - Also includes findings connected to the selected node + */ +export const computeFilteredSubgraph = ( + fullData: AttackPathGraphData, + targetNodeId: string, +): AttackPathGraphData => { + const nodes = fullData.nodes; + const edges = fullData.edges || []; + + // Build directed adjacency lists + const forwardEdges = new Map>(); // source -> targets + const backwardEdges = new Map>(); // target -> sources + nodes.forEach((node) => { + forwardEdges.set(node.id, new Set()); + backwardEdges.set(node.id, new Set()); + }); + + edges.forEach((edge) => { + const sourceId = getEdgeNodeId(edge.source); + const targetId = getEdgeNodeId(edge.target); + forwardEdges.get(sourceId)?.add(targetId); + backwardEdges.get(targetId)?.add(sourceId); + }); + + const visibleNodeIds = new Set(); + visibleNodeIds.add(targetNodeId); + + // Traverse upstream (backward) - find all ancestors + const traverseUpstream = (nodeId: string) => { + const sources = backwardEdges.get(nodeId); + if (sources) { + sources.forEach((sourceId) => { + if (!visibleNodeIds.has(sourceId)) { + visibleNodeIds.add(sourceId); + traverseUpstream(sourceId); + } + }); + } + }; + + // Traverse downstream (forward) - find all descendants + const traverseDownstream = (nodeId: string) => { + const targets = forwardEdges.get(nodeId); + if (targets) { + targets.forEach((targetId) => { + if (!visibleNodeIds.has(targetId)) { + visibleNodeIds.add(targetId); + traverseDownstream(targetId); + } + }); + } + }; + + // Start traversal from the target node + traverseUpstream(targetNodeId); + traverseDownstream(targetNodeId); + + // Also include findings directly connected to the selected node + edges.forEach((edge) => { + const sourceId = getEdgeNodeId(edge.source); + const targetId = getEdgeNodeId(edge.target); + const sourceNode = nodes.find((n) => n.id === sourceId); + const targetNode = nodes.find((n) => n.id === targetId); + + const sourceIsFinding = sourceNode?.labels.some((l) => + l.toLowerCase().includes("finding"), + ); + const targetIsFinding = targetNode?.labels.some((l) => + l.toLowerCase().includes("finding"), + ); + + // Include findings connected to the selected node + if (sourceId === targetNodeId && targetIsFinding) { + visibleNodeIds.add(targetId); + } + if (targetId === targetNodeId && sourceIsFinding) { + visibleNodeIds.add(sourceId); + } + }); + + // Filter nodes and edges to only include visible ones + const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id)); + const filteredEdges = edges.filter((edge) => { + const sourceId = getEdgeNodeId(edge.source); + const targetId = getEdgeNodeId(edge.target); + return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId); + }); + + return { + nodes: filteredNodes, + edges: filteredEdges, + }; +}; + /** * Find edges in the path from a given node. * Upstream: follows only ONE parent path (first parent at each level) to avoid lighting up siblings * Downstream: follows ALL children recursively * + * Uses pre-built adjacency maps for O(1) lookups instead of O(n) array searches per traversal step. + * * @param nodeId - The starting node ID * @param edges - Array of edges with sourceId and targetId * @returns Set of edge IDs in the format "sourceId-targetId" @@ -15,6 +133,23 @@ export const getPathEdges = ( nodeId: string, edges: Array<{ sourceId: string; targetId: string }>, ): Set => { + // Build adjacency maps once - O(n) + const parentMap = new Map(); + const childrenMap = new Map< + string, + Array<{ sourceId: string; targetId: string }> + >(); + + edges.forEach((edge) => { + // First parent only (matches original behavior of find()) + if (!parentMap.has(edge.targetId)) { + parentMap.set(edge.targetId, edge); + } + const children = childrenMap.get(edge.sourceId) || []; + children.push(edge); + childrenMap.set(edge.sourceId, children); + }); + const pathEdgeIds = new Set(); const visitedNodes = new Set(); @@ -24,8 +159,7 @@ export const getPathEdges = ( if (visitedNodes.has(`up-${currentNodeId}`)) return; visitedNodes.add(`up-${currentNodeId}`); - // Find the first parent edge only - const parentEdge = edges.find((edge) => edge.targetId === currentNodeId); + const parentEdge = parentMap.get(currentNodeId); // O(1) lookup if (parentEdge) { pathEdgeIds.add(`${parentEdge.sourceId}-${parentEdge.targetId}`); traverseUpstream(parentEdge.sourceId); @@ -37,11 +171,10 @@ export const getPathEdges = ( if (visitedNodes.has(`down-${currentNodeId}`)) return; visitedNodes.add(`down-${currentNodeId}`); - edges.forEach((edge) => { - if (edge.sourceId === currentNodeId) { - pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`); - traverseDownstream(edge.targetId); - } + const children = childrenMap.get(currentNodeId) || []; // O(1) lookup + children.forEach((edge) => { + pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`); + traverseDownstream(edge.targetId); }); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts index 4f90cf71fa..d4e489f0fa 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts @@ -4,7 +4,12 @@ export { exportGraphAsSVG, } from "./export"; export { formatNodeLabel, formatNodeLabels } from "./format"; -export { getPathEdges } from "./graph-utils"; +export { + computeFilteredSubgraph, + getEdgeNodeId, + getPathEdges, + type EdgeNodeRef, +} from "./graph-utils"; export { getNodeBorderColor, getNodeColor, 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 9cce39568e..a817b9a715 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,6 @@ "use client"; -import { Maximize2, X } from "lucide-react"; +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"; @@ -233,15 +233,15 @@ export default function AttackPathAnalysisPage() { }; const handleNodeClick = (node: GraphNode) => { - graphState.selectNode(node.id); + // Enter filtered view showing only paths containing this node + graphState.enterFilteredView(node.id); - // Only scroll to details if it's a finding node + // For findings, also scroll to the details section const isFinding = node.labels.some((label) => label.toLowerCase().includes("finding"), ); if (isFinding) { - // Scroll to node details section after a short delay setTimeout(() => { nodeDetailsRef.current?.scrollIntoView({ behavior: "smooth", @@ -251,6 +251,10 @@ export default function AttackPathAnalysisPage() { } }; + const handleBackToFullView = () => { + graphState.exitFilteredView(); + }; + const handleCloseDetails = () => { graphState.selectNode(null); }; @@ -371,18 +375,50 @@ export default function AttackPathAnalysisPage() { <> {/* Info message and controls */}
-
- - - Click on any resource node to view its related findings - -
+ {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 */}
@@ -441,6 +477,7 @@ export default function AttackPathAnalysisPage() { data={graphState.data} onNodeClick={handleNodeClick} selectedNodeId={graphState.selectedNodeId} + isFilteredView={graphState.isFilteredView} />
{/* Node Detail Panel - Side by side */} @@ -509,6 +546,7 @@ export default function AttackPathAnalysisPage() { data={graphState.data} onNodeClick={handleNodeClick} selectedNodeId={graphState.selectedNodeId} + isFilteredView={graphState.isFilteredView} />