feat(attack-paths): add filtered view for graph nodes (#9784)

This commit is contained in:
Andoni Alonso
2026-01-14 18:32:24 +01:00
committed by GitHub
parent 39280c8b9b
commit e4ef4bfd4d
5 changed files with 364 additions and 80 deletions
@@ -37,6 +37,7 @@ interface AttackPathGraphProps {
data: AttackPathGraphData;
onNodeClick?: (node: GraphNode) => void;
selectedNodeId?: string | null;
isFilteredView?: boolean;
ref?: Ref<AttackPathGraphRef>;
}
@@ -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<SVGSVGElement>(null);
const [zoomLevel, setZoomLevel] = useState(1);
const zoomBehaviorRef = useRef<ZoomBehavior<SVGSVGElement, unknown> | 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<string>();
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<SVGRectElement, NodeData>
@@ -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 (
<svg
@@ -8,17 +8,31 @@ import type {
GraphState,
} from "@/types/attack-paths";
interface GraphStore extends GraphState {
import { computeFilteredSubgraph } from "../_lib";
interface FilteredViewState {
isFilteredView: boolean;
filteredNodeId: string | null;
fullData: AttackPathGraphData | null; // Original data before filtering
}
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,
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<GraphStore>((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,
};
};
@@ -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<string, Set<string>>(); // source -> targets
const backwardEdges = new Map<string, Set<string>>(); // 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<string>();
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<string> => {
// Build adjacency maps once - O(n)
const parentMap = new Map<string, { sourceId: string; targetId: string }>();
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<string>();
const visitedNodes = new Set<string>();
@@ -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);
});
};
@@ -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,
@@ -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 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div
className="bg-button-primary inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm sm:px-4 sm:text-sm"
role="status"
aria-label="Graph interaction instructions"
>
<span className="flex-shrink-0" aria-hidden="true">
💡
</span>
<span className="flex-1">
Click on any resource node to view its related findings
</span>
</div>
{graphState.isFilteredView ? (
<div className="flex items-center gap-3">
<Button
onClick={handleBackToFullView}
variant="outline"
size="sm"
className="gap-2"
aria-label="Return to full graph view"
>
<ArrowLeft size={16} />
Back to Full View
</Button>
<div
className="bg-bg-info-secondary text-text-info inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium shadow-sm sm:px-4 sm:text-sm"
role="status"
aria-label="Filtered view active"
>
<span className="flex-shrink-0" aria-hidden="true">
🔍
</span>
<span className="flex-1">
Showing paths for:{" "}
<strong>
{graphState.filteredNode?.properties?.name ||
graphState.filteredNode?.properties?.id ||
"Selected node"}
</strong>
</span>
</div>
</div>
) : (
<div
className="bg-button-primary inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm sm:px-4 sm:text-sm"
role="status"
aria-label="Graph interaction instructions"
>
<span className="flex-shrink-0" aria-hidden="true">
💡
</span>
<span className="flex-1">
Click on any node to filter and view its connected paths
</span>
</div>
)}
{/* Graph controls and fullscreen button together */}
<div className="flex items-center gap-2">
@@ -441,6 +477,7 @@ export default function AttackPathAnalysisPage() {
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
/>
</div>
{/* 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}
/>
</div>