fix(ui): improve attack paths graph layout highlights

This commit is contained in:
Alan Buscaglia
2026-05-05 23:08:24 +02:00
parent fc7e0c85e4
commit 6be12a2eb6
14 changed files with 340 additions and 45 deletions
+125
View File
@@ -49,6 +49,127 @@ const toErrorBody = (detail: string, status: number): JsonApiErrorBody => ({
errors: [{ detail, status: String(status) }],
});
const toFindingApiResponse = (fx: PageFixture, findingId: string) => {
const findingNode = fx.queryResult?.nodes.find(
(node) => node.id === findingId,
);
const resourceNode = fx.queryResult?.nodes.find((node) =>
fx.queryResult?.relationships?.some(
(rel) =>
(rel.source === node.id && rel.target === findingId) ||
(rel.target === node.id && rel.source === findingId),
),
);
const scan = fx.scans[0];
const providerId = scan?.relationships?.provider?.data?.id ?? "provider-1";
const resourceId = resourceNode?.id ?? "resource-1";
return {
data: {
type: "findings",
id: findingId,
attributes: {
uid: String(findingNode?.properties.id ?? findingId),
delta: null,
status: String(findingNode?.properties.status ?? "FAIL"),
status_extended: "Status extended",
severity: String(findingNode?.properties.severity ?? "critical"),
check_id: "attack_path_check",
muted: false,
muted_reason: null,
check_metadata: {
risk: "High",
notes: "",
checkid: "attack_path_check",
provider: "aws",
severity: String(findingNode?.properties.severity ?? "critical"),
checktype: [],
dependson: [],
relatedto: [],
categories: ["security"],
checktitle: String(
findingNode?.properties.check_title ?? "Attack path finding",
),
compliance: null,
relatedurl: "",
description: "Attack path finding description",
remediation: {
code: { cli: "", other: "", nativeiac: "", terraform: "" },
recommendation: { url: "", text: "Fix the finding" },
},
additionalurls: [],
servicename: String(resourceNode?.properties.service ?? "s3"),
checkaliases: [],
resourcetype: String(resourceNode?.labels[0] ?? "Resource"),
subservicename: "",
resourceidtemplate: "",
},
raw_result: null,
inserted_at: "2026-04-21T10:00:00Z",
updated_at: "2026-04-21T10:05:00Z",
first_seen_at: null,
},
relationships: {
resources: { data: [{ type: "resources", id: resourceId }] },
scan: { data: { type: "scans", id: scan?.id ?? "scan-1" } },
},
},
included: [
{
type: "resources",
id: resourceId,
attributes: {
uid: String(resourceNode?.properties.arn ?? resourceId),
name: String(resourceNode?.properties.name ?? resourceId),
region: "us-east-1",
service: String(resourceNode?.properties.service ?? "s3"),
tags: {},
type: String(resourceNode?.labels[0] ?? "Resource"),
inserted_at: "2026-04-21T10:00:00Z",
updated_at: "2026-04-21T10:05:00Z",
details: null,
partition: null,
},
},
{
type: "scans",
id: scan?.id ?? "scan-1",
attributes: {
name: "Attack path scan",
trigger: "manual",
state: scan?.attributes.state ?? "completed",
unique_resource_count: 1,
progress: scan?.attributes.progress ?? 100,
duration: scan?.attributes.duration ?? 0,
started_at: scan?.attributes.started_at ?? "2026-04-21T10:00:00Z",
inserted_at: scan?.attributes.inserted_at ?? "2026-04-21T10:00:00Z",
completed_at: scan?.attributes.completed_at ?? "2026-04-21T10:05:00Z",
scheduled_at: null,
next_scan_at: "",
},
relationships: {
provider: { data: { type: "providers", id: providerId } },
},
},
{
type: "providers",
id: providerId,
attributes: {
provider: scan?.attributes.provider_type ?? "aws",
uid: scan?.attributes.provider_uid ?? "123456789",
alias: scan?.attributes.provider_alias ?? "Provider",
connection: {
connected: true,
last_checked_at: "2026-04-21T10:00:00Z",
},
inserted_at: "2026-04-21T10:00:00Z",
updated_at: "2026-04-21T10:05:00Z",
},
},
],
};
};
export const handlersForFixture = (fx: PageFixture) => [
http.get(`${API}/attack-paths-scans`, () =>
HttpResponse.json<AttackPathScansResponse>(toScansApiResponse(fx.scans)),
@@ -103,4 +224,8 @@ export const handlersForFixture = (fx: PageFixture) => [
);
},
),
http.get<{ findingId: string }>(`${API}/findings/:findingId`, ({ params }) =>
HttpResponse.json(toFindingApiResponse(fx, params.findingId)),
),
];
@@ -97,7 +97,7 @@ const GRAPH_STYLES = `
// --- SVG filter color constants ---
const GRAPH_FINDING_GLOW_COLOR = "#ef4444";
const GRAPH_SELECTED_GLOW_COLOR = "#f97316";
const GRAPH_SELECTED_GLOW_COLOR = GRAPH_EDGE_HIGHLIGHT_COLOR;
// --- SVG filter defs (shared by all node components) ---
@@ -122,7 +122,7 @@ const GraphDefs = () => (
floodOpacity="0.6"
/>
</filter>
{/* Orange glow for selected nodes */}
{/* Prowler green glow for selected nodes */}
<filter id="selectedGlow">
<feDropShadow
dx="0"
@@ -442,9 +442,10 @@ const GraphCanvas = ({
}
// Path highlight: compute highlighted edge IDs
const highlightedEdgeIds = hoveredNodeId
const activeHighlightNodeId = hoveredNodeId ?? selectedNodeId;
const highlightedEdgeIds = activeHighlightNodeId
? getPathEdges(
hoveredNodeId,
activeHighlightNodeId,
rfEdges.map((e) => ({ sourceId: e.source, targetId: e.target })),
)
: new Set<string>();
@@ -74,6 +74,12 @@ describe("GraphLegend", () => {
expect(screen.getByText("Normal edge")).toBeInTheDocument();
expect(screen.getByText("Finding edge")).toBeInTheDocument();
expect(screen.getByText("Highlighted path")).toBeInTheDocument();
expect(
screen.getByRole("img", {
name: /highlighted path: prowler green path/i,
}),
).toBeInTheDocument();
expect(screen.queryByText(/orange path/i)).not.toBeInTheDocument();
expect(screen.queryByText(/ctrl/i)).not.toBeInTheDocument();
expect(screen.queryByText(/scroll to zoom/i)).not.toBeInTheDocument();
@@ -189,7 +189,8 @@ const edgeItems: LegendEdgeItem[] = [
},
{
label: "Highlighted path",
description: "Orange path shown when hovering related graph nodes.",
description:
"Prowler green path shown when hovering or selecting related graph nodes.",
variant: EDGE_VARIANT.HIGHLIGHTED,
},
];
@@ -1,13 +1,15 @@
import { render, screen } from "@testing-library/react";
import { type NodeProps } from "@xyflow/react";
import { type NodeProps, Position } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
import type { GraphNode } from "@/types/attack-paths";
import { FindingNode } from "./finding-node";
const hiddenHandlesMock = vi.hoisted(() => vi.fn(() => null));
vi.mock("./hidden-handles", () => ({
HiddenHandles: () => null,
HiddenHandles: hiddenHandlesMock,
}));
const buildFindingNode = (severity: string, title: string): GraphNode => ({
@@ -30,6 +32,25 @@ const buildNodeProps = (graphNode: GraphNode): NodeProps =>
}) as unknown as NodeProps;
describe("FindingNode", () => {
it("positions graph handles for vertical top-to-bottom edges", () => {
// Given
const props = buildNodeProps(
buildFindingNode("critical", "Root key exposed"),
);
// When
render(<FindingNode {...props} />);
// Then
expect(hiddenHandlesMock).toHaveBeenCalledWith(
expect.objectContaining({
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
}),
undefined,
);
});
describe("severity visuals", () => {
it("should render the critical finding risk icon with readable text", () => {
// Given
@@ -1,6 +1,6 @@
"use client";
import { type NodeProps } from "@xyflow/react";
import { type NodeProps, Position } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
@@ -21,8 +21,8 @@ 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 BADGE_BOTTOM_Y = BADGE_CENTER_Y + BADGE_RADIUS;
const BADGE_TOP_Y = BADGE_CENTER_Y - BADGE_RADIUS;
const ICON_SIZE = 28;
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
@@ -76,9 +76,11 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
return (
<>
<HiddenHandles
sourceStyle={{ right: BADGE_RIGHT_INSET }}
style={{ top: BADGE_CENTER_Y }}
targetStyle={{ left: BADGE_LEFT_X }}
sourcePosition={Position.Bottom}
sourceStyle={{ top: BADGE_BOTTOM_Y }}
style={{ left: BADGE_CENTER_X }}
targetPosition={Position.Top}
targetStyle={{ top: BADGE_TOP_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
<circle
@@ -4,26 +4,30 @@ import { Handle, Position } from "@xyflow/react";
import type { CSSProperties } from "react";
interface HiddenHandlesProps {
sourcePosition?: Position;
style?: CSSProperties;
targetPosition?: Position;
sourceStyle?: CSSProperties;
targetStyle?: CSSProperties;
}
export const HiddenHandles = ({
sourcePosition = Position.Bottom,
sourceStyle,
style,
targetPosition = Position.Top,
targetStyle,
}: HiddenHandlesProps) => (
<>
<Handle
type="target"
position={Position.Left}
position={targetPosition}
className="invisible"
style={{ ...style, ...targetStyle }}
/>
<Handle
type="source"
position={Position.Right}
position={sourcePosition}
className="invisible"
style={{ ...style, ...sourceStyle }}
/>
@@ -1,13 +1,15 @@
import { render, screen } from "@testing-library/react";
import { type NodeProps } from "@xyflow/react";
import { type NodeProps, Position } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
import type { GraphNode } from "@/types/attack-paths";
import { ResourceNode } from "./resource-node";
const hiddenHandlesMock = vi.hoisted(() => vi.fn(() => null));
vi.mock("./hidden-handles", () => ({
HiddenHandles: () => null,
HiddenHandles: hiddenHandlesMock,
}));
const buildGraphNode = (label: string, name: string): GraphNode => ({
@@ -30,6 +32,23 @@ const buildNodeProps = (graphNode: GraphNode): NodeProps =>
}) as unknown as NodeProps;
describe("ResourceNode", () => {
it("positions graph handles for vertical top-to-bottom edges", () => {
// Given
const props = buildNodeProps(buildGraphNode("S3Bucket", "logs"));
// When
render(<ResourceNode {...props} />);
// Then
expect(hiddenHandlesMock).toHaveBeenCalledWith(
expect.objectContaining({
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
}),
undefined,
);
});
describe("node visual icons", () => {
it("should render the S3 bucket icon with the resource label", () => {
// Given
@@ -1,6 +1,6 @@
"use client";
import { type NodeProps } from "@xyflow/react";
import { type NodeProps, Position } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
@@ -22,8 +22,8 @@ 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 BADGE_BOTTOM_Y = BADGE_CENTER_Y + BADGE_RADIUS;
const BADGE_TOP_Y = BADGE_CENTER_Y - BADGE_RADIUS;
const ICON_SIZE = 28;
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
@@ -63,9 +63,11 @@ export const ResourceNode = ({ data, selected }: NodeProps) => {
return (
<>
<HiddenHandles
sourceStyle={{ right: BADGE_RIGHT_INSET }}
style={{ top: BADGE_CENTER_Y }}
targetStyle={{ left: BADGE_LEFT_X }}
sourcePosition={Position.Bottom}
sourceStyle={{ top: BADGE_BOTTOM_Y }}
style={{ left: BADGE_CENTER_X }}
targetPosition={Position.Top}
targetStyle={{ top: BADGE_TOP_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
{glowRadius > 0 && (
@@ -54,8 +54,8 @@ export const GRAPH_NODE_BORDER_COLORS = {
// Edge colors per theme
export const GRAPH_EDGE_COLOR_DARK = "#ffffff"; // White for dark theme
export const GRAPH_EDGE_COLOR_LIGHT = "#1e293b"; // Slate 800 for light theme
export const GRAPH_EDGE_HIGHLIGHT_COLOR = "#f97316"; // Orange 500 (on hover)
export const GRAPH_EDGE_GLOW_COLOR = "#fb923c";
export const GRAPH_EDGE_HIGHLIGHT_COLOR = "#34d399"; // Prowler green (hover/selection)
export const GRAPH_EDGE_GLOW_COLOR = "#6ee7b7";
export const GRAPH_SELECTION_COLOR = "#ffffff";
export const GRAPH_BORDER_COLOR = "#374151";
export const GRAPH_ALERT_BORDER_COLOR = "#ef4444"; // Red 500 - for resources with findings
@@ -1,3 +1,4 @@
import { Position } from "@xyflow/react";
import { describe, expect, it } from "vitest";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
@@ -71,6 +72,77 @@ describe("layoutWithDagre", () => {
expect(a).toEqual(b);
});
it("spreads sibling nodes horizontally to use wide graph space", () => {
const rootNode: GraphNode = {
id: "root",
labels: ["AWSAccount"],
properties: { name: "account" },
};
const siblingNodes: GraphNode[] = [
{
id: "bucket",
labels: ["S3Bucket"],
properties: { name: "bucket" },
},
{
id: "lambda",
labels: ["AWSLambda"],
properties: { name: "function" },
},
{
id: "database",
labels: ["RDSInstance"],
properties: { name: "database" },
},
];
const { rfNodes } = layoutWithDagre(
[rootNode, ...siblingNodes],
siblingNodes.map((node) => ({
id: `root-${node.id}`,
source: "root",
target: node.id,
type: "CONNECTS_TO",
})),
);
const siblingPositions = siblingNodes.map((node) => {
const rfNode = rfNodes.find((candidate) => candidate.id === node.id);
expect(rfNode).toBeDefined();
return rfNode?.position ?? { x: 0, y: 0 };
});
const xSpread =
Math.max(...siblingPositions.map((position) => position.x)) -
Math.min(...siblingPositions.map((position) => position.x));
const ySpread =
Math.max(...siblingPositions.map((position) => position.y)) -
Math.min(...siblingPositions.map((position) => position.y));
expect(xSpread).toBeGreaterThan(ySpread);
});
it("connects edges through top and bottom node sides for vertical layout", () => {
const { rfNodes } = layoutWithDagre(
[findingNode, resourceNode],
[
{
id: "e1",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
],
);
rfNodes.forEach((node) => {
expect(node.sourcePosition).toBe(Position.Bottom);
expect(node.targetPosition).toBe(Position.Top);
});
});
it("offsets dagre center positions by half of the node dimensions (top-left)", () => {
const { rfNodes } = layoutWithDagre([findingNode, resourceNode], []);
@@ -4,7 +4,7 @@
*/
import { Graph, layout as dagreLayout } from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
import { type Edge, type Node, Position } from "@xyflow/react";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
@@ -65,7 +65,7 @@ export const layoutWithDagre = (
): { rfNodes: Node<NodeData>[]; rfEdges: Edge[] } => {
const g = new Graph();
g.setGraph({
rankdir: "LR",
rankdir: "TB",
nodesep: 80,
ranksep: 150,
marginx: 50,
@@ -112,6 +112,8 @@ export const layoutWithDagre = (
x: dagreNode.x - width / 2,
y: dagreNode.y - height / 2,
},
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
data: { graphNode: node },
width,
height,
@@ -337,14 +337,8 @@ describe("exploring the graph", () => {
expect(graph.isInFilteredView).toBe(false);
await graph.waitForTransition();
const expandedContextViewport = graph.viewportTransform;
await graph.fit();
await graph.waitFor(
() => graph.viewportTransform !== expandedContextViewport,
2000,
);
expect(graph.findingNodes.length).toBeGreaterThan(0);
expect(graph.viewportTransform).toBeTruthy();
});
test("choosing View node details opens node details in a modal", async ({
@@ -423,6 +417,18 @@ describe("exploring the graph", () => {
expect(graph.highlightedEdges.length).toBe(0);
});
test("selecting a node keeps its path edges highlighted", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNodeWithoutFindings();
expect(graph.highlightedEdges.length).toBeGreaterThan(0);
});
test("clicking the empty canvas keeps the full graph", async ({
mountWith,
}) => {
@@ -295,7 +295,39 @@ export class AttackPathPageHarness {
// --- Action methods ---
private async clickGraphElement(element: HTMLElement): Promise<void> {
await this.closeFindingDrawerIfOpen();
// React Flow nodes can be visually reachable while the surrounding scroll
// container or overlay intercepts browser-mode pointer events in large
// graphs. Use a bubbling DOM click so the component's onClick path is still
// exercised without depending on viewport scroll physics.
element.click();
}
async closeFindingDrawerIfOpen(): Promise<void> {
const drawer = Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"]'),
).find((dialog) =>
/resource finding details|finding overview/i.test(
dialog.textContent ?? "",
),
);
if (!drawer) return;
const closeButton = Array.from(
drawer.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => /^close$/i.test(button.textContent?.trim() ?? ""));
if (closeButton) {
closeButton.click();
await this.waitForTransition();
}
}
async selectQuery(queryId?: string): Promise<void> {
await this.closeFindingDrawerIfOpen();
const trigger = await this.waitFor<HTMLButtonElement>(
() =>
this.container.querySelector<HTMLButtonElement>(
@@ -347,14 +379,14 @@ export class AttackPathPageHarness {
async clickNode(nodeId: string): Promise<void> {
const el = this.getNodeById(nodeId);
if (!el) throw new Error(`clickNode: node "${nodeId}" not found`);
await this.user.click(el);
await this.clickGraphElement(el);
await this.waitForTransition();
}
async clickFirstFindingNode(): Promise<HTMLElement> {
const [finding] = this.findingNodes;
if (!finding) throw new Error("clickFirstFindingNode: no finding rendered");
await this.user.click(finding);
await this.clickGraphElement(finding);
await this.waitForTransition();
return finding;
}
@@ -363,7 +395,7 @@ export class AttackPathPageHarness {
const [resource] = this.resourceNodes;
if (!resource)
throw new Error("clickFirstResourceNode: no resource rendered");
await this.user.click(resource);
await this.clickGraphElement(resource);
await this.waitForTransition();
return resource;
}
@@ -390,7 +422,7 @@ export class AttackPathPageHarness {
"clickFirstResourceNodeWithoutFindings: no resource without findings rendered",
);
}
await this.user.click(resource);
await this.clickGraphElement(resource);
await this.waitForTransition();
return resource;
}
@@ -404,7 +436,7 @@ export class AttackPathPageHarness {
if (!finding)
throw new Error("rapidlyClickFirstFindingNode: no finding rendered");
for (let i = 0; i < times; i++) {
await this.user.click(finding);
finding.click();
}
await this.waitForTransition();
return finding;
@@ -513,7 +545,7 @@ export class AttackPathPageHarness {
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);
button.click();
await this.waitForTransition();
}
@@ -522,7 +554,7 @@ export class AttackPathPageHarness {
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);
button.click();
await this.waitForTransition();
}
@@ -532,7 +564,7 @@ export class AttackPathPageHarness {
).find((btn) => /view node details/i.test(btn.textContent ?? ""));
if (!button)
throw new Error("chooseViewNodeDetailsAction: button not found");
await this.user.click(button);
button.click();
await this.waitForTransition();
}
@@ -542,14 +574,16 @@ export class AttackPathPageHarness {
).find((btn) => /back to full graph/i.test(btn.textContent ?? ""));
if (!button)
throw new Error("chooseBackToFullGraphAction: button not found");
await this.user.click(button);
button.click();
await this.waitForTransition();
}
async exitFilteredView(): Promise<void> {
await this.closeFindingDrawerIfOpen();
const btn = this.toolbar.backToFullViewButton;
if (!btn) throw new Error("exitFilteredView: not in filtered view");
await this.user.click(btn);
btn.click();
await this.waitForTransition();
}