Compare commits

...

6 Commits

Author SHA1 Message Date
Alan Buscaglia fc7e0c85e4 feat(ui): expand attack paths node legend coverage 2026-05-05 21:54:39 +02:00
Alan Buscaglia 33ad74b53c feat(ui): improve attack paths graph exploration 2026-05-05 21:28:26 +02:00
Alan Buscaglia c5aa4ced0d feat(ui): render attack paths node icons
- Add badge-style graph nodes with wrapped labels
- Map provider, resource, identity, and finding icons
- Align graph layout and handles with badge nodes
- Cover icon rendering and visual metadata with tests
2026-05-05 20:47:13 +02:00
Alan Buscaglia 58b0fa556d feat(ui): add attack paths node visual mapper
- Add typed resolver for graph node visual metadata
- Reuse existing service icons for known resource labels
- Cover exact, alias, and fallback mappings with tests
2026-05-05 20:00:22 +02:00
Alan Buscaglia aa311623fe refactor(ui): simplify attack paths graph interactions
- Reuse shared measured-fit scheduling for graph viewport updates
- Consolidate node action dialog state
- Tighten browser harness dialog detection
2026-05-05 19:25:00 +02:00
Alan Buscaglia 142b45a387 fix(ui): improve attack paths graph interactions
- Restore supported graph scroll zoom behavior
- Add node action selector for ambiguous resource clicks
- Open finding and node details in existing drawers
- Cover resource actions with browser tests
2026-05-05 19:13:04 +02:00
17 changed files with 2590 additions and 775 deletions
@@ -53,11 +53,7 @@ interface AttackPathGraphProps {
selectedNodeId?: string | null;
isFilteredView?: boolean;
initialNodeId?: string;
// Tier 1 expansion state — controlled by the parent (lives in the graph
// store) so it survives filtered-view enter/exit. If omitted, no resource
// expansion is tracked and findings stay hidden in the full view.
expandedResources?: Set<string>;
onResourceToggle?: (resourceId: string) => void;
onNodeClick?: (node: GraphNode) => void;
onInitialFilter?: (filteredData: AttackPathGraphData) => void;
ref?: Ref<GraphHandle>;
@@ -176,26 +172,42 @@ const AUTO_FIT_OPTIONS = {
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,
initialNodeId,
expandedResources,
onResourceToggle,
onNodeClick,
onInitialFilter,
ref,
}: GraphCanvasProps) => {
const {
zoomIn,
zoomOut,
fitView,
getZoom,
getNodes,
getNodesBounds,
getViewport,
} = useReactFlow();
const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } =
useReactFlow();
const { resolvedTheme } = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
const hasInitialized = useRef(false);
@@ -245,16 +257,11 @@ const GraphCanvas = ({
// 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 fits ONLY when a newly revealed finding sits
// entirely outside the current viewport (i.e. its full bounding box
// is past one of the viewport edges). This intentionally lets
// partially-clipped edge nodes through without re-fitting — hidden
// findings contribute zero size to the initial bbox, so a finding's
// far edge often peeks past the padded viewport on the first reveal,
// and a "partially outside" check would re-fit on every expand. The
// user's "no cabe visualmente" complaint is about findings that
// appear completely off-screen after the user has panned away, which
// the strict check captures.
// 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);
@@ -271,56 +278,104 @@ const GraphCanvas = ({
return;
}
if (previousFilteredRef.current === isFilteredView) return;
const wasFilteredView = previousFilteredRef.current;
previousFilteredRef.current = isFilteredView;
// Defer to the next animation frame so React Flow has applied the new
// layout (after the filter swap) before we measure for the fit.
const frame = requestAnimationFrame(() => {
fitView(AUTO_FIT_OPTIONS);
});
return () => cancelAnimationFrame(frame);
}, [isFilteredView, fitView]);
// 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;
// Only fit on growth — collapsing intentionally leaves the user's
// framing alone.
const newResourceIds = Array.from(expanded).filter(
(id) => !previous.has(id),
);
if (newResourceIds.length === 0) return;
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));
findings.forEach((id) => {
newFindingIds.add(id);
contextualFitNodeIds.add(id);
});
}
if (newFindingIds.size === 0) return;
const frame = requestAnimationFrame(() => {
const containerEl = containerRef.current;
if (!containerEl) return;
const { width, height } = containerEl.getBoundingClientRect();
if (width === 0 || height === 0) return;
const { x, y, zoom } = getViewport();
const minX = -x / zoom;
const minY = -y / zoom;
const maxX = minX + width / zoom;
const maxY = minY + height / zoom;
const anyOutside = getNodes().some((node) => {
if (!newFindingIds.has(node.id)) return false;
const nx = node.position.x;
const ny = node.position.y;
const nw = node.measured?.width ?? 0;
const nh = node.measured?.height ?? 0;
return nx + nw < minX || nx > maxX || ny + nh < minY || ny > maxY;
});
if (anyOutside) fitView(AUTO_FIT_OPTIONS);
});
return () => cancelAnimationFrame(frame);
}, [expanded, fitView, getNodes, getViewport]);
// 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]);
const nodes = effectiveData.nodes ?? [];
const edges = effectiveData.edges ?? [];
@@ -434,13 +489,6 @@ const GraphCanvas = ({
const handleNodeClick = (_event: MouseEvent, node: Node) => {
const graphNode = (node.data as { graphNode: GraphNode }).graphNode;
// Tier 1: clicking resource in full view toggles connected findings
if (!isFilteredView && !isFindingNode(graphNode.labels)) {
if (resourcesWithFindings.has(node.id)) {
onResourceToggle?.(node.id);
}
}
// Always fire parent callback (handles selection + Tier 2 filtered view)
onNodeClick?.(graphNode);
};
@@ -464,11 +512,15 @@ const GraphCanvas = ({
onNodeMouseEnter={handleNodeMouseEnter}
onNodeMouseLeave={handleNodeMouseLeave}
fitView
fitViewOptions={{ padding: 0.2, includeHiddenNodes: true }}
zoomOnScroll={false}
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 }}
@@ -508,7 +560,6 @@ export const AttackPathGraph = ({
isFilteredView,
initialNodeId,
expandedResources,
onResourceToggle,
onNodeClick,
onInitialFilter,
ref,
@@ -533,7 +584,6 @@ export const AttackPathGraph = ({
isFilteredView={isFilteredView}
initialNodeId={initialNodeId}
expandedResources={expandedResources}
onResourceToggle={onResourceToggle}
onNodeClick={onNodeClick}
onInitialFilter={onInitialFilter}
/>
@@ -0,0 +1,151 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AttackPathGraphData } from "@/types/attack-paths";
import { GraphLegend } from "./graph-legend";
vi.mock("next-themes", () => ({
useTheme: () => ({ resolvedTheme: "dark" }),
}));
const graphData: AttackPathGraphData = {
nodes: [
{
id: "aws-account",
labels: ["AWSAccount"],
properties: { name: "Production" },
},
{ id: "bucket", labels: ["S3Bucket"], properties: { name: "logs" } },
{ id: "vpc", labels: ["VPC"], properties: { name: "main" } },
{
id: "finding",
labels: ["ProwlerFinding"],
properties: { check_title: "Public bucket", severity: "critical" },
},
],
relationships: [
{ id: "r1", source: "aws-account", target: "bucket", label: "HAS" },
{ id: "r2", source: "bucket", target: "vpc", label: "CAN_ACCESS" },
{ id: "r3", source: "bucket", target: "finding", label: "HAS_FINDING" },
],
};
describe("GraphLegend", () => {
it("should explain concrete visible node types without generic categories", () => {
// Given - A graph with provider, resource, and finding nodes
// When
render(
<GraphLegend data={graphData} expandedResources={new Set(["bucket"])} />,
);
// Then
expect(
screen.getByRole("heading", { name: /provider roots/i }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /node types/i }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /findings by risk/i }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /states/i }),
).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /edges/i })).toBeInTheDocument();
expect(screen.getByText("Provider / account root")).toBeInTheDocument();
expect(screen.getByText("S3 Bucket")).toBeInTheDocument();
expect(screen.getByText("VPC")).toBeInTheDocument();
expect(screen.queryByText("Storage")).not.toBeInTheDocument();
expect(screen.queryByText("Network")).not.toBeInTheDocument();
expect(screen.queryByText("Compute")).not.toBeInTheDocument();
expect(screen.queryByText("Identity")).not.toBeInTheDocument();
expect(screen.queryByText("Secret / misc")).not.toBeInTheDocument();
expect(screen.getByText("Critical")).toBeInTheDocument();
expect(screen.queryByText("High")).not.toBeInTheDocument();
expect(screen.queryByText("Medium")).not.toBeInTheDocument();
expect(screen.queryByText("Low / Info")).not.toBeInTheDocument();
expect(screen.getByText("Selected node")).toBeInTheDocument();
expect(screen.getByText("Node with findings")).toBeInTheDocument();
expect(screen.getByText("Normal edge")).toBeInTheDocument();
expect(screen.getByText("Finding edge")).toBeInTheDocument();
expect(screen.getByText("Highlighted path")).toBeInTheDocument();
expect(screen.queryByText(/ctrl/i)).not.toBeInTheDocument();
expect(screen.queryByText(/scroll to zoom/i)).not.toBeInTheDocument();
});
it("should hide finding legend items when finding nodes are hidden", () => {
// Given - A resource has related findings, but it is not expanded yet
// When
render(<GraphLegend data={graphData} />);
// Then
expect(
screen.queryByRole("heading", { name: /findings by risk/i }),
).not.toBeInTheDocument();
expect(screen.queryByText("Finding edge")).not.toBeInTheDocument();
expect(screen.getByText("Node with findings")).toBeInTheDocument();
});
it("should list policy and role node types separately", () => {
// Given - A graph whose visible nodes are all identity-related, but distinct
const identityGraphData: AttackPathGraphData = {
nodes: [
{
id: "aws-account",
labels: ["AWSAccount"],
properties: { name: "Production" },
},
{
id: "role",
labels: ["PermissionRole"],
properties: { name: "prowler-pro-dev-gha-role" },
},
{
id: "policy",
labels: ["AWSPolicy"],
properties: { name: "IAMPermissions" },
},
{
id: "statement",
labels: ["AWSPolicyStatement"],
properties: { name: "policy statement" },
},
],
relationships: [
{ id: "r1", source: "aws-account", target: "role", label: "HAS" },
{ id: "r2", source: "role", target: "policy", label: "HAS" },
{ id: "r3", source: "policy", target: "statement", label: "HAS" },
],
};
// When
render(<GraphLegend data={identityGraphData} />);
// Then
expect(screen.getByText("Permission Role")).toBeInTheDocument();
expect(screen.getByText("AWS Policy")).toBeInTheDocument();
expect(screen.getByText("AWS Policy Statement")).toBeInTheDocument();
expect(screen.queryByText("Identity")).not.toBeInTheDocument();
});
it("should stay hidden until graph nodes are available", () => {
// Given - No graph nodes have been loaded yet
const emptyGraphData: AttackPathGraphData = { nodes: [] };
// When
const { container } = render(<GraphLegend data={emptyGraphData} />);
// Then
expect(container).toBeEmptyDOMElement();
expect(
screen.queryByRole("heading", { name: /provider roots/i }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
import { render, screen } from "@testing-library/react";
import { type NodeProps } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
import type { GraphNode } from "@/types/attack-paths";
import { FindingNode } from "./finding-node";
vi.mock("./hidden-handles", () => ({
HiddenHandles: () => null,
}));
const buildFindingNode = (severity: string, title: string): GraphNode => ({
id: `${severity}-finding`,
labels: ["ProwlerFinding"],
properties: { check_title: title, id: `${severity}-finding`, severity },
});
const buildNodeProps = (graphNode: GraphNode): NodeProps =>
({
id: graphNode.id,
type: "finding",
data: { graphNode },
selected: false,
dragging: false,
zIndex: 0,
isConnectable: false,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}) as unknown as NodeProps;
describe("FindingNode", () => {
describe("severity visuals", () => {
it("should render the critical finding risk icon with readable text", () => {
// Given
const props = buildNodeProps(
buildFindingNode("critical", "Root key exposed"),
);
// When
render(<FindingNode {...props} />);
// Then
expect(
screen.getByTestId("attack-path-finding-icon-critical"),
).toHaveAccessibleName("Critical finding risk icon");
expect(screen.getByText("Root key exposed")).toBeInTheDocument();
expect(screen.getByText("critical")).toBeInTheDocument();
});
it("should render a distinct medium finding risk icon with readable text", () => {
// Given
const props = buildNodeProps(
buildFindingNode("medium", "Bucket lacks logging"),
);
// When
render(<FindingNode {...props} />);
// Then
expect(
screen.getByTestId("attack-path-finding-icon-medium"),
).toHaveAccessibleName("Medium finding risk icon");
expect(
screen.queryByTestId("attack-path-finding-icon-critical"),
).not.toBeInTheDocument();
expect(screen.getByText("Bucket lacks")).toBeInTheDocument();
expect(screen.getByText("logging")).toBeInTheDocument();
expect(screen.getByText("medium")).toBeInTheDocument();
});
});
});
@@ -4,17 +4,46 @@ import { type NodeProps } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, truncateLabel } from "../../../_lib";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
import { getNodeLabelLines } from "./node-label-lines";
interface FindingNodeData {
graphNode: GraphNode;
[key: string]: unknown;
}
const HEXAGON_WIDTH = 200;
const HEXAGON_HEIGHT = 55;
const TITLE_MAX_CHARS = 24;
const NODE_WIDTH = 150;
const NODE_HEIGHT = 112;
const TITLE_MAX_CHARS = 18;
const TITLE_MAX_LINES = 2;
const BADGE_SIZE = 44;
const BADGE_RADIUS = BADGE_SIZE / 2;
const BADGE_CENTER_X = NODE_WIDTH / 2;
const BADGE_CENTER_Y = 26;
const BADGE_LEFT_X = BADGE_CENTER_X - BADGE_RADIUS;
const BADGE_RIGHT_INSET = NODE_WIDTH - (BADGE_CENTER_X + BADGE_RADIUS);
const ICON_SIZE = 28;
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
const TEXT_X = BADGE_CENTER_X;
const TITLE_Y = 66;
const TITLE_LINE_HEIGHT = 13;
const SEVERITY_Y = 94;
const severityLabel = (severity: unknown): string | undefined => {
if (!severity) return undefined;
const rawSeverity = Array.isArray(severity) ? severity[0] : severity;
return String(rawSeverity).toLowerCase();
};
const toFindingIconTestId = (severity: string | undefined): string =>
`attack-path-finding-icon-${severity ?? "unknown"}`;
const toAccessibleSeverity = (severity: string | undefined): string =>
severity
? `${severity.charAt(0).toUpperCase()}${severity.slice(1)}`
: "Unknown";
export const FindingNode = ({ data, selected }: NodeProps) => {
const { graphNode } = data as FindingNodeData;
@@ -30,51 +59,96 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
graphNode.properties?.id ||
"Finding",
);
const displayTitle = truncateLabel(title, TITLE_MAX_CHARS);
const displayTitleLines = getNodeLabelLines(
title,
TITLE_MAX_CHARS,
TITLE_MAX_LINES,
);
const visual = resolveNodeVisual(graphNode);
const Icon = visual.Icon;
const severity = severityLabel(graphNode.properties?.severity);
const iconLabel = `${toAccessibleSeverity(severity)} finding risk icon`;
// Hexagon SVG path
const w = HEXAGON_WIDTH;
const h = HEXAGON_HEIGHT;
const sideInset = w * 0.15;
const hexPath = `
M ${sideInset} 0
L ${w - sideInset} 0
L ${w} ${h / 2}
L ${w - sideInset} ${h}
L ${sideInset} ${h}
L 0 ${h / 2}
Z
`;
const badgeStrokeWidth = selected ? 4 : 2.5;
const glowRadius = selected ? 32 : 30;
const glowOpacity = selected ? 0.34 : 0.28;
return (
<>
<HiddenHandles />
<svg
width={w}
height={h}
className="overflow-visible"
style={{ filter: selected ? undefined : "url(#glow)" }}
>
<path
d={hexPath}
fill={fillColor}
fillOpacity={0.85}
<HiddenHandles
sourceStyle={{ right: BADGE_RIGHT_INSET }}
style={{ top: BADGE_CENTER_Y }}
targetStyle={{ left: BADGE_LEFT_X }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
stroke={borderColor}
strokeWidth={selected ? 4 : 2}
strokeOpacity={glowOpacity}
strokeWidth={8}
fill={borderColor}
fillOpacity={glowOpacity / 2}
pointerEvents="none"
/>
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.95}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toFindingIconTestId(severity)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
color="#ffffff"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
strokeWidth={2.4}
width={ICON_SIZE}
/>
</g>
<text
x={w / 2}
y={h / 2}
x={TEXT_X}
y={TITLE_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
fontSize="11px"
fontWeight="600"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayTitle}
{displayTitleLines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={TITLE_Y + index * TITLE_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{severity && (
<tspan
x={TEXT_X}
y={SEVERITY_Y}
fontSize="9px"
fill="rgba(255,255,255,0.82)"
>
{severity}
</tspan>
)}
</text>
</svg>
</>
@@ -1,10 +1,31 @@
"use client";
import { Handle, Position } from "@xyflow/react";
import type { CSSProperties } from "react";
export const HiddenHandles = () => (
interface HiddenHandlesProps {
style?: CSSProperties;
sourceStyle?: CSSProperties;
targetStyle?: CSSProperties;
}
export const HiddenHandles = ({
sourceStyle,
style,
targetStyle,
}: HiddenHandlesProps) => (
<>
<Handle type="target" position={Position.Left} className="invisible" />
<Handle type="source" position={Position.Right} className="invisible" />
<Handle
type="target"
position={Position.Left}
className="invisible"
style={{ ...style, ...targetStyle }}
/>
<Handle
type="source"
position={Position.Right}
className="invisible"
style={{ ...style, ...sourceStyle }}
/>
</>
);
@@ -0,0 +1,48 @@
const splitByMaxChars = (text: string, maxChars: number): string[] => {
const words = text.trim().split(/\s+/).filter(Boolean);
const lines: string[] = [];
let currentLine = "";
for (const word of words) {
if (!currentLine) {
currentLine = word;
continue;
}
const nextLine = `${currentLine} ${word}`;
if (nextLine.length <= maxChars) {
currentLine = nextLine;
continue;
}
lines.push(currentLine);
currentLine = word;
}
if (currentLine) lines.push(currentLine);
return lines;
};
const splitLongToken = (text: string, maxChars: number): string[] => {
const lines: string[] = [];
for (let index = 0; index < text.length; index += maxChars) {
lines.push(text.slice(index, index + maxChars));
}
return lines;
};
export const getNodeLabelLines = (
text: string,
maxChars: number,
maxLines: number,
): string[] => {
if (!text.trim()) return [];
const rawLines = text.includes(" ")
? splitByMaxChars(text, maxChars)
: splitLongToken(text, maxChars);
return rawLines.slice(0, maxLines);
};
@@ -0,0 +1,67 @@
import { render, screen } from "@testing-library/react";
import { type NodeProps } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
import type { GraphNode } from "@/types/attack-paths";
import { ResourceNode } from "./resource-node";
vi.mock("./hidden-handles", () => ({
HiddenHandles: () => null,
}));
const buildGraphNode = (label: string, name: string): GraphNode => ({
id: `${label}-${name}`,
labels: [label],
properties: { id: `${label}-${name}`, name },
});
const buildNodeProps = (graphNode: GraphNode): NodeProps =>
({
id: graphNode.id,
type: "resource",
data: { graphNode },
selected: false,
dragging: false,
zIndex: 0,
isConnectable: false,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}) as unknown as NodeProps;
describe("ResourceNode", () => {
describe("node visual icons", () => {
it("should render the S3 bucket icon with the resource label", () => {
// Given
const props = buildNodeProps(buildGraphNode("S3Bucket", "logs"));
// When
render(<ResourceNode {...props} />);
// Then
expect(
screen.getByTestId("attack-path-node-icon-s3-bucket"),
).toHaveAccessibleName("S3 Bucket icon");
expect(screen.getByText("logs")).toBeInTheDocument();
expect(screen.getByText("S3 Bucket")).toBeInTheDocument();
});
it("should render a distinct VPC icon with the resource label", () => {
// Given
const props = buildNodeProps(buildGraphNode("VPC", "main-vpc"));
// When
render(<ResourceNode {...props} />);
// Then
expect(
screen.getByTestId("attack-path-node-icon-vpc"),
).toHaveAccessibleName("VPC icon");
expect(
screen.queryByTestId("attack-path-node-icon-s3-bucket"),
).not.toBeInTheDocument();
expect(screen.getByText("main-vpc")).toBeInTheDocument();
expect(screen.getByText("VPC")).toBeInTheDocument();
});
});
});
@@ -4,9 +4,9 @@ import { type NodeProps } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, truncateLabel } from "../../../_lib";
import { formatNodeLabel } from "../../../_lib/format";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
import { getNodeLabelLines } from "./node-label-lines";
interface ResourceNodeData {
graphNode: GraphNode;
@@ -14,10 +14,29 @@ interface ResourceNodeData {
[key: string]: unknown;
}
const NODE_WIDTH = 180;
const NODE_HEIGHT = 50;
const NODE_RADIUS = 25;
const NAME_MAX_CHARS = 22;
const NODE_WIDTH = 136;
const NODE_HEIGHT = 112;
const NAME_MAX_CHARS = 16;
const NAME_MAX_LINES = 2;
const BADGE_SIZE = 44;
const BADGE_RADIUS = BADGE_SIZE / 2;
const BADGE_CENTER_X = NODE_WIDTH / 2;
const BADGE_CENTER_Y = 26;
const BADGE_LEFT_X = BADGE_CENTER_X - BADGE_RADIUS;
const BADGE_RIGHT_INSET = NODE_WIDTH - (BADGE_CENTER_X + BADGE_RADIUS);
const ICON_SIZE = 28;
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
const TEXT_X = BADGE_CENTER_X;
const NAME_Y = 66;
const NAME_LINE_HEIGHT = 13;
const TYPE_Y = 94;
const toIconTestId = (description: string): string =>
`attack-path-node-icon-${description
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")}`;
export const ResourceNode = ({ data, selected }: NodeProps) => {
const { graphNode, hasFindings } = data as ResourceNodeData;
@@ -27,58 +46,88 @@ export const ResourceNode = ({ data, selected }: NodeProps) => {
selected,
hasFindings,
});
const strokeWidth = selected ? 4 : hasFindings ? 2.5 : 1.5;
const badgeStrokeWidth = selected ? 4 : hasFindings ? 3 : 1.5;
const glowRadius = selected ? 31 : hasFindings ? 29 : 0;
const glowOpacity = selected ? 0.32 : hasFindings ? 0.26 : 0;
const visual = resolveNodeVisual(graphNode);
const Icon = visual.Icon;
const name = String(
graphNode.properties?.name ||
graphNode.properties?.id ||
(graphNode.labels.length > 0
? formatNodeLabel(graphNode.labels[0])
: "Unknown"),
const displayNameLines = getNodeLabelLines(
visual.displayName,
NAME_MAX_CHARS,
NAME_MAX_LINES,
);
const displayName = truncateLabel(name, NAME_MAX_CHARS);
const typeLabel =
graphNode.labels.length > 0 ? formatNodeLabel(graphNode.labels[0]) : "";
const typeLabel = visual.description;
const iconLabel = `${visual.description} icon`;
return (
<>
<HiddenHandles />
<HiddenHandles
sourceStyle={{ right: BADGE_RIGHT_INSET }}
style={{ top: BADGE_CENTER_Y }}
targetStyle={{ left: BADGE_LEFT_X }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
<rect
x={0}
y={0}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={NODE_RADIUS}
ry={NODE_RADIUS}
{glowRadius > 0 && (
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
fill={borderColor}
fillOpacity={glowOpacity}
pointerEvents="none"
/>
)}
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.85}
fillOpacity={0.92}
stroke={borderColor}
strokeWidth={strokeWidth}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toIconTestId(visual.description)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
className="rounded-md"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
width={ICON_SIZE}
/>
</g>
<text
x={NODE_WIDTH / 2}
y={NODE_HEIGHT / 2}
x={TEXT_X}
y={NAME_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
<tspan
x={NODE_WIDTH / 2}
dy="-0.3em"
fontSize="11px"
fontWeight="600"
>
{displayName}
</tspan>
{displayNameLines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={NAME_Y + index * NAME_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{typeLabel && (
<tspan
x={NODE_WIDTH / 2}
dy="1.3em"
x={TEXT_X}
y={TYPE_Y}
fontSize="9px"
fill="rgba(255,255,255,0.8)"
>
@@ -14,3 +14,9 @@ export {
} from "./graph-colors";
export { computeFilteredSubgraph, getPathEdges } from "./graph-utils";
export { layoutWithDagre } from "./layout";
export {
NODE_CATEGORY,
type NodeCategory,
type NodeVisual,
resolveNodeVisual,
} from "./node-visuals";
@@ -39,13 +39,13 @@ describe("layoutWithDagre", () => {
expect(byId.get("finding-1")).toMatchObject({
type: "finding",
width: 200,
height: 55,
width: 150,
height: 112,
});
expect(byId.get("resource-1")).toMatchObject({
type: "resource",
width: 180,
height: 50,
width: 136,
height: 112,
});
expect(byId.get("internet-1")).toMatchObject({
type: "internet",
@@ -8,11 +8,11 @@ import type { Edge, Node } from "@xyflow/react";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
// Node dimensions matching the original D3 implementation
const NODE_WIDTH = 180;
const NODE_HEIGHT = 50;
const HEXAGON_WIDTH = 200;
const HEXAGON_HEIGHT = 55;
// Node dimensions matching the rendered React Flow custom nodes.
const RESOURCE_NODE_WIDTH = 136;
const RESOURCE_NODE_HEIGHT = 112;
const FINDING_NODE_WIDTH = 150;
const FINDING_NODE_HEIGHT = 112;
const INTERNET_DIAMETER = 80; // NODE_HEIGHT * 0.8 * 2
// Container relationships that get reversed for proper hierarchy
@@ -49,10 +49,10 @@ const getNodeDimensions = (
type: NodeType,
): { width: number; height: number } => {
if (type === NODE_TYPE.FINDING)
return { width: HEXAGON_WIDTH, height: HEXAGON_HEIGHT };
return { width: FINDING_NODE_WIDTH, height: FINDING_NODE_HEIGHT };
if (type === NODE_TYPE.INTERNET)
return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER };
return { width: NODE_WIDTH, height: NODE_HEIGHT };
return { width: RESOURCE_NODE_WIDTH, height: RESOURCE_NODE_HEIGHT };
};
/**
@@ -0,0 +1,430 @@
import {
AlertTriangle,
Bot,
Braces,
CircleAlert,
FileKey2,
Globe2,
Info,
Route,
Server,
Shield,
ShieldCheck,
Siren,
Tags,
UserCog,
Users,
Waypoints,
} from "lucide-react";
import { describe, expect, it } from "vitest";
import {
AWSProviderBadge,
AzureProviderBadge,
GCPProviderBadge,
KS8ProviderBadge,
} from "@/components/icons/providers-badge";
import {
AmazonRDSIcon,
AmazonS3Icon,
AmazonVPCIcon,
AWSIAMIcon,
AWSLambdaIcon,
} from "@/components/icons/services/IconServices";
import type { GraphNode } from "@/types/attack-paths";
import { NODE_CATEGORY, resolveNodeVisual } from "./node-visuals";
const buildNode = (labels: string[], properties = {}): GraphNode => ({
id: labels[0] ?? "unknown-node",
labels,
properties,
});
describe("resolveNodeVisual", () => {
describe("exact label mappings", () => {
it("should resolve AWSAccount nodes to account metadata", () => {
// Given
const node = buildNode(["AWSAccount"], { name: "Production" });
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.ACCOUNT,
displayName: "Production",
description: "AWS Account",
fallbackUsed: false,
});
expect(visual.Icon).toBe(AWSProviderBadge);
});
it("should resolve cloud provider root nodes to provider badges", () => {
// Given
const providerNodes = [
{
label: "AzureTenant",
description: "Azure Tenant",
Icon: AzureProviderBadge,
},
{
label: "GCPProject",
description: "Google Cloud Project",
Icon: GCPProviderBadge,
},
{
label: "KubernetesCluster",
description: "Kubernetes Cluster",
Icon: KS8ProviderBadge,
},
];
for (const providerNode of providerNodes) {
// When
const visual = resolveNodeVisual(
buildNode([providerNode.label], { name: providerNode.description }),
);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.ACCOUNT,
displayName: providerNode.description,
description: providerNode.description,
fallbackUsed: false,
});
expect(visual.Icon).toBe(providerNode.Icon);
}
});
it("should resolve S3Bucket nodes to storage metadata", () => {
// Given
const node = buildNode(["S3Bucket"], { name: "public-assets" });
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.STORAGE,
displayName: "public-assets",
description: "S3 Bucket",
fallbackUsed: false,
});
expect(visual.Icon).toBe(AmazonS3Icon);
});
it("should resolve VPC nodes to network metadata", () => {
// Given
const node = buildNode(["VPC"], { name: "main-vpc" });
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.NETWORK,
displayName: "main-vpc",
description: "VPC",
fallbackUsed: false,
});
expect(visual.Icon).toBe(AmazonVPCIcon);
});
it("should resolve ProwlerFinding nodes to finding metadata", () => {
// Given
const node = buildNode(["ProwlerFinding"], {
check_title: "S3 bucket is public",
});
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.FINDING,
displayName: "S3 bucket is public",
description: "Prowler Finding",
fallbackUsed: false,
});
});
it("should resolve finding icons from severity", () => {
// Given
const findingNodes = [
{ severity: "critical", Icon: Siren },
{ severity: "high", Icon: AlertTriangle },
{ severity: "medium", Icon: CircleAlert },
{ severity: "low", Icon: Info },
{ severity: "informational", Icon: Info },
];
for (const findingNode of findingNodes) {
// When
const visual = resolveNodeVisual(
buildNode(["ProwlerFinding"], {
check_title: `${findingNode.severity} finding`,
severity: findingNode.severity,
}),
);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.FINDING,
description: "Prowler Finding",
fallbackUsed: false,
});
expect(visual.Icon).toBe(findingNode.Icon);
}
});
it("should use the generic alert icon when finding severity is unknown", () => {
// Given
const node = buildNode(["ProwlerFinding"], {
check_title: "Unknown risk",
severity: "unknown",
});
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual.Icon).toBe(AlertTriangle);
});
it("should resolve Internet nodes to internet metadata", () => {
// Given
const node = buildNode(["Internet"]);
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.INTERNET,
displayName: "Internet",
description: "Internet",
fallbackUsed: false,
});
});
});
describe("alias and normalized mappings", () => {
it("should resolve IAMUser nodes to identity metadata with the AWS IAM icon", () => {
// Given
const node = buildNode(["IAMUser"], { name: "alice" });
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.IDENTITY,
displayName: "alice",
description: "IAM User",
fallbackUsed: false,
});
expect(visual.Icon).toBe(AWSIAMIcon);
});
it("should resolve case-insensitive AccessKey labels to secret metadata", () => {
// Given
const node = buildNode(["access_key"], { id: "AKIA123" });
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.SECRET,
displayName: "AKIA123",
description: "Access Key",
fallbackUsed: false,
});
});
it("should resolve AWS identity and policy labels to distinct icons", () => {
// Given
const identityNodes = [
{
label: "AWSUser",
description: "AWS User",
Icon: UserCog,
},
{
label: "AWSManagedPolicy",
description: "AWS Managed Policy",
Icon: FileKey2,
},
{
label: "AWSPolicyStatement",
description: "AWS Policy Statement",
Icon: Braces,
},
{
label: "PermissionRole",
description: "Permission Role",
Icon: ShieldCheck,
},
];
for (const identityNode of identityNodes) {
// When
const visual = resolveNodeVisual(
buildNode([identityNode.label], { name: identityNode.description }),
);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.IDENTITY,
displayName: identityNode.description,
description: identityNode.description,
fallbackUsed: false,
});
expect(visual.Icon).toBe(identityNode.Icon);
}
});
it("should resolve all AWS labels used by predefined Attack Paths queries", () => {
// Given
const awsQueryNodes = [
{
label: "AWSTag",
category: NODE_CATEGORY.MISC,
description: "AWS Tag",
Icon: Tags,
},
{
label: "EC2SecurityGroup",
category: NODE_CATEGORY.NETWORK,
description: "EC2 Security Group",
Icon: Shield,
},
{
label: "IpPermissionInbound",
category: NODE_CATEGORY.NETWORK,
description: "Inbound IP Permission",
Icon: Shield,
},
{
label: "IpRange",
category: NODE_CATEGORY.NETWORK,
description: "IP Range",
Icon: Globe2,
},
{
label: "AWSPrincipal",
category: NODE_CATEGORY.IDENTITY,
description: "AWS Principal",
Icon: ShieldCheck,
},
{
label: "AWSGroup",
category: NODE_CATEGORY.IDENTITY,
description: "AWS Group",
Icon: Users,
},
{
label: "RDSInstance",
category: NODE_CATEGORY.STORAGE,
description: "RDS Instance",
Icon: AmazonRDSIcon,
},
{
label: "LoadBalancer",
category: NODE_CATEGORY.NETWORK,
description: "Load Balancer",
Icon: Route,
},
{
label: "ELBListener",
category: NODE_CATEGORY.NETWORK,
description: "ELB Listener",
Icon: Route,
},
{
label: "LoadBalancerV2",
category: NODE_CATEGORY.NETWORK,
description: "Load Balancer V2",
Icon: Route,
},
{
label: "ELBV2Listener",
category: NODE_CATEGORY.NETWORK,
description: "ELB V2 Listener",
Icon: Route,
},
{
label: "ElasticIPAddress",
category: NODE_CATEGORY.NETWORK,
description: "Elastic IP Address",
Icon: Globe2,
},
{
label: "EC2PrivateIp",
category: NODE_CATEGORY.NETWORK,
description: "EC2 Private IP",
Icon: Waypoints,
},
{
label: "NetworkInterface",
category: NODE_CATEGORY.NETWORK,
description: "Network Interface",
Icon: Waypoints,
},
{
label: "LaunchTemplate",
category: NODE_CATEGORY.COMPUTE,
description: "Launch Template",
Icon: Server,
},
{
label: "AWSLambda",
category: NODE_CATEGORY.COMPUTE,
description: "AWS Lambda",
Icon: AWSLambdaIcon,
},
{
label: "AWSSageMakerNotebookInstance",
category: NODE_CATEGORY.COMPUTE,
description: "SageMaker Notebook Instance",
Icon: Bot,
},
];
for (const awsQueryNode of awsQueryNodes) {
// When
const visual = resolveNodeVisual(
buildNode([awsQueryNode.label], { name: awsQueryNode.description }),
);
// Then
expect(visual).toMatchObject({
category: awsQueryNode.category,
displayName: awsQueryNode.description,
description: awsQueryNode.description,
fallbackUsed: false,
});
expect(visual.Icon).toBe(awsQueryNode.Icon);
}
});
});
describe("fallback behavior", () => {
it("should use formatted labels for unknown nodes and mark the fallback", () => {
// Given
const node = buildNode(["CustomGraphNode"]);
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.MISC,
displayName: "Custom Graph Node",
description: "Custom Graph Node",
fallbackUsed: true,
});
});
});
});
@@ -0,0 +1,522 @@
import {
AlertTriangle,
Bot,
Box,
Braces,
CircleAlert,
FileKey2,
Globe2,
Info,
KeyRound,
Route,
Server,
Shield,
ShieldCheck,
Siren,
Tags,
UserCog,
Users,
Waypoints,
} from "lucide-react";
import type { ElementType } from "react";
import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
import {
AmazonEC2Icon,
AmazonRDSIcon,
AmazonS3Icon,
AmazonVPCIcon,
AWSIAMIcon,
AWSLambdaIcon,
} from "@/components/icons/services/IconServices";
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
import { formatNodeLabel } from "./format";
export const NODE_CATEGORY = {
FINDING: "finding",
INTERNET: "internet",
ACCOUNT: "account",
STORAGE: "storage",
NETWORK: "network",
COMPUTE: "compute",
IDENTITY: "identity",
SECRET: "secret",
MISC: "misc",
} as const;
export type NodeCategory = (typeof NODE_CATEGORY)[keyof typeof NODE_CATEGORY];
interface KnownNodeVisualMapping {
category: NodeCategory;
description: string;
Icon: ElementType;
}
export interface NodeVisual extends KnownNodeVisualMapping {
displayName: string;
fallbackUsed: boolean;
}
const KNOWN_NODE_VISUALS = {
awsaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "AWS Account",
Icon: AWSProviderBadge,
},
azureaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Azure Account",
Icon: AzureProviderBadge,
},
azuretenant: {
category: NODE_CATEGORY.ACCOUNT,
description: "Azure Tenant",
Icon: AzureProviderBadge,
},
gcpaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Google Cloud Account",
Icon: GCPProviderBadge,
},
gcpproject: {
category: NODE_CATEGORY.ACCOUNT,
description: "Google Cloud Project",
Icon: GCPProviderBadge,
},
googlecloudaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Google Cloud Account",
Icon: GCPProviderBadge,
},
kubernetescluster: {
category: NODE_CATEGORY.ACCOUNT,
description: "Kubernetes Cluster",
Icon: KS8ProviderBadge,
},
k8scluster: {
category: NODE_CATEGORY.ACCOUNT,
description: "Kubernetes Cluster",
Icon: KS8ProviderBadge,
},
githubaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "GitHub Account",
Icon: GitHubProviderBadge,
},
githuborganization: {
category: NODE_CATEGORY.ACCOUNT,
description: "GitHub Organization",
Icon: GitHubProviderBadge,
},
m365tenant: {
category: NODE_CATEGORY.ACCOUNT,
description: "Microsoft 365 Tenant",
Icon: M365ProviderBadge,
},
googleworkspace: {
category: NODE_CATEGORY.ACCOUNT,
description: "Google Workspace",
Icon: GoogleWorkspaceProviderBadge,
},
iac: {
category: NODE_CATEGORY.ACCOUNT,
description: "Infrastructure as Code",
Icon: IacProviderBadge,
},
containerregistry: {
category: NODE_CATEGORY.ACCOUNT,
description: "Container Registry",
Icon: ImageProviderBadge,
},
oraclecloudaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Oracle Cloud Account",
Icon: OracleCloudProviderBadge,
},
mongodbatlas: {
category: NODE_CATEGORY.ACCOUNT,
description: "MongoDB Atlas",
Icon: MongoDBAtlasProviderBadge,
},
alibabacloudaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Alibaba Cloud Account",
Icon: AlibabaCloudProviderBadge,
},
cloudflareaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Cloudflare Account",
Icon: CloudflareProviderBadge,
},
openstackaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "OpenStack Account",
Icon: OpenStackProviderBadge,
},
vercelaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Vercel Account",
Icon: VercelProviderBadge,
},
s3bucket: {
category: NODE_CATEGORY.STORAGE,
description: "S3 Bucket",
Icon: AmazonS3Icon,
},
s3: {
category: NODE_CATEGORY.STORAGE,
description: "S3",
Icon: AmazonS3Icon,
},
vpc: {
category: NODE_CATEGORY.NETWORK,
description: "VPC",
Icon: AmazonVPCIcon,
},
subnet: {
category: NODE_CATEGORY.NETWORK,
description: "Subnet",
Icon: Waypoints,
},
securitygroup: {
category: NODE_CATEGORY.NETWORK,
description: "Security Group",
Icon: Shield,
},
ec2securitygroup: {
category: NODE_CATEGORY.NETWORK,
description: "EC2 Security Group",
Icon: Shield,
},
ippermissioninbound: {
category: NODE_CATEGORY.NETWORK,
description: "Inbound IP Permission",
Icon: Shield,
},
iprange: {
category: NODE_CATEGORY.NETWORK,
description: "IP Range",
Icon: Globe2,
},
elasticipaddress: {
category: NODE_CATEGORY.NETWORK,
description: "Elastic IP Address",
Icon: Globe2,
},
ec2privateip: {
category: NODE_CATEGORY.NETWORK,
description: "EC2 Private IP",
Icon: Waypoints,
},
networkinterface: {
category: NODE_CATEGORY.NETWORK,
description: "Network Interface",
Icon: Waypoints,
},
internetgateway: {
category: NODE_CATEGORY.NETWORK,
description: "Internet Gateway",
Icon: Route,
},
defaultgateway: {
category: NODE_CATEGORY.NETWORK,
description: "Default Gateway",
Icon: Route,
},
loadbalancer: {
category: NODE_CATEGORY.NETWORK,
description: "Load Balancer",
Icon: Route,
},
loadbalancerv2: {
category: NODE_CATEGORY.NETWORK,
description: "Load Balancer V2",
Icon: Route,
},
elblistener: {
category: NODE_CATEGORY.NETWORK,
description: "ELB Listener",
Icon: Route,
},
elbv2listener: {
category: NODE_CATEGORY.NETWORK,
description: "ELB V2 Listener",
Icon: Route,
},
ec2instance: {
category: NODE_CATEGORY.COMPUTE,
description: "EC2 Instance",
Icon: AmazonEC2Icon,
},
launchtemplate: {
category: NODE_CATEGORY.COMPUTE,
description: "Launch Template",
Icon: Server,
},
awslambda: {
category: NODE_CATEGORY.COMPUTE,
description: "AWS Lambda",
Icon: AWSLambdaIcon,
},
awssagemakernotebookinstance: {
category: NODE_CATEGORY.COMPUTE,
description: "SageMaker Notebook Instance",
Icon: Bot,
},
virtualmachine: {
category: NODE_CATEGORY.COMPUTE,
description: "Virtual Machine",
Icon: AmazonEC2Icon,
},
rdsinstance: {
category: NODE_CATEGORY.STORAGE,
description: "RDS Instance",
Icon: AmazonRDSIcon,
},
compute: {
category: NODE_CATEGORY.COMPUTE,
description: "Compute",
Icon: Server,
},
nic: {
category: NODE_CATEGORY.COMPUTE,
description: "NIC",
Icon: Server,
},
iamuser: {
category: NODE_CATEGORY.IDENTITY,
description: "IAM User",
Icon: AWSIAMIcon,
},
awsuser: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS User",
Icon: UserCog,
},
awsgroup: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Group",
Icon: Users,
},
awsprincipal: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Principal",
Icon: ShieldCheck,
},
iamrole: {
category: NODE_CATEGORY.IDENTITY,
description: "IAM Role",
Icon: AWSIAMIcon,
},
awsrole: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Role",
Icon: ShieldCheck,
},
permissionrole: {
category: NODE_CATEGORY.IDENTITY,
description: "Permission Role",
Icon: ShieldCheck,
},
awsmanagedpolicy: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Managed Policy",
Icon: FileKey2,
},
awspolicy: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Policy",
Icon: FileKey2,
},
policy: {
category: NODE_CATEGORY.IDENTITY,
description: "Policy",
Icon: FileKey2,
},
awspolicystatement: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Policy Statement",
Icon: Braces,
},
policystatement: {
category: NODE_CATEGORY.IDENTITY,
description: "Policy Statement",
Icon: Braces,
},
accesskey: {
category: NODE_CATEGORY.SECRET,
description: "Access Key",
Icon: KeyRound,
},
secret: {
category: NODE_CATEGORY.SECRET,
description: "Secret",
Icon: KeyRound,
},
serviceaccount: {
category: NODE_CATEGORY.IDENTITY,
description: "Service Account",
Icon: Bot,
},
awstag: {
category: NODE_CATEGORY.MISC,
description: "AWS Tag",
Icon: Tags,
},
} as const satisfies Record<string, KnownNodeVisualMapping>;
type KnownNodeLabel = keyof typeof KNOWN_NODE_VISUALS;
const normalizeLabel = (label: string): string =>
label.toLowerCase().replace(/[^a-z0-9]/g, "");
const isKnownNodeLabel = (label: string): label is KnownNodeLabel =>
label in KNOWN_NODE_VISUALS;
const isFindingLabel = (label: string): boolean =>
normalizeLabel(label).includes("finding");
const isInternetLabel = (label: string): boolean =>
normalizeLabel(label) === "internet";
const FINDING_SEVERITY = {
CRITICAL: "critical",
HIGH: "high",
MEDIUM: "medium",
LOW: "low",
INFO: "info",
INFORMATIONAL: "informational",
} as const;
type FindingSeverity = (typeof FINDING_SEVERITY)[keyof typeof FINDING_SEVERITY];
const FINDING_SEVERITY_ICONS = {
[FINDING_SEVERITY.CRITICAL]: Siren,
[FINDING_SEVERITY.HIGH]: AlertTriangle,
[FINDING_SEVERITY.MEDIUM]: CircleAlert,
[FINDING_SEVERITY.LOW]: Info,
[FINDING_SEVERITY.INFO]: Info,
[FINDING_SEVERITY.INFORMATIONAL]: Info,
} as const satisfies Record<FindingSeverity, ElementType>;
const stringifyProperty = (
value: GraphNodePropertyValue,
): string | undefined => {
if (value === null || value === undefined) return undefined;
if (Array.isArray(value)) return value.join(", ");
return String(value);
};
const firstDefinedProperty = (
node: GraphNode,
keys: string[],
): string | undefined => {
for (const key of keys) {
const value = stringifyProperty(node.properties[key]);
if (value) return value;
}
return undefined;
};
const getPrimaryFormattedLabel = (node: GraphNode): string => {
const primaryLabel = node.labels[0];
if (!primaryLabel) return "Unknown";
return formatNodeLabel(primaryLabel.replace(/[_-]/g, " "));
};
const resolveDisplayName = (node: GraphNode): string =>
firstDefinedProperty(node, ["name", "display_name", "title", "id"]) ??
getPrimaryFormattedLabel(node);
const resolveFindingDisplayName = (node: GraphNode): string =>
firstDefinedProperty(node, ["check_title", "title", "name", "id"]) ??
getPrimaryFormattedLabel(node);
const resolveFindingSeverity = (
node: GraphNode,
): FindingSeverity | undefined => {
const severity = firstDefinedProperty(node, ["severity"]);
if (!severity) return undefined;
const normalizedSeverity = severity.toLowerCase();
return normalizedSeverity in FINDING_SEVERITY_ICONS
? (normalizedSeverity as FindingSeverity)
: undefined;
};
const resolveFindingIcon = (node: GraphNode): ElementType => {
const severity = resolveFindingSeverity(node);
return severity ? FINDING_SEVERITY_ICONS[severity] : AlertTriangle;
};
const resolveKnownMapping = (
labels: string[],
): KnownNodeVisualMapping | undefined => {
for (const label of labels) {
const normalizedLabel = normalizeLabel(label);
if (isKnownNodeLabel(normalizedLabel)) {
return KNOWN_NODE_VISUALS[normalizedLabel];
}
}
return undefined;
};
export const resolveNodeVisual = (node: GraphNode): NodeVisual => {
if (node.labels.some(isFindingLabel)) {
return {
category: NODE_CATEGORY.FINDING,
displayName: resolveFindingDisplayName(node),
description: "Prowler Finding",
Icon: resolveFindingIcon(node),
fallbackUsed: false,
};
}
if (node.labels.some(isInternetLabel)) {
return {
category: NODE_CATEGORY.INTERNET,
displayName: "Internet",
description: "Internet",
Icon: Globe2,
fallbackUsed: false,
};
}
const knownMapping = resolveKnownMapping(node.labels);
if (knownMapping) {
return {
...knownMapping,
displayName: resolveDisplayName(node),
fallbackUsed: false,
};
}
const fallbackLabel = getPrimaryFormattedLabel(node);
return {
category: NODE_CATEGORY.MISC,
displayName: resolveDisplayName(node),
description: fallbackLabel,
Icon: Box,
fallbackUsed: true,
};
};
@@ -212,6 +212,186 @@ describe("exploring the graph", () => {
expect(graph.isInFilteredView).toBe(false);
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
expect(graph.hasNodeDetailsModal).toBe(false);
});
test("clicking a resource with findings opens the action selector", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.hasNodeDetailsModal).toBe(false);
await graph.clickFirstResourceNode();
expect(graph.hasNodeActionDialog).toBe(true);
expect(graph.hasNodeDetailsModal).toBe(false);
});
test("choosing Show findings reveals related finding nodes", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
await graph.chooseShowFindingsAction();
expect(graph.findingNodes.length).toBeGreaterThan(0);
expect(graph.hasNodeDetailsModal).toBe(false);
});
test("choosing Show findings re-fits around the resource and its findings", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
const initialViewport = graph.viewportTransform;
await graph.clickFirstResourceNode();
await graph.chooseShowFindingsAction();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitFor(
() => graph.viewportTransform !== initialViewport,
2000,
);
const contextualViewport = graph.viewportTransform;
await graph.fit();
await graph.waitFor(
() => graph.viewportTransform !== contextualViewport,
2000,
);
});
test("expanded resources offer Hide findings in the action selector", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
await graph.chooseShowFindingsAction();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.clickFirstResourceNode();
expect(graph.hasNodeActionDialog).toBe(true);
expect(graph.containsText(/Hide findings/i)).toBe(true);
await graph.chooseHideFindingsAction();
expect(graph.findingNodes.length).toBe(0);
});
test("choosing Hide findings re-fits the remaining visible graph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
await graph.chooseShowFindingsAction();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitForTransition();
const expandedViewport = graph.viewportTransform;
await graph.clickFirstResourceNode();
await graph.chooseHideFindingsAction();
expect(graph.findingNodes.length).toBe(0);
await graph.waitFor(
() => graph.viewportTransform !== expandedViewport,
2000,
);
});
test("returning from a finding keeps the expanded findings context fitted", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.large(20));
await graph.executeQuery();
await graph.waitForLayoutStable(16);
await graph.clickFirstResourceNode();
await graph.chooseShowFindingsAction();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitForTransition();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.exitFilteredView();
expect(graph.isInFilteredView).toBe(false);
await graph.waitForTransition();
const expandedContextViewport = graph.viewportTransform;
await graph.fit();
await graph.waitFor(
() => graph.viewportTransform !== expandedContextViewport,
2000,
);
});
test("choosing View node details opens node details in a modal", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
await graph.chooseViewNodeDetailsAction();
expect(graph.hasNodeDetailsModal).toBe(true);
});
test("clicking a resource without findings opens node details in a modal", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.hasNodeDetailsModal).toBe(false);
await graph.clickFirstResourceNodeWithoutFindings();
expect(graph.hasNodeDetailsModal).toBe(true);
});
test("clicking a parent node in filtered view asks whether to go back or view details", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.clickFirstResourceNode();
expect(graph.hasNodeActionDialog).toBe(true);
expect(graph.containsText(/Back to full graph/i)).toBe(true);
await graph.chooseBackToFullGraphAction();
expect(graph.isInFilteredView).toBe(false);
});
test("exiting the filtered view restores the full graph", async ({
@@ -294,6 +474,14 @@ describe("auto-fitting the viewport", () => {
await graph.executeQuery();
await graph.waitForLayoutStable(3);
// Given - zoom into the current overview so newly revealed findings can
// sit entirely outside the current frame. The expand auto-fit should then
// recover the user instead of leaving them hunting off-screen.
for (let i = 0; i < 5; i++) {
await graph.zoomIn();
await graph.waitForTransition(80);
}
// Hidden findings are not measured by the initial declarative fit, so
// their positions can sit outside the framed viewport. Expanding the
// resources should re-fit so the user does not have to hunt for the
@@ -231,6 +231,33 @@ export class AttackPathPageHarness {
return document.querySelector<HTMLElement>('[role="dialog"]');
}
get nodeDetailsHeading(): HTMLElement | null {
return (
Array.from(
document.querySelectorAll<HTMLElement>("[role='dialog'] h2"),
).find((heading) => /^Node Details$/i.test(heading.textContent ?? "")) ??
null
);
}
get hasNodeDetailsModal(): boolean {
return !!this.nodeDetailsHeading;
}
get nodeActionHeading(): HTMLElement | null {
return (
Array.from(
document.querySelectorAll<HTMLElement>("[role='dialog'] h2"),
).find((heading) =>
/^Choose node action$/i.test(heading.textContent ?? ""),
) ?? null
);
}
get hasNodeActionDialog(): boolean {
return !!this.nodeActionHeading;
}
// --- Sync helpers ---
/** Wait until React Flow has rendered at least `expected` node elements. */
@@ -341,6 +368,33 @@ export class AttackPathPageHarness {
return resource;
}
async clickFirstResourceNodeWithoutFindings(): Promise<HTMLElement> {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
.filter((n) =>
n.labels.some((l) => l.toLowerCase().includes("finding")),
)
.map((n) => n.id),
);
const resourceWithFindingIds = new Set<string>();
for (const rel of this.fixture.queryResult?.relationships ?? []) {
if (findingIds.has(rel.source)) resourceWithFindingIds.add(rel.target);
if (findingIds.has(rel.target)) resourceWithFindingIds.add(rel.source);
}
const resource = this.resourceNodes.find((node) => {
const id = node.getAttribute("data-id");
return id && !resourceWithFindingIds.has(id);
});
if (!resource) {
throw new Error(
"clickFirstResourceNodeWithoutFindings: no resource without findings rendered",
);
}
await this.user.click(resource);
await this.waitForTransition();
return resource;
}
/**
* Click the first finding `times` times back-to-back, with no transition
* waits between clicks. Used for rapid-click race tests.
@@ -399,6 +453,9 @@ export class AttackPathPageHarness {
if (el) {
await this.user.click(el);
await this.waitForTransition(50);
if (this.hasNodeActionDialog) {
await this.chooseShowFindingsAction();
}
}
}
}
@@ -444,6 +501,51 @@ export class AttackPathPageHarness {
await this.user.click(btn);
}
async closeNodeDetailsModal(): Promise<void> {
const btn = this.q('button[aria-label="Close node details"]');
if (!btn) throw new Error("closeNodeDetailsModal: modal not rendered");
await this.user.click(btn);
await this.waitForTransition();
}
async chooseShowFindingsAction(): Promise<void> {
const button = Array.from(
document.querySelectorAll<HTMLButtonElement>("button"),
).find((btn) => /show findings/i.test(btn.textContent ?? ""));
if (!button) throw new Error("chooseShowFindingsAction: button not found");
await this.user.click(button);
await this.waitForTransition();
}
async chooseHideFindingsAction(): Promise<void> {
const button = Array.from(
document.querySelectorAll<HTMLButtonElement>("button"),
).find((btn) => /hide findings/i.test(btn.textContent ?? ""));
if (!button) throw new Error("chooseHideFindingsAction: button not found");
await this.user.click(button);
await this.waitForTransition();
}
async chooseViewNodeDetailsAction(): Promise<void> {
const button = Array.from(
document.querySelectorAll<HTMLButtonElement>("button"),
).find((btn) => /view node details/i.test(btn.textContent ?? ""));
if (!button)
throw new Error("chooseViewNodeDetailsAction: button not found");
await this.user.click(button);
await this.waitForTransition();
}
async chooseBackToFullGraphAction(): Promise<void> {
const button = Array.from(
document.querySelectorAll<HTMLButtonElement>("button"),
).find((btn) => /back to full graph/i.test(btn.textContent ?? ""));
if (!button)
throw new Error("chooseBackToFullGraphAction: button not found");
await this.user.click(button);
await this.waitForTransition();
}
async exitFilteredView(): Promise<void> {
const btn = this.toolbar.backToFullViewButton;
if (!btn) throw new Error("exitFilteredView: not in filtered view");
@@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
import { ArrowLeft, Info, Maximize2 } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
@@ -22,8 +22,6 @@ import {
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
} from "@/components/shadcn";
import {
Dialog,
@@ -33,9 +31,8 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/shadcn/dialog";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { Modal } from "@/components/shadcn/modal/modal";
import { useToast } from "@/components/ui";
import { cn } from "@/lib/utils";
import type {
AttackPathQuery,
AttackPathQueryError,
@@ -50,7 +47,7 @@ import {
GraphControls,
GraphLegend,
GraphLoading,
NodeDetailContent,
NodeDetailPanel as NodeDetailDrawer,
QueryDescription,
QueryExecutionError,
QueryParametersForm,
@@ -62,98 +59,28 @@ import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsPNG } from "./_lib";
const getNodeDisplayTitle = (node: GraphNode): string => {
const isFinding = node.labels.some((l) =>
l.toLowerCase().includes("finding"),
);
return String(
isFinding
? node.properties?.check_title || node.properties?.id || "Unknown Finding"
: node.properties?.name || node.properties?.id || "Unknown Resource",
);
};
const NODE_ACTION_CONTEXT = {
RESOURCE_FINDINGS: "resource-findings",
FILTERED_PARENT: "filtered-parent",
} as const;
interface NodeDetailPanelProps {
type NodeActionContext =
(typeof NODE_ACTION_CONTEXT)[keyof typeof NODE_ACTION_CONTEXT];
interface NodeActionBase {
node: GraphNode;
allNodes: GraphNode[];
onClose: () => void;
headingId: string;
compact?: boolean;
onViewFinding?: (findingId: string) => void;
viewFindingLoading?: boolean;
context: NodeActionContext;
}
const NodeDetailPanel = ({
node,
allNodes,
onClose,
headingId,
compact,
onViewFinding,
viewFindingLoading = false,
}: NodeDetailPanelProps) => {
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const findingId = String(node.properties?.id || node.id);
interface ResourceFindingsNodeAction extends NodeActionBase {
context: typeof NODE_ACTION_CONTEXT.RESOURCE_FINDINGS;
}
return (
<>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3
id={headingId}
className={
compact ? "text-sm font-semibold" : "text-lg font-semibold"
}
>
Node Details
</h3>
<p
className={cn(
"text-text-neutral-secondary",
compact ? "mb-4 text-xs" : "mt-1 text-sm",
)}
>
{getNodeDisplayTitle(node)}
</p>
</div>
<div className="flex items-center gap-2">
{!compact && isFinding && onViewFinding && (
<Button
variant="default"
size="sm"
onClick={() => onViewFinding(findingId)}
disabled={viewFindingLoading}
aria-label={`View finding ${findingId}`}
>
{viewFindingLoading ? (
<Spinner className="size-4" />
) : (
"View Finding"
)}
</Button>
)}
<Button
onClick={onClose}
variant="ghost"
size="sm"
className={compact ? "h-6 w-6 p-0" : "h-8 w-8 p-0"}
aria-label="Close node details"
>
<X size={16} />
</Button>
</div>
</div>
<NodeDetailContent
node={node}
allNodes={allNodes}
onViewFinding={onViewFinding}
viewFindingLoading={viewFindingLoading}
/>
</>
);
};
interface FilteredParentNodeAction extends NodeActionBase {
context: typeof NODE_ACTION_CONTEXT.FILTERED_PARENT;
}
type NodeActionState = ResourceFindingsNodeAction | FilteredParentNodeAction;
/**
* Attack Paths
@@ -171,10 +98,10 @@ export default function AttackPathsPage() {
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const [nodeAction, setNodeAction] = useState<NodeActionState | null>(null);
const graphRef = useRef<GraphHandle>(null);
const fullscreenGraphRef = useRef<GraphHandle>(null);
const hasResetRef = useRef(false);
const nodeDetailsRef = useRef<HTMLDivElement>(null);
const graphContainerRef = useRef<HTMLDivElement>(null);
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
@@ -385,27 +312,44 @@ export default function AttackPathsPage() {
};
const handleNodeClick = (node: GraphNode) => {
// Always select the node (opens detail panel)
graphState.selectNode(node.id);
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
// Tier 2: clicking a finding node OR any node in filtered view → enter filtered view
if (isFinding || graphState.isFilteredView) {
if (isFinding) {
// Findings skip the intermediate node-details modal. The finding drawer
// is the useful destination, so open it directly from the graph click.
graphState.enterFilteredView(node.id);
// enterFilteredView stores the filtered node as selected so the graph can
// highlight it. Clear the selection right after for findings so the node
// details modal does not open before the finding drawer.
graphState.selectNode(null);
handleViewFinding(String(node.properties?.id || node.id));
return;
}
// Scroll to details section for findings
if (isFinding) {
setTimeout(() => {
nodeDetailsRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}, 100);
if (graphState.isFilteredView) {
setNodeAction({ node, context: NODE_ACTION_CONTEXT.FILTERED_PARENT });
return;
}
const sourceData = graphState.fullData || graphState.data;
const hasFindings = sourceData?.edges?.some((edge) => {
if (edge.source !== node.id && edge.target !== node.id) return false;
const otherId = edge.source === node.id ? edge.target : edge.source;
const otherNode = sourceData.nodes?.find(({ id }) => id === otherId);
return otherNode?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
});
if (hasFindings) {
setNodeAction({ node, context: NODE_ACTION_CONTEXT.RESOURCE_FINDINGS });
return;
}
finding.resetFindingDetails();
graphState.selectNode(node.id);
};
const handleBackToFullView = () => {
@@ -416,11 +360,34 @@ export default function AttackPathsPage() {
graphState.selectNode(null);
};
const handleShowNodeFindings = () => {
if (!nodeAction) return;
graphState.toggleExpandedResource(nodeAction.node.id);
setNodeAction(null);
};
const handleOpenNodeDetails = () => {
if (!nodeAction) return;
finding.resetFindingDetails();
graphState.selectNode(nodeAction.node.id);
setNodeAction(null);
};
const handleReturnToFullGraph = () => {
graphState.exitFilteredView();
graphState.selectNode(null);
setNodeAction(null);
};
const handleViewFinding = (findingId: string) => {
if (!findingId) return;
void finding.navigateToFinding(findingId);
};
const actionNodeFindingsExpanded = nodeAction
? graphState.expandedResources.has(nodeAction.node.id)
: false;
const handleGraphExport = async (target: "main" | "fullscreen") => {
const ref = target === "fullscreen" ? fullscreenGraphRef : graphRef;
const handle = ref.current;
@@ -674,34 +641,8 @@ export default function AttackPathsPage() {
expandedResources={
graphState.expandedResources
}
onResourceToggle={
graphState.toggleExpandedResource
}
/>
</div>
{/* Node Detail Panel - Side by side */}
{graphState.selectedNode && graphState.data && (
<section
aria-labelledby="fullscreen-node-details-heading"
className="w-full overflow-y-auto lg:w-96"
>
<Card>
<CardContent className="p-4">
<NodeDetailPanel
node={graphState.selectedNode}
allNodes={graphState.data.nodes}
onClose={handleCloseDetails}
onViewFinding={handleViewFinding}
viewFindingLoading={
finding.findingDetailLoading
}
headingId="fullscreen-node-details-heading"
compact
/>
</CardContent>
</Card>
</section>
)}
</div>
</DialogContent>
</Dialog>
@@ -721,34 +662,64 @@ export default function AttackPathsPage() {
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
expandedResources={graphState.expandedResources}
onResourceToggle={graphState.toggleExpandedResource}
/>
</div>
{/* Legend below */}
<div className="flex justify-center overflow-x-auto">
<GraphLegend data={graphState.data} />
<GraphLegend
data={graphState.data}
expandedResources={graphState.expandedResources}
isFilteredView={graphState.isFilteredView}
/>
</div>
</>
) : null}
</div>
)}
{/* Node Detail Panel - Below Graph */}
{graphState.selectedNode && graphState.data && (
<div
ref={nodeDetailsRef}
className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4"
{/* Node Detail Drawer */}
{graphState.data && (
<NodeDetailDrawer
node={graphState.selectedNode}
allNodes={graphState.data.nodes}
onClose={handleCloseDetails}
onViewFinding={handleViewFinding}
viewFindingLoading={finding.findingDetailLoading}
/>
)}
{nodeAction && (
<Modal
open={!!nodeAction}
onOpenChange={(open) => {
if (!open) setNodeAction(null);
}}
size="md"
title="Choose node action"
description={
nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT
? "You're viewing a filtered path. Choose whether to return to the full graph or inspect this node."
: "This node has related findings. Choose whether to reveal them in the graph or inspect the node metadata."
}
>
<NodeDetailPanel
node={graphState.selectedNode}
allNodes={graphState.data.nodes}
onClose={handleCloseDetails}
onViewFinding={handleViewFinding}
viewFindingLoading={finding.findingDetailLoading}
headingId="node-details-heading"
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleOpenNodeDetails}>
View node details
</Button>
{nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT ? (
<Button onClick={handleReturnToFullGraph}>
Back to full graph
</Button>
) : (
<Button onClick={handleShowNodeFindings}>
{actionNodeFindingsExpanded
? "Hide findings"
: "Show findings"}
</Button>
)}
</div>
</Modal>
)}
{finding.findingDetails && (