diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts index 9664891907..546eef23ee 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts @@ -8,17 +8,34 @@ import type { GraphState, } from "@/types/attack-paths"; -interface GraphStore extends GraphState { +interface FilteredViewState { + isFilteredView: boolean; + filteredNodeId: string | null; + fullData: AttackPathGraphData | null; +} + +interface GraphStore extends GraphState, FilteredViewState { setGraphData: (data: AttackPathGraphData) => 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, + fullData: AttackPathGraphData | null, + ) => void; + enterFiltered: ( + filteredData: AttackPathGraphData, + nodeId: string, + fullData: AttackPathGraphData, + ) => void; + exitFiltered: (fullData: AttackPathGraphData) => void; reset: () => void; } -const initialState: GraphState = { +const initialState: GraphState & FilteredViewState = { data: null, selectedNodeId: null, loading: false, @@ -26,6 +43,9 @@ const initialState: GraphState = { zoomLevel: 1, panX: 0, panY: 0, + isFilteredView: false, + filteredNodeId: null, + fullData: null, }; const useGraphStore = create((set) => ({ @@ -36,18 +56,152 @@ const useGraphStore = create((set) => ({ setError: (error) => set({ error }), setZoom: (zoomLevel) => set({ zoomLevel }), setPan: (panX, panY) => set({ panX, panY }), + setFilteredView: (isFiltered, nodeId, fullData) => + set({ isFilteredView: isFiltered, filteredNodeId: nodeId, fullData }), + // Atomic state update for entering filtered view + enterFiltered: (filteredData, nodeId, fullData) => + set({ + data: filteredData, + isFilteredView: true, + filteredNodeId: nodeId, + fullData: fullData, + selectedNodeId: nodeId, + error: null, + }), + // Atomic state update for exiting filtered view + exitFiltered: (fullData) => + set({ + data: fullData, + isFilteredView: false, + filteredNodeId: null, + fullData: null, + selectedNodeId: null, + }), reset: () => set(initialState), })); +/** + * Helper to get edge source/target ID from string or object + */ +function getEdgeNodeId(nodeRef: string | object): string { + if (typeof nodeRef === "string") { + return nodeRef; + } + return (nodeRef as GraphNode).id; +} + +/** + * Compute the subgraph containing the upstream and downstream paths for a specific 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 + */ +function 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 includedNodes = new Set(); + includedNodes.add(targetNodeId); + + // Traverse upstream (backward) - find all ancestors + const traverseUpstream = (nodeId: string) => { + const sources = backwardEdges.get(nodeId); + if (sources) { + sources.forEach((sourceId) => { + if (!includedNodes.has(sourceId)) { + includedNodes.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 (!includedNodes.has(targetId)) { + includedNodes.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) { + includedNodes.add(targetId); + } + if (targetId === targetNodeId && sourceIsFinding) { + includedNodes.add(sourceId); + } + }); + + // Filter nodes and edges + const filteredNodes = nodes.filter((node) => includedNodes.has(node.id)); + const filteredEdges = edges.filter((edge) => { + const sourceId = getEdgeNodeId(edge.source); + const targetId = getEdgeNodeId(edge.target); + return includedNodes.has(sourceId) && includedNodes.has(targetId); + }); + + return { + nodes: filteredNodes, + edges: filteredEdges, + }; +} + /** * 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(); // Zustand store methods are stable, no need to memoize const updateGraphData = (data: AttackPathGraphData) => { + // When updating graph data, exit filtered view if active + if (store.isFilteredView) { + store.setFilteredView(false, null, null); + } store.setGraphData(data); }; @@ -86,6 +240,45 @@ export const useGraphState = () => { const clearGraph = () => { store.setGraphData({ nodes: [], edges: [] }); store.setSelectedNodeId(null); + store.setFilteredView(false, null, null); + }; + + /** + * Enter filtered view mode - shows only paths containing the specified node + */ + const enterFilteredView = (nodeId: string) => { + if (!store.data) return; + + // Get full data (use stored fullData if already in filtered view, otherwise current data) + const fullData = store.isFilteredView ? store.fullData : store.data; + if (!fullData) return; + + const filteredData = computeFilteredSubgraph(fullData, nodeId); + + // Atomic state update to ensure graph re-renders with filtered data + store.enterFiltered(filteredData, nodeId, fullData); + }; + + /** + * Exit filtered view mode - restore the full graph + */ + const exitFilteredView = () => { + if (!store.isFilteredView || !store.fullData) return; + + // Atomic state update to restore full graph + store.exitFiltered(store.fullData); + }; + + /** + * Get the node that was used to filter the view + */ + const getFilteredNode = (): GraphNode | null => { + if (!store.isFilteredView || !store.filteredNodeId || !store.fullData) + return null; + return ( + store.fullData.nodes.find((node) => node.id === store.filteredNodeId) || + null + ); }; return { @@ -97,6 +290,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 +301,7 @@ export const useGraphState = () => { updateZoomAndPan, resetGraph, clearGraph, + enterFilteredView, + exitFilteredView, }; }; 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..9becff907a 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 */}