feat(ui): improve attack paths graph exploration

This commit is contained in:
Alan Buscaglia
2026-05-05 21:28:26 +02:00
parent c5aa4ced0d
commit 33ad74b53c
4 changed files with 615 additions and 518 deletions
@@ -206,15 +206,8 @@ const GraphCanvas = ({
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);
@@ -264,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);
@@ -290,6 +278,7 @@ const GraphCanvas = ({
return;
}
if (previousFilteredRef.current === isFilteredView) return;
const wasFilteredView = previousFilteredRef.current;
previousFilteredRef.current = isFilteredView;
// React Flow measures node sizes asynchronously via ResizeObserver after
// the data swap. A single rAF runs while measured.width is still 0, so
@@ -305,9 +294,30 @@ const GraphCanvas = ({
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
);
},
() => fitView(AUTO_FIT_OPTIONS),
() => {
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);
},
);
}, [isFilteredView, fitView, getNodes]);
}, [expanded, isFilteredView, fitView, getNodes]);
useEffect(() => {
const previous = previousExpandedRef.current;
@@ -316,22 +326,40 @@ const GraphCanvas = ({
const newResourceIds = Array.from(expanded).filter(
(id) => !previous.has(id),
);
// Only fit on growth — collapsing intentionally leaves the user's
// current framing alone.
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;
// Findings transition from hidden to visible on expand, and React Flow
// measures them asynchronously. Poll before checking whether their full
// bounding boxes sit entirely past a viewport edge; collapsing and
// partially clipped findings preserve the user's current frame.
// 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));
@@ -341,27 +369,13 @@ const GraphCanvas = ({
);
},
() => {
const targets = getNodes().filter((n) => newFindingIds.has(n.id));
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 = targets.some((node) => {
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);
const contextualNodes = getNodes().filter((n) =>
contextualFitNodeIds.has(n.id),
);
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
},
);
}, [expanded, fitView, getNodes, getViewport]);
}, [expanded, fitView, getNodes]);
const nodes = effectiveData.nodes ?? [];
const edges = effectiveData.edges ?? [];
@@ -0,0 +1,88 @@
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" },
},
],
};
describe("GraphLegend", () => {
it("should explain graph visuals by semantic groups instead of repeating node labels", () => {
// Given - A graph with provider, resource, and finding nodes
// When
render(<GraphLegend data={graphData} />);
// Then
expect(
screen.getByRole("heading", { name: /provider roots/i }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /resource categories/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("Storage")).toBeInTheDocument();
expect(screen.getByText("Network")).toBeInTheDocument();
expect(screen.getByText("Compute")).toBeInTheDocument();
expect(screen.getByText("Identity")).toBeInTheDocument();
expect(screen.getByText("Secret / misc")).toBeInTheDocument();
expect(screen.getByText("Critical")).toBeInTheDocument();
expect(screen.getByText("High")).toBeInTheDocument();
expect(screen.getByText("Medium")).toBeInTheDocument();
expect(screen.getByText("Low / Info")).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("S3 Bucket")).not.toBeInTheDocument();
expect(screen.queryByText("VPC")).not.toBeInTheDocument();
expect(screen.queryByText(/ctrl/i)).not.toBeInTheDocument();
expect(screen.queryByText(/scroll to zoom/i)).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();
});
});
@@ -1,6 +1,7 @@
"use client";
import { useTheme } from "next-themes";
import type { ElementType, ReactNode } from "react";
import { Card, CardContent } from "@/components/shadcn";
import {
@@ -9,513 +10,425 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import type { AttackPathGraphData } from "@/types/attack-paths";
import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths";
import {
getNodeBorderColor,
getNodeColor,
GRAPH_ALERT_BORDER_COLOR,
GRAPH_EDGE_COLOR_DARK,
GRAPH_EDGE_COLOR_LIGHT,
GRAPH_EDGE_HIGHLIGHT_COLOR,
GRAPH_NODE_BORDER_COLORS,
GRAPH_NODE_COLORS,
} from "../../_lib/graph-colors";
import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals";
interface LegendItem {
const LEGEND_PREVIEW = {
BADGE_RADIUS: 16,
BADGE_CENTER: 18,
ICON_SIZE: 20,
ICON_OFFSET: 8,
SVG_SIZE: 36,
} as const;
const EDGE_VARIANT = {
NORMAL: "normal",
FINDING: "finding",
HIGHLIGHTED: "highlighted",
} as const;
type EdgeVariant = (typeof EDGE_VARIANT)[keyof typeof EDGE_VARIANT];
interface LegendVisualItem {
label: string;
color: string;
borderColor: string;
description: string;
shape: "rectangle" | "hexagon" | "cloud";
Icon: ElementType;
fillColor: string;
borderColor: string;
glow?: boolean;
}
// Map node labels to human-readable names and descriptions
const nodeTypeDescriptions: Record<
string,
{ name: string; description: string }
> = {
// Findings
ProwlerFinding: {
name: "Finding",
description: "Security findings from Prowler scans",
},
// AWS Account
AWSAccount: {
name: "AWS Account",
description: "AWS account root node",
},
// Compute
EC2Instance: {
name: "EC2 Instance",
description: "Elastic Compute Cloud instance",
},
LambdaFunction: {
name: "Lambda Function",
description: "AWS Lambda serverless function",
},
// Storage
S3Bucket: {
name: "S3 Bucket",
description: "Simple Storage Service bucket",
},
// IAM
IAMRole: {
name: "IAM Role",
description: "Identity and Access Management role",
},
IAMPolicy: {
name: "IAM Policy",
description: "Identity and Access Management policy",
},
AWSRole: {
name: "AWS Role",
description: "AWS IAM role",
},
AWSPolicy: {
name: "AWS Policy",
description: "AWS IAM policy",
},
AWSInlinePolicy: {
name: "AWS Inline Policy",
description: "AWS IAM inline policy",
},
AWSPolicyStatement: {
name: "AWS Policy Statement",
description: "AWS IAM policy statement",
},
AWSPrincipal: {
name: "AWS Principal",
description: "AWS IAM principal entity",
},
// Networking
SecurityGroup: {
name: "Security Group",
description: "AWS security group for network access control",
},
EC2SecurityGroup: {
name: "EC2 Security Group",
description: "EC2 security group for network access control",
},
IpPermissionInbound: {
name: "IP Permission Inbound",
description: "Inbound IP permission rule",
},
IpRule: {
name: "IP Rule",
description: "IP address rule",
},
Internet: {
name: "Internet",
description: "Internet gateway or public access",
},
// Tags
AWSTag: {
name: "AWS Tag",
description: "AWS resource tag",
},
Tag: {
name: "Tag",
description: "Resource tag",
},
};
/**
* Extract unique node types from graph data
*/
function extractNodeTypes(
nodes: AttackPathGraphData["nodes"] | undefined,
): string[] {
if (!nodes) return [];
const nodeTypes = new Set<string>();
nodes.forEach((node) => {
node.labels.forEach((label) => {
nodeTypes.add(label);
});
});
return Array.from(nodeTypes).sort();
interface LegendStateItem {
label: string;
description: string;
fillColor: string;
borderColor: string;
strokeWidth: number;
glowColor?: string;
}
/**
* Severity legend items - colors work in both light and dark themes
*/
const severityLegendItems: LegendItem[] = [
{
label: "Critical",
color: GRAPH_NODE_COLORS.critical,
borderColor: GRAPH_NODE_BORDER_COLORS.critical,
description: "Critical severity finding",
shape: "hexagon",
},
{
label: "High",
color: GRAPH_NODE_COLORS.high,
borderColor: GRAPH_NODE_BORDER_COLORS.high,
description: "High severity finding",
shape: "hexagon",
},
{
label: "Medium",
color: GRAPH_NODE_COLORS.medium,
borderColor: GRAPH_NODE_BORDER_COLORS.medium,
description: "Medium severity finding",
shape: "hexagon",
},
{
label: "Low",
color: GRAPH_NODE_COLORS.low,
borderColor: GRAPH_NODE_BORDER_COLORS.low,
description: "Low severity finding",
shape: "hexagon",
},
];
/**
* Generate legend items from graph data
*/
function generateLegendItems(
nodeTypes: string[],
hasFindings: boolean,
): LegendItem[] {
const items: LegendItem[] = [];
const seenTypes = new Set<string>();
// Add severity items if there are findings
if (hasFindings) {
items.push(...severityLegendItems);
}
// Helper to format unknown node types (e.g., "AWSPolicyStatement" -> "AWS Policy Statement")
const formatNodeTypeName = (nodeType: string): string => {
return nodeType
.replace(/([A-Z])/g, " $1") // Add space before capitals
.replace(/^ /, "") // Remove leading space
.replace(/AWS /g, "AWS ") // Keep AWS together
.replace(/EC2 /g, "EC2 ") // Keep EC2 together
.replace(/S3 /g, "S3 ") // Keep S3 together
.replace(/IAM /g, "IAM ") // Keep IAM together
.replace(/IP /g, "IP ") // Keep IP together
.trim();
};
nodeTypes.forEach((nodeType) => {
if (seenTypes.has(nodeType)) return;
seenTypes.add(nodeType);
// Skip findings - we show severity colors instead
const isFinding = nodeType.toLowerCase().includes("finding");
if (isFinding) return;
const description = nodeTypeDescriptions[nodeType];
// Determine shape based on node type
const isInternet = nodeType.toLowerCase() === "internet";
const shape: "rectangle" | "hexagon" | "cloud" = isInternet
? "cloud"
: "rectangle";
if (description) {
items.push({
label: description.name,
color: getNodeColor([nodeType]),
borderColor: getNodeBorderColor([nodeType]),
description: description.description,
shape,
});
} else {
// Format unknown node types nicely
const formattedName = formatNodeTypeName(nodeType);
items.push({
label: formattedName,
color: getNodeColor([nodeType]),
borderColor: getNodeBorderColor([nodeType]),
description: `${formattedName} node`,
shape,
});
}
});
return items;
interface LegendEdgeItem {
label: string;
description: string;
variant: EdgeVariant;
}
/**
* Hexagon shape component for legend
*/
const HexagonShape = ({
color,
borderColor,
}: {
color: string;
borderColor: string;
}) => (
<svg width="32" height="22" viewBox="0 0 32 22" aria-hidden="true">
<defs>
<filter id="legendGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<path
d="M5 1 L27 1 L31 11 L27 21 L5 21 L1 11 Z"
fill={color}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={1.5}
filter="url(#legendGlow)"
/>
</svg>
);
interface LegendSectionProps {
title: string;
children: ReactNode;
}
/**
* Pill shape component for legend
*/
const PillShape = ({
color,
borderColor,
}: {
color: string;
borderColor: string;
}) => (
<svg width="36" height="20" viewBox="0 0 36 20" aria-hidden="true">
<defs>
<filter id="legendGlow2" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<rect
x="1"
y="1"
width="34"
height="18"
rx="9"
ry="9"
fill={color}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={1.5}
filter="url(#legendGlow2)"
/>
</svg>
);
/**
* Globe shape component for legend (used for Internet nodes)
*/
const GlobeShape = ({
color,
borderColor,
}: {
color: string;
borderColor: string;
}) => (
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<defs>
<filter id="legendGlow3" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Globe circle */}
<circle
cx="12"
cy="12"
r="10"
fill={color}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={1.5}
filter="url(#legendGlow3)"
/>
{/* Horizontal line */}
<ellipse
cx="12"
cy="12"
rx="10"
ry="4"
fill="none"
stroke={borderColor}
strokeWidth={1}
strokeOpacity={0.6}
/>
{/* Vertical ellipse */}
<ellipse
cx="12"
cy="12"
rx="4"
ry="10"
fill="none"
stroke={borderColor}
strokeWidth={1}
strokeOpacity={0.6}
/>
</svg>
);
/**
* Edge line component for legend
*/
const EdgeLine = ({
dashed,
edgeColor,
}: {
dashed: boolean;
edgeColor: string;
}) => (
<svg
width="60"
height="20"
viewBox="0 0 60 20"
aria-hidden="true"
style={{ overflow: "visible" }}
>
{/* Line */}
<line
x1="4"
y1="10"
x2="44"
y2="10"
stroke={edgeColor}
strokeWidth={3}
strokeLinecap="round"
strokeDasharray={dashed ? "8,6" : undefined}
/>
{/* Arrow head */}
<polygon points="44,5 56,10 44,15" fill={edgeColor} />
</svg>
);
interface LegendItemProps {
label: string;
description: string;
children: ReactNode;
}
interface GraphLegendProps {
data?: AttackPathGraphData;
}
const buildNode = (
labels: string[],
properties: GraphNode["properties"] = {},
): GraphNode => ({
id: labels[0] ?? "legend-node",
labels,
properties,
});
const buildVisualItem = (
label: string,
description: string,
node: GraphNode,
fillColor: string,
borderColor: string,
glow = false,
): LegendVisualItem => ({
label,
description,
Icon: resolveNodeVisual(node).Icon,
fillColor,
borderColor,
glow,
});
const providerRootItem = buildVisualItem(
"Provider / account root",
"Cloud account, tenant, project, organization, or cluster entry point.",
buildNode(["AWSAccount"], { name: "Provider root" }),
GRAPH_NODE_COLORS.awsAccount,
GRAPH_NODE_BORDER_COLORS.awsAccount,
);
const resourceCategoryItems: LegendVisualItem[] = [
buildVisualItem(
"Storage",
"Data stores such as buckets and object storage.",
buildNode(["S3Bucket"], { name: NODE_CATEGORY.STORAGE }),
GRAPH_NODE_COLORS.s3Bucket,
GRAPH_NODE_BORDER_COLORS.s3Bucket,
),
buildVisualItem(
"Network",
"VPCs, security boundaries, gateways, and reachable network resources.",
buildNode(["VPC"], { name: NODE_CATEGORY.NETWORK }),
GRAPH_NODE_COLORS.securityGroup,
GRAPH_NODE_BORDER_COLORS.securityGroup,
),
buildVisualItem(
"Compute",
"Instances, virtual machines, functions, and execution surfaces.",
buildNode(["EC2Instance"], { name: NODE_CATEGORY.COMPUTE }),
GRAPH_NODE_COLORS.ec2Instance,
GRAPH_NODE_BORDER_COLORS.ec2Instance,
),
buildVisualItem(
"Identity",
"Users, roles, policies, and permission-bearing principals.",
buildNode(["IAMRole"], { name: NODE_CATEGORY.IDENTITY }),
GRAPH_NODE_COLORS.iamRole,
GRAPH_NODE_BORDER_COLORS.iamRole,
),
buildVisualItem(
"Secret / misc",
"Secrets, access keys, or fallback resources that do not map to a known category.",
buildNode(["AccessKey"], { name: NODE_CATEGORY.SECRET }),
GRAPH_NODE_COLORS.default,
GRAPH_NODE_BORDER_COLORS.default,
),
];
const findingRiskItems: LegendVisualItem[] = [
buildVisualItem(
"Critical",
"Highest-risk finding node with severity-colored badge and glow.",
buildNode(["ProwlerFinding"], { severity: "critical" }),
GRAPH_NODE_COLORS.critical,
GRAPH_NODE_BORDER_COLORS.critical,
true,
),
buildVisualItem(
"High",
"High-risk finding node with severity-colored badge and glow.",
buildNode(["ProwlerFinding"], { severity: "high" }),
GRAPH_NODE_COLORS.high,
GRAPH_NODE_BORDER_COLORS.high,
true,
),
buildVisualItem(
"Medium",
"Medium-risk finding node with severity-colored badge and glow.",
buildNode(["ProwlerFinding"], { severity: "medium" }),
GRAPH_NODE_COLORS.medium,
GRAPH_NODE_BORDER_COLORS.medium,
true,
),
buildVisualItem(
"Low / Info",
"Lower-risk informational findings use the info-style risk icon.",
buildNode(["ProwlerFinding"], { severity: "info" }),
GRAPH_NODE_COLORS.info,
GRAPH_NODE_BORDER_COLORS.info,
true,
),
];
const stateItems: LegendStateItem[] = [
{
label: "Selected node",
description: "Active node with a stronger animated selection ring.",
fillColor: GRAPH_NODE_COLORS.default,
borderColor: GRAPH_EDGE_HIGHLIGHT_COLOR,
strokeWidth: 4,
glowColor: GRAPH_EDGE_HIGHLIGHT_COLOR,
},
{
label: "Node with findings",
description: "Resource node linked to one or more findings.",
fillColor: GRAPH_NODE_COLORS.default,
borderColor: GRAPH_ALERT_BORDER_COLOR,
strokeWidth: 3,
glowColor: GRAPH_ALERT_BORDER_COLOR,
},
];
const edgeItems: LegendEdgeItem[] = [
{
label: "Normal edge",
description: "Relationship between resources in the attack path.",
variant: EDGE_VARIANT.NORMAL,
},
{
label: "Finding edge",
description: "Animated dashed edge that connects a resource to a finding.",
variant: EDGE_VARIANT.FINDING,
},
{
label: "Highlighted path",
description: "Orange path shown when hovering related graph nodes.",
variant: EDGE_VARIANT.HIGHLIGHTED,
},
];
const hasLegendData = (data?: AttackPathGraphData): boolean =>
(data?.nodes.length ?? 0) > 0;
const LegendSection = ({ title, children }: LegendSectionProps) => (
<section className="bg-bg-neutral-secondary/60 border-border-neutral-primary flex w-full min-w-0 flex-col gap-2 rounded-lg border p-3 sm:w-fit sm:max-w-full">
<h3 className="text-text-neutral-secondary text-[0.68rem] leading-none font-semibold tracking-wide uppercase">
{title}
</h3>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{children}
</div>
</section>
);
const LegendItem = ({ label, description, children }: LegendItemProps) => (
<Tooltip>
<TooltipTrigger asChild>
<div
className="hover:bg-bg-neutral-tertiary/70 inline-flex cursor-help items-center gap-2 rounded-full px-1.5 py-1 transition-colors"
role="img"
aria-label={`${label}: ${description}`}
>
{children}
<span className="text-text-neutral-secondary text-xs whitespace-nowrap">
{label}
</span>
</div>
</TooltipTrigger>
<TooltipContent>{description}</TooltipContent>
</Tooltip>
);
const BadgePreview = ({
Icon,
fillColor,
borderColor,
glow,
}: LegendVisualItem) => (
<svg
width={LEGEND_PREVIEW.SVG_SIZE}
height={LEGEND_PREVIEW.SVG_SIZE}
viewBox="0 0 36 36"
aria-hidden="true"
className="overflow-visible"
>
{glow && (
<circle
cx={LEGEND_PREVIEW.BADGE_CENTER}
cy={LEGEND_PREVIEW.BADGE_CENTER}
r={LEGEND_PREVIEW.BADGE_RADIUS + 4}
stroke={borderColor}
strokeOpacity={0.28}
strokeWidth={6}
fill={borderColor}
fillOpacity={0.14}
/>
)}
<circle
cx={LEGEND_PREVIEW.BADGE_CENTER}
cy={LEGEND_PREVIEW.BADGE_CENTER}
r={LEGEND_PREVIEW.BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.94}
stroke={borderColor}
strokeWidth={glow ? 2.5 : 1.5}
/>
<g
transform={`translate(${LEGEND_PREVIEW.ICON_OFFSET}, ${LEGEND_PREVIEW.ICON_OFFSET})`}
>
<Icon
aria-hidden="true"
color="#ffffff"
focusable="false"
height={LEGEND_PREVIEW.ICON_SIZE}
role="presentation"
size={LEGEND_PREVIEW.ICON_SIZE}
width={LEGEND_PREVIEW.ICON_SIZE}
/>
</g>
</svg>
);
const StatePreview = ({
fillColor,
borderColor,
strokeWidth,
glowColor,
}: LegendStateItem) => (
<svg width="36" height="36" viewBox="0 0 36 36" aria-hidden="true">
{glowColor && (
<circle cx="18" cy="18" r="20" fill={glowColor} fillOpacity="0.18" />
)}
<circle
cx="18"
cy="18"
r="16"
fill={fillColor}
fillOpacity="0.92"
stroke={borderColor}
strokeWidth={strokeWidth}
/>
</svg>
);
const EdgePreview = ({
variant,
edgeColor,
}: {
variant: EdgeVariant;
edgeColor: string;
}) => {
const isFindingEdge = variant === EDGE_VARIANT.FINDING;
const isHighlightedPath = variant === EDGE_VARIANT.HIGHLIGHTED;
const strokeColor = isHighlightedPath
? GRAPH_EDGE_HIGHLIGHT_COLOR
: edgeColor;
return (
<svg
width="56"
height="20"
viewBox="0 0 56 20"
aria-hidden="true"
className="overflow-visible"
>
{isHighlightedPath && (
<line
x1="4"
y1="10"
x2="40"
y2="10"
stroke={GRAPH_EDGE_HIGHLIGHT_COLOR}
strokeOpacity="0.32"
strokeWidth="7"
strokeLinecap="round"
/>
)}
<line
x1="4"
y1="10"
x2="40"
y2="10"
stroke={strokeColor}
strokeWidth={isHighlightedPath ? 3 : 2.5}
strokeLinecap="round"
strokeDasharray={isFindingEdge ? "8 6" : undefined}
/>
<polygon points="40,5 52,10 40,15" fill={strokeColor} />
</svg>
);
};
/**
* Legend for attack path graph node types and edge styles
* Compact semantic legend for the Attack Paths graph visual language.
*/
export const GraphLegend = ({ data }: GraphLegendProps) => {
const { resolvedTheme } = useTheme();
const nodeTypes = extractNodeTypes(data?.nodes);
// Get edge color based on current theme
const edgeColor =
resolvedTheme === "dark" ? GRAPH_EDGE_COLOR_DARK : GRAPH_EDGE_COLOR_LIGHT;
// Check if there are any findings in the data
const hasFindings = nodeTypes.some((type) =>
type.toLowerCase().includes("finding"),
);
const legendItems = generateLegendItems(nodeTypes, hasFindings);
if (legendItems.length === 0) {
if (!hasLegendData(data)) {
return null;
}
const edgeColor =
resolvedTheme === "dark" ? GRAPH_EDGE_COLOR_DARK : GRAPH_EDGE_COLOR_LIGHT;
return (
<Card className="w-fit border-0">
<CardContent className="gap-3 p-4">
<div className="flex flex-col gap-4">
{/* Node types section */}
<div className="flex flex-col items-start gap-3 lg:flex-row lg:flex-wrap lg:items-center">
<TooltipProvider>
{legendItems.map((item) => (
<Tooltip key={item.label}>
<TooltipTrigger asChild>
<div
className="flex cursor-help items-center gap-2"
role="img"
aria-label={`${item.label}: ${item.description}`}
>
{item.shape === "hexagon" ? (
<HexagonShape
color={item.color}
borderColor={item.borderColor}
/>
) : item.shape === "cloud" ? (
<GlobeShape
color={item.color}
borderColor={item.borderColor}
/>
) : (
<PillShape
color={item.color}
borderColor={item.borderColor}
/>
)}
<span className="text-text-neutral-secondary text-xs">
{item.label}
</span>
</div>
</TooltipTrigger>
<TooltipContent>{item.description}</TooltipContent>
</Tooltip>
<Card className="w-full border-0">
<CardContent className="p-3">
<TooltipProvider>
<div className="flex w-full flex-wrap items-stretch gap-2">
<LegendSection title="Provider roots">
<LegendItem {...providerRootItem}>
<BadgePreview {...providerRootItem} />
</LegendItem>
</LegendSection>
<LegendSection title="Resource categories">
{resourceCategoryItems.map((item) => (
<LegendItem key={item.label} {...item}>
<BadgePreview {...item} />
</LegendItem>
))}
</TooltipProvider>
</div>
</LegendSection>
{/* Edge types section */}
<div className="border-border-neutral-primary flex flex-col items-start gap-3 border-t pt-3 lg:flex-row lg:flex-wrap lg:items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex cursor-help items-center gap-2"
role="img"
aria-label="Solid line: Resource connection"
>
<EdgeLine dashed={false} edgeColor={edgeColor} />
<span className="text-text-neutral-secondary text-xs">
Resource Connection
</span>
</div>
</TooltipTrigger>
<TooltipContent>
Connection between infrastructure resources
</TooltipContent>
</Tooltip>
<LegendSection title="Findings by risk">
{findingRiskItems.map((item) => (
<LegendItem key={item.label} {...item}>
<BadgePreview {...item} />
</LegendItem>
))}
</LegendSection>
{hasFindings && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex cursor-help items-center gap-2"
role="img"
aria-label="Dashed line: Finding connection"
>
<EdgeLine dashed={true} edgeColor={edgeColor} />
<span className="text-text-neutral-secondary text-xs">
Finding Connection
</span>
</div>
</TooltipTrigger>
<TooltipContent>
Connection to a security finding
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
<LegendSection title="States">
{stateItems.map((item) => (
<LegendItem key={item.label} {...item}>
<StatePreview {...item} />
</LegendItem>
))}
</LegendSection>
{/* Zoom control hint */}
<div className="border-border-neutral-primary flex items-center gap-2 border-t pt-3">
<kbd className="bg-bg-neutral-tertiary text-text-neutral-secondary rounded px-1.5 py-0.5 text-xs font-medium">
Ctrl
</kbd>
<span className="text-text-neutral-secondary text-xs">+</span>
<span className="text-text-neutral-secondary text-xs">
Scroll to zoom
</span>
<LegendSection title="Edges">
{edgeItems.map((item) => (
<LegendItem key={item.label} {...item}>
<EdgePreview variant={item.variant} edgeColor={edgeColor} />
</LegendItem>
))}
</LegendSection>
</div>
</div>
</TooltipProvider>
</CardContent>
</Card>
);
@@ -244,6 +244,34 @@ describe("exploring the graph", () => {
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,
}) => {
@@ -265,6 +293,60 @@ describe("exploring the graph", () => {
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,
}) => {