feat(ui): replace D3+Dagre attack path graph with React Flow (#10686)

Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-05-12 16:33:29 +02:00
committed by GitHub
parent 67e4b1a082
commit 1090ed59b7
53 changed files with 7340 additions and 2328 deletions
+30 -9
View File
@@ -1,14 +1,14 @@
name: 'UI: Tests'
name: "UI: Tests"
on:
push:
branches:
- 'master'
- 'v5.*'
- "master"
- "v5.*"
pull_request:
branches:
- 'master'
- 'v5.*'
- "master"
- "v5.*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -16,7 +16,7 @@ concurrency:
env:
UI_WORKING_DIR: ./ui
NODE_VERSION: '24.13.0'
NODE_VERSION: "24.13.0"
permissions: {}
@@ -42,6 +42,9 @@ jobs:
fonts.gstatic.com:443
api.github.com:443
release-assets.githubusercontent.com:443
cdn.playwright.dev:443
objects.githubusercontent.com:443
playwright.download.prss.microsoft.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -133,7 +136,7 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed == 'true'
run: |
echo "Critical paths changed - running ALL unit tests"
pnpm run test:run
pnpm run test:unit
- name: Run unit tests (related to changes only)
if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed != 'true' && steps.changed-source.outputs.all_changed_files != ''
@@ -142,7 +145,7 @@ jobs:
echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}"
# Convert space-separated to vitest related format (remove ui/ prefix for relative paths)
CHANGED_FILES=$(echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | sed 's|^ui/||' | tr '\n' ' ')
pnpm exec vitest related $CHANGED_FILES --run
pnpm exec vitest related $CHANGED_FILES --run --project unit
env:
STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-source.outputs.all_changed_files }}
@@ -150,7 +153,25 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed != 'true' && steps.changed-source.outputs.all_changed_files == ''
run: |
echo "Only test files changed - running ALL unit tests"
pnpm run test:run
pnpm run test:unit
- name: Cache Playwright browsers
if: steps.check-changes.outputs.any_changed == 'true'
id: playwright-cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('ui/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-chromium-
- name: Install Playwright Chromium browser
if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium
- name: Run browser tests
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run test:browser
- name: Build application
if: steps.check-changes.outputs.any_changed == 'true'
+4
View File
@@ -7,6 +7,7 @@
# testing
/coverage
__screenshots__/
# next.js
/.next/
@@ -28,6 +29,9 @@ yarn-error.log*
.env*.local
.env
# Claude Code local settings
.claude/
# vercel
.vercel
+1
View File
@@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed
- Trimmed unused npm dependencies [(#11115)](https://github.com/prowler-cloud/prowler/pull/11115)
- Attack Paths graph now uses React Flow with improved layout, interactions, export, minimap, and browser test coverage [(#10686)](https://github.com/prowler-cloud/prowler/pull/10686)
---
+18
View File
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
describe("mock service worker message hardening", () => {
it("rejects messages from unexpected origins before handling client messages", () => {
const workerSource = readFileSync(
join(process.cwd(), "public/mockServiceWorker.js"),
"utf8",
);
expect(workerSource).toContain("event.origin !== self.location.origin");
expect(
workerSource.indexOf("event.origin !== self.location.origin"),
).toBeLessThan(workerSource.indexOf("const clientId = Reflect.get"));
});
});
+231
View File
@@ -0,0 +1,231 @@
import { http, HttpResponse } from "msw";
import type { PageFixture } from "@/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures";
import type {
AttackPathQueriesResponse,
AttackPathQuery,
AttackPathQueryResult,
AttackPathScan,
AttackPathScansResponse,
QueryResultAttributes,
} from "@/types/attack-paths";
const API = process.env.NEXT_PUBLIC_API_BASE_URL!;
type JsonApiErrorBody = {
errors: Array<{ detail: string; status: string }>;
};
const toScansApiResponse = (
scans: AttackPathScan[],
): AttackPathScansResponse => ({
data: scans,
links: {
first: `${API}/attack-paths-scans?page=1`,
last: `${API}/attack-paths-scans?page=1`,
next: null,
prev: null,
},
});
const toQueriesApiResponse = (
queries: AttackPathQuery[],
): AttackPathQueriesResponse => ({
data: queries,
});
const toQueryResultApiResponse = (
attrs: QueryResultAttributes,
queryId: string,
): AttackPathQueryResult => ({
data: {
type: "attack-paths-query-run-requests",
id: queryId,
attributes: attrs,
},
});
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)),
),
http.get<{ scanId: string }>(
`${API}/attack-paths-scans/:scanId/queries`,
() =>
HttpResponse.json<AttackPathQueriesResponse>(
toQueriesApiResponse(fx.queries),
),
),
http.post<{ scanId: string }>(
`${API}/attack-paths-scans/:scanId/queries/run`,
() => {
if (fx.queryError) {
return HttpResponse.json<JsonApiErrorBody>(
toErrorBody(fx.queryError.error, fx.queryError.status),
{ status: fx.queryError.status },
);
}
if (!fx.queryResult) {
return HttpResponse.json<JsonApiErrorBody>(
toErrorBody("No data found", 404),
{ status: 404 },
);
}
return HttpResponse.json<AttackPathQueryResult>(
toQueryResultApiResponse(fx.queryResult, fx.queryId),
);
},
),
http.post<{ scanId: string }>(
`${API}/attack-paths-scans/:scanId/queries/custom`,
() => {
if (fx.queryError) {
return HttpResponse.json<JsonApiErrorBody>(
toErrorBody(fx.queryError.error, fx.queryError.status),
{ status: fx.queryError.status },
);
}
if (!fx.queryResult) {
return HttpResponse.json<JsonApiErrorBody>(
toErrorBody("No data found", 404),
{ status: 404 },
);
}
return HttpResponse.json<AttackPathQueryResult>(
toQueryResultApiResponse(fx.queryResult, fx.queryId),
);
},
),
http.get<{ findingId: string }>(`${API}/findings/:findingId`, ({ params }) =>
HttpResponse.json(toFindingApiResponse(fx, params.findingId)),
),
];
+13
View File
@@ -0,0 +1,13 @@
import type { HttpHandler } from "msw";
/**
* Static handlers shared by every browser test — registered as defaults on
* the worker. Use this list for endpoints whose response doesn't change
* across tests (e.g. `/users/me`, `/tenants/current`, health checks).
*
* Per-domain dynamic handlers that depend on fixture data live in their own
* files alongside this index (e.g. `./attack-paths.ts`) and are imported
* directly by the tests that need them, then wired via
* `worker.use(...handlersForFixture(fx))`.
*/
export const handlers: HttpHandler[] = [];
+5
View File
@@ -0,0 +1,5 @@
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
+25
View File
@@ -0,0 +1,25 @@
import type { ComponentType, PropsWithChildren, ReactElement } from "react";
import { render as vitestRender } from "vitest-browser-react";
const TestProviders = ({ children }: PropsWithChildren) => <>{children}</>;
type RenderOptions = Parameters<typeof vitestRender>[1];
export function render(ui: ReactElement, options?: RenderOptions) {
const userWrapper = options?.wrapper as
| ComponentType<PropsWithChildren>
| undefined;
const Wrapper = userWrapper
? ({ children }: PropsWithChildren) => {
const Inner = userWrapper;
return (
<TestProviders>
<Inner>{children}</Inner>
</TestProviders>
);
}
: TestProviders;
return vitestRender(ui, { ...options, wrapper: Wrapper });
}
@@ -131,27 +131,16 @@ export function adaptQueryResultToGraphData(
// Populate findings and resources based on HAS_FINDING edges
edges.forEach((edge) => {
if (edge.type === "HAS_FINDING") {
const sourceId =
typeof edge.source === "string"
? edge.source
: (edge.source as { id?: string })?.id;
const targetId =
typeof edge.target === "string"
? edge.target
: (edge.target as { id?: string })?.id;
// Add finding to source node (resource -> finding)
const sourceNode = normalizedNodes.find((n) => n.id === edge.source);
if (sourceNode) {
sourceNode.findings.push(edge.target);
}
if (sourceId && targetId) {
// Add finding to source node (resource -> finding)
const sourceNode = normalizedNodes.find((n) => n.id === sourceId);
if (sourceNode) {
sourceNode.findings.push(targetId);
}
// Add resource to target node (finding <- resource)
const targetNode = normalizedNodes.find((n) => n.id === targetId);
if (targetNode) {
targetNode.resources.push(sourceId);
}
// Add resource to target node (finding <- resource)
const targetNode = normalizedNodes.find((n) => n.id === edge.target);
if (targetNode) {
targetNode.resources.push(edge.source);
}
}
});
@@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { GraphControls } from "./graph-controls";
const baseProps = {
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onFitToScreen: vi.fn(),
};
describe("GraphControls", () => {
it("disables the export button and surfaces the unavailable message when no onExport is provided", () => {
render(<GraphControls {...baseProps} />);
const exportButton = screen.getByRole("button", {
name: /export available soon/i,
});
expect(exportButton).toBeDisabled();
expect(
screen.queryByRole("button", { name: /^export graph$/i }),
).not.toBeInTheDocument();
});
it("enables the export button and invokes the callback when onExport is provided", async () => {
const user = userEvent.setup();
const onExport = vi.fn();
render(<GraphControls {...baseProps} onExport={onExport} />);
const exportButton = screen.getByRole("button", {
name: /^export graph$/i,
});
expect(exportButton).toBeEnabled();
await user.click(exportButton);
expect(onExport).toHaveBeenCalledTimes(1);
});
});
@@ -14,7 +14,7 @@ interface GraphControlsProps {
onZoomIn: () => void;
onZoomOut: () => void;
onFitToScreen: () => void;
onExport: () => void;
onExport?: () => void;
}
/**
@@ -38,6 +38,7 @@ export const GraphControls = ({
size="sm"
onClick={onZoomIn}
className="h-8 w-8 p-0"
aria-label="Zoom in"
>
<ZoomIn size={18} />
</Button>
@@ -52,6 +53,7 @@ export const GraphControls = ({
size="sm"
onClick={onZoomOut}
className="h-8 w-8 p-0"
aria-label="Zoom out"
>
<ZoomOut size={18} />
</Button>
@@ -66,6 +68,7 @@ export const GraphControls = ({
size="sm"
onClick={onFitToScreen}
className="h-8 w-8 p-0"
aria-label="Fit graph to view"
>
<Minimize2 size={18} />
</Button>
@@ -79,12 +82,16 @@ export const GraphControls = ({
variant="ghost"
size="sm"
onClick={onExport}
disabled={!onExport}
className="h-8 w-8 p-0"
aria-label={onExport ? "Export graph" : "Export available soon"}
>
<Download size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Export graph</TooltipContent>
<TooltipContent>
{onExport ? "Export graph" : "Export available soon"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@@ -0,0 +1,186 @@
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")).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.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();
});
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 keep unattached findings visible in the legend", () => {
// Given - Findings have no connected resource and stay visible in the full graph
const findingsOnlyGraphData: AttackPathGraphData = {
nodes: [
{
id: "finding-critical",
labels: ["ProwlerFinding"],
properties: { check_title: "Critical finding", severity: "critical" },
},
{
id: "finding-high",
labels: ["ProwlerFinding"],
properties: { check_title: "High finding", severity: "high" },
},
],
relationships: [],
};
// When
render(<GraphLegend data={findingsOnlyGraphData} />);
// Then
expect(
screen.getByRole("heading", { name: /findings by risk/i }),
).toBeInTheDocument();
expect(screen.getByText("Critical")).toBeInTheDocument();
expect(screen.getByText("High")).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();
});
});
@@ -1,4 +1,4 @@
export type { AttackPathGraphRef } from "./attack-path-graph";
export type { GraphHandle } from "./attack-path-graph";
export { AttackPathGraph } from "./attack-path-graph";
export { GraphControls } from "./graph-controls";
export { GraphLegend } from "./graph-legend";
@@ -0,0 +1,95 @@
import { render, screen } from "@testing-library/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: hiddenHandlesMock,
}));
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", () => {
it("positions graph handles for horizontal left-to-right edges", () => {
// Given
const props = buildNodeProps(
buildFindingNode("critical", "Root key exposed"),
);
// When
render(<FindingNode {...props} />);
// Then
expect(hiddenHandlesMock).toHaveBeenCalledWith(
expect.objectContaining({
sourcePosition: Position.Right,
sourceStyle: { left: 97, top: 26 },
targetPosition: Position.Left,
targetStyle: { left: 53, top: 26 },
}),
undefined,
);
});
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();
});
});
});
@@ -0,0 +1,157 @@
"use client";
import { type NodeProps, Position } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
import { getNodeLabelLines } from "./node-label-lines";
interface FindingNodeData {
graphNode: GraphNode;
[key: string]: unknown;
}
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_X = 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;
const { fillColor, borderColor } = resolveNodeColors({
labels: graphNode.labels,
properties: graphNode.properties,
selected,
});
const title = String(
graphNode.properties?.check_title ||
graphNode.properties?.name ||
graphNode.properties?.id ||
"Finding",
);
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`;
const badgeStrokeWidth = selected ? 4 : 2.5;
const glowRadius = selected ? 32 : 30;
const glowOpacity = selected ? 0.34 : 0.28;
return (
<>
<HiddenHandles
sourcePosition={Position.Right}
sourceStyle={{ left: BADGE_RIGHT_X, top: BADGE_CENTER_Y }}
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
stroke={borderColor}
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={TEXT_X}
y={TITLE_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{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>
</>
);
};
@@ -0,0 +1,35 @@
"use client";
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.Right,
sourceStyle,
style,
targetPosition = Position.Left,
targetStyle,
}: HiddenHandlesProps) => (
<>
<Handle
type="target"
position={targetPosition}
className="invisible"
style={{ ...style, ...targetStyle }}
/>
<Handle
type="source"
position={sourcePosition}
className="invisible"
style={{ ...style, ...sourceStyle }}
/>
</>
);
@@ -0,0 +1,81 @@
"use client";
import { type NodeProps } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
interface InternetNodeData {
graphNode: GraphNode;
[key: string]: unknown;
}
const RADIUS = 40; // NODE_HEIGHT * 0.8
const DIAMETER = RADIUS * 2;
export const InternetNode = ({ data, selected }: NodeProps) => {
const { graphNode } = data as InternetNodeData;
const { fillColor, borderColor } = resolveNodeColors({
labels: graphNode.labels,
properties: graphNode.properties,
selected,
});
const strokeWidth = selected ? 4 : 1.5;
return (
<>
<HiddenHandles />
<svg width={DIAMETER} height={DIAMETER} className="overflow-visible">
{/* Main circle */}
<circle
cx={RADIUS}
cy={RADIUS}
r={RADIUS}
fill={fillColor}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={strokeWidth}
className={selected ? "selected-node" : undefined}
/>
{/* Horizontal ellipse (equator) */}
<ellipse
cx={RADIUS}
cy={RADIUS}
rx={RADIUS}
ry={RADIUS * 0.35}
fill="none"
stroke={borderColor}
strokeWidth={1}
strokeOpacity={0.5}
/>
{/* Vertical ellipse (meridian) */}
<ellipse
cx={RADIUS}
cy={RADIUS}
rx={RADIUS * 0.35}
ry={RADIUS}
fill="none"
stroke={borderColor}
strokeWidth={1}
strokeOpacity={0.5}
/>
{/* Label */}
<text
x={RADIUS}
y={RADIUS}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
fontSize="11px"
fontWeight="600"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
Internet
</text>
</svg>
</>
);
};
@@ -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,88 @@
import { render, screen } from "@testing-library/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: hiddenHandlesMock,
}));
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", () => {
it("positions graph handles for horizontal left-to-right edges", () => {
// Given
const props = buildNodeProps(buildGraphNode("S3Bucket", "logs"));
// When
render(<ResourceNode {...props} />);
// Then
expect(hiddenHandlesMock).toHaveBeenCalledWith(
expect.objectContaining({
sourcePosition: Position.Right,
sourceStyle: { left: 90, top: 26 },
targetPosition: Position.Left,
targetStyle: { left: 46, top: 26 },
}),
undefined,
);
});
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();
});
});
});
@@ -0,0 +1,142 @@
"use client";
import { type NodeProps, Position } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
import { getNodeLabelLines } from "./node-label-lines";
interface ResourceNodeData {
graphNode: GraphNode;
hasFindings?: boolean;
[key: string]: unknown;
}
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_X = 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;
const { fillColor, borderColor } = resolveNodeColors({
labels: graphNode.labels,
properties: graphNode.properties,
selected,
hasFindings,
});
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 displayNameLines = getNodeLabelLines(
visual.displayName,
NAME_MAX_CHARS,
NAME_MAX_LINES,
);
const typeLabel = visual.description;
const iconLabel = `${visual.description} icon`;
return (
<>
<HiddenHandles
sourcePosition={Position.Right}
sourceStyle={{ left: BADGE_RIGHT_X, top: BADGE_CENTER_Y }}
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
{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.92}
stroke={borderColor}
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={TEXT_X}
y={NAME_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{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={TEXT_X}
y={TYPE_Y}
fontSize="9px"
fill="rgba(255,255,255,0.8)"
>
{typeLabel}
</tspan>
)}
</text>
</svg>
</>
);
};
@@ -1,4 +1,3 @@
export { NodeDetailContent, NodeDetailPanel } from "./node-detail-panel";
export { NodeOverview } from "./node-overview";
export { NodeRelationships } from "./node-relationships";
export { NodeRemediation } from "./node-remediation";
@@ -1,105 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import type { GraphEdge } from "@/types/attack-paths";
interface NodeRelationshipsProps {
incomingEdges: GraphEdge[];
outgoingEdges: GraphEdge[];
}
/**
* Format edge type to human-readable label
* e.g., "HAS_FINDING" -> "Has Finding"
*/
function formatEdgeType(edgeType: string): string {
return edgeType
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
interface EdgeItemProps {
edge: GraphEdge;
isOutgoing: boolean;
}
/**
* Reusable edge item component
*/
function EdgeItem({ edge, isOutgoing }: EdgeItemProps) {
const targetId =
typeof edge.target === "string" ? edge.target : String(edge.target);
const sourceId =
typeof edge.source === "string" ? edge.source : String(edge.source);
const displayId = (isOutgoing ? targetId : sourceId).substring(0, 30);
return (
<div
key={edge.id}
className="border-border-neutral-tertiary dark:border-border-neutral-tertiary flex items-center justify-between rounded border p-2"
>
<code className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
{displayId}
</code>
<span
className={cn(
"rounded px-2 py-1 text-xs font-medium",
isOutgoing
? "bg-bg-data-info text-text-neutral-primary dark:text-text-neutral-primary"
: "bg-bg-pass-primary text-text-neutral-primary dark:text-text-neutral-primary",
)}
>
{formatEdgeType(edge.type)}
</span>
</div>
);
}
/**
* Node relationships section showing incoming and outgoing edges
*/
export const NodeRelationships = ({
incomingEdges,
outgoingEdges,
}: NodeRelationshipsProps) => {
return (
<div className="flex flex-col gap-6">
{/* Outgoing Relationships */}
<div>
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
Outgoing Relationships ({outgoingEdges.length})
</h4>
{outgoingEdges.length > 0 ? (
<div className="space-y-2">
{outgoingEdges.map((edge) => (
<EdgeItem key={edge.id} edge={edge} isOutgoing />
))}
</div>
) : (
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
No outgoing relationships
</p>
)}
</div>
{/* Incoming Relationships */}
<div className="border-border-neutral-tertiary dark:border-border-neutral-tertiary border-t pt-6">
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
Incoming Relationships ({incomingEdges.length})
</h4>
{incomingEdges.length > 0 ? (
<div className="space-y-2">
{incomingEdges.map((edge) => (
<EdgeItem key={edge.id} edge={edge} isOutgoing={false} />
))}
</div>
) : (
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
No incoming relationships
</p>
)}
</div>
</div>
);
};
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useGraphStore } from "./use-graph-state";
describe("useGraphStore", () => {
beforeEach(() => {
useGraphStore.getState().reset();
});
it("keeps only one expanded findings resource open at a time", () => {
// Given
const store = useGraphStore.getState();
// When
store.toggleExpandedResource("resource-a");
useGraphStore.getState().toggleExpandedResource("resource-b");
// Then
expect(Array.from(useGraphStore.getState().expandedResources)).toEqual([
"resource-b",
]);
});
it("closes the expanded findings resource when toggled again", () => {
// Given
const store = useGraphStore.getState();
// When
store.toggleExpandedResource("resource-a");
useGraphStore.getState().toggleExpandedResource("resource-a");
// Then
expect(useGraphStore.getState().expandedResources.size).toBe(0);
});
});
@@ -14,6 +14,11 @@ interface FilteredViewState {
isFilteredView: boolean;
filteredNodeId: string | null;
fullData: AttackPathGraphData | null; // Original data before filtering
// Tier 1 expansion state: which resource nodes have their findings revealed.
// Lives in the store (not local component state) so it survives the data
// swaps that happen when entering/exiting filtered view. Reset only on
// fresh data loads (new query / scan) — see `setGraphData`.
expandedResources: Set<string>;
}
interface GraphStore extends GraphState, FilteredViewState {
@@ -21,14 +26,13 @@ interface GraphStore extends GraphState, FilteredViewState {
setSelectedNodeId: (nodeId: string | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setZoom: (zoomLevel: number) => void;
setPan: (panX: number, panY: number) => void;
setFilteredView: (
isFiltered: boolean,
nodeId: string | null,
filteredData: AttackPathGraphData | null,
fullData: AttackPathGraphData | null,
) => void;
toggleExpandedResource: (resourceId: string) => void;
reset: () => void;
}
@@ -37,15 +41,13 @@ const initialState: GraphState & FilteredViewState = {
selectedNodeId: null,
loading: false,
error: null,
zoomLevel: 1,
panX: 0,
panY: 0,
isFilteredView: false,
filteredNodeId: null,
fullData: null,
expandedResources: new Set(),
};
const useGraphStore = create<GraphStore>((set) => ({
export const useGraphStore = create<GraphStore>((set) => ({
...initialState,
setGraphData: (data) =>
set({
@@ -54,12 +56,12 @@ const useGraphStore = create<GraphStore>((set) => ({
error: null,
isFilteredView: false,
filteredNodeId: null,
// Fresh data → drop any stale expansion from the previous graph.
expandedResources: new Set(),
}),
setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
setZoom: (zoomLevel) => set({ zoomLevel }),
setPan: (panX, panY) => set({ panX, panY }),
setFilteredView: (isFiltered, nodeId, filteredData, fullData) =>
set({
isFilteredView: isFiltered,
@@ -68,6 +70,13 @@ const useGraphStore = create<GraphStore>((set) => ({
fullData,
selectedNodeId: nodeId,
}),
toggleExpandedResource: (resourceId) =>
set((state) => {
const next = state.expandedResources.has(resourceId)
? new Set<string>()
: new Set([resourceId]);
return { expandedResources: next };
}),
reset: () => set(initialState),
}));
@@ -106,11 +115,6 @@ export const useGraphState = () => {
store.setError(error);
};
const updateZoomAndPan = (zoomLevel: number, panX: number, panY: number) => {
store.setZoom(zoomLevel);
store.setPan(panX, panY);
};
const resetGraph = () => {
store.reset();
};
@@ -162,18 +166,16 @@ export const useGraphState = () => {
selectedNode: getSelectedNode(),
loading: store.loading,
error: store.error,
zoomLevel: store.zoomLevel,
panX: store.panX,
panY: store.panY,
isFilteredView: store.isFilteredView,
filteredNodeId: store.filteredNodeId,
filteredNode: getFilteredNode(),
expandedResources: store.expandedResources,
toggleExpandedResource: store.toggleExpandedResource,
updateGraphData,
selectNode,
startLoading,
stopLoading,
setError,
updateZoomAndPan,
resetGraph,
clearGraph,
enterFilteredView,
@@ -0,0 +1,156 @@
import type { Rect } from "@xyflow/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AttackPathGraphData } from "@/types/attack-paths";
import { exportGraphAsJSON, exportGraphAsPNG } from "./export";
const bounds: Rect = { x: 0, y: 0, width: 100, height: 100 };
const graphData: AttackPathGraphData = {
nodes: [
{ id: "internet", labels: ["Internet"], properties: { name: "Internet" } },
{
id: "ec2-1",
labels: ["EC2Instance"],
properties: { name: "api-server-01" },
},
],
edges: [
{ id: "edge-1", source: "internet", target: "ec2-1", type: "CAN_REACH" },
],
};
const buildContainerWithViewport = () => {
const container = document.createElement("div");
const reactFlow = document.createElement("div");
reactFlow.className = "react-flow";
const viewport = document.createElement("div");
viewport.className = "react-flow__viewport";
reactFlow.appendChild(viewport);
container.appendChild(reactFlow);
return container;
};
class TestImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
decoding = "async";
set src(_value: string) {
queueMicrotask(() => this.onload?.());
}
}
describe("exportGraphAsPNG", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.stubGlobal("Image", TestImage);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:graph-export");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
arc: vi.fn(),
beginPath: vi.fn(),
bezierCurveTo: vi.fn(),
closePath: vi.fn(),
fill: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
lineTo: vi.fn(),
moveTo: vi.fn(),
quadraticCurveTo: vi.fn(),
restore: vi.fn(),
save: vi.fn(),
setLineDash: vi.fn(),
stroke: vi.fn(),
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(
"data:image/png;base64,AAAA",
);
});
it("throws when the container is not mounted", async () => {
await expect(
exportGraphAsPNG(null, bounds, undefined, graphData),
).rejects.toThrow("Graph container not mounted");
});
it("throws when the React Flow root is missing inside the container", async () => {
const container = document.createElement("div");
await expect(
exportGraphAsPNG(container, bounds, undefined, graphData),
).rejects.toThrow("React Flow root not found in container");
});
it("throws when the React Flow viewport is missing inside the container", async () => {
const container = document.createElement("div");
const reactFlow = document.createElement("div");
reactFlow.className = "react-flow";
container.appendChild(reactFlow);
await expect(
exportGraphAsPNG(container, bounds, undefined, graphData),
).rejects.toThrow("React Flow viewport not found in container");
});
it("throws when bounds are null (no nodes to export)", async () => {
const container = buildContainerWithViewport();
await expect(
exportGraphAsPNG(container, null, undefined, graphData),
).rejects.toThrow("No nodes to export");
});
it("throws when graph data has no nodes", async () => {
const container = buildContainerWithViewport();
await expect(
exportGraphAsPNG(container, bounds, undefined, { nodes: [] }),
).rejects.toThrow("No nodes to export");
});
it("renders graph data to a PNG download", async () => {
const container = buildContainerWithViewport();
const appendChild = vi.spyOn(document.body, "appendChild");
await exportGraphAsPNG(container, bounds, "graph.png", graphData);
expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith(
"image/png",
);
const link = appendChild.mock.calls.find(
([element]) => element instanceof HTMLAnchorElement,
)?.[0] as HTMLAnchorElement | undefined;
expect(link?.download).toBe("graph.png");
expect(link?.href).toBe("data:image/png;base64,AAAA");
});
it("re-throws a generic export error when canvas is unavailable", async () => {
const container = buildContainerWithViewport();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
await expect(
exportGraphAsPNG(container, bounds, undefined, graphData),
).rejects.toThrow("Failed to export graph");
expect(consoleError).toHaveBeenCalled();
});
});
describe("exportGraphAsJSON", () => {
it("re-throws a generic export error when serialization fails", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
expect(() => exportGraphAsJSON(circular)).toThrow("Failed to export graph");
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
@@ -1,13 +1,49 @@
/**
* Export utilities for attack path graphs
* Handles exporting graph visualization to various formats
* Export utilities for attack path graphs.
*
* React Flow DOM screenshotting proved unreliable in-app, so PNG export draws a
* deterministic canvas from the same graph data + Dagre layout used by the UI.
*/
/**
* Helper function to download a blob as a file
* @param blob The blob to download
* @param filename The name of the file
*/
import type { Rect } from "@xyflow/react";
import type { AttackPathGraphData, GraphEdge } from "@/types/attack-paths";
import { truncateLabel } from "./format";
import {
getNodeBorderColor,
getNodeColor,
GRAPH_ALERT_BORDER_COLOR,
GRAPH_EDGE_COLOR_DARK,
} from "./graph-colors";
import { layoutWithDagre } from "./layout";
import { resolveNodeVisual } from "./node-visuals";
interface ExportGraphOptions {
expandedResources?: ReadonlySet<string>;
isFilteredView?: boolean;
selectedNodeId?: string | null;
}
interface Point {
x: number;
y: number;
}
const EXPORT_IMAGE_WIDTH = 1920;
const EXPORT_IMAGE_HEIGHT = 1080;
const EXPORT_BACKGROUND = "#1c1917";
const EXPORT_PADDING = 96;
const DOT_SPACING = 32;
const BADGE_RADIUS = 22;
const BADGE_CENTER_Y = 26;
const GLOW_RADIUS = 30;
const LABEL_Y = 66;
const LABEL_LINE_HEIGHT = 13;
const TYPE_Y = 94;
const RESOURCE_NAME_MAX_CHARS = 18;
const RESOURCE_NAME_MAX_LINES = 2;
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -19,106 +55,507 @@ const downloadBlob = (blob: Blob, filename: string) => {
URL.revokeObjectURL(url);
};
/**
* Export graph as SVG image
* @param svgElement The SVG element to export
* @param filename The name of the file to download
*/
export const exportGraphAsSVG = (
svgElement: SVGSVGElement | null,
filename: string = "attack-path-graph.svg",
const downloadDataUrl = (dataUrl: string, filename: string) => {
const link = document.createElement("a");
link.href = dataUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const isFindingNode = (labels: string[]) =>
labels.some((label) => label.toLowerCase().includes("finding"));
const getGraphEdges = (graphData: AttackPathGraphData): GraphEdge[] => {
if (graphData.edges?.length) return graphData.edges;
return (graphData.relationships ?? []).map((relationship) => ({
id: relationship.id,
source: relationship.source,
target: relationship.target,
type: relationship.label,
properties: relationship.properties,
}));
};
const getVisibleGraphData = (
graphData: AttackPathGraphData,
options: ExportGraphOptions = {},
): AttackPathGraphData => {
if (options.isFilteredView) return graphData;
const edges = getGraphEdges(graphData);
const expandedResources = options.expandedResources ?? new Set<string>();
const nodeById = new Map(graphData.nodes.map((node) => [node.id, node]));
const hiddenFindingIds = new Set<string>();
graphData.nodes.forEach((node) => {
if (!isFindingNode(node.labels)) return;
const connectedResources = edges
.flatMap((edge) => {
if (edge.source === node.id) return [edge.target];
if (edge.target === node.id) return [edge.source];
return [];
})
.filter((id) => {
const connectedNode = nodeById.get(id);
return connectedNode && !isFindingNode(connectedNode.labels);
});
const hasExpandedResource = connectedResources.some((resourceId) =>
expandedResources.has(resourceId),
);
if (connectedResources.length > 0 && !hasExpandedResource) {
hiddenFindingIds.add(node.id);
}
});
return {
nodes: graphData.nodes.filter((node) => !hiddenFindingIds.has(node.id)),
edges: edges.filter(
(edge) =>
!hiddenFindingIds.has(edge.source) &&
!hiddenFindingIds.has(edge.target),
),
};
};
const getResourcesWithFindings = (
sourceGraphData: AttackPathGraphData,
visibleGraphData: AttackPathGraphData,
) => {
if (!svgElement) return;
const visibleNodeIds = new Set(visibleGraphData.nodes.map((node) => node.id));
const sourceNodeById = new Map(
sourceGraphData.nodes.map((node) => [node.id, node]),
);
const findingNodeIds = new Set(
sourceGraphData.nodes
.filter((node) => isFindingNode(node.labels))
.map((node) => node.id),
);
const resourcesWithFindings = new Set<string>();
try {
// Clone the SVG element to avoid modifying the original
const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement;
getGraphEdges(sourceGraphData).forEach((edge) => {
const sourceIsFinding = findingNodeIds.has(edge.source);
const targetIsFinding = findingNodeIds.has(edge.target);
const sourceNode = sourceNodeById.get(edge.source);
const targetNode = sourceNodeById.get(edge.target);
// Find the main container group (first g element with transform)
const containerGroup = clonedSvg.querySelector("g");
if (!containerGroup) {
throw new Error("Could not find graph container");
if (
sourceIsFinding &&
targetNode &&
!isFindingNode(targetNode.labels) &&
visibleNodeIds.has(edge.target)
) {
resourcesWithFindings.add(edge.target);
}
// Get the bounding box of the actual graph content
// We need to get it from the original SVG since cloned elements don't have computed geometry
const originalContainer = svgElement.querySelector("g");
if (!originalContainer) {
throw new Error("Could not find original graph container");
if (
targetIsFinding &&
sourceNode &&
!isFindingNode(sourceNode.labels) &&
visibleNodeIds.has(edge.source)
) {
resourcesWithFindings.add(edge.source);
}
});
const bbox = originalContainer.getBBox();
return resourcesWithFindings;
};
// Add padding around the content
const padding = 50;
const contentWidth = bbox.width + padding * 2;
const contentHeight = bbox.height + padding * 2;
const getLabelLines = (label: string, maxChars: number, maxLines: number) => {
const words = label.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let current = "";
// Set the SVG dimensions to fit the content
clonedSvg.setAttribute("width", `${contentWidth}`);
clonedSvg.setAttribute("height", `${contentHeight}`);
clonedSvg.setAttribute(
"viewBox",
`${bbox.x - padding} ${bbox.y - padding} ${contentWidth} ${contentHeight}`,
);
words.forEach((word) => {
const next = current ? `${current} ${word}` : word;
if (next.length <= maxChars) {
current = next;
return;
}
if (current) lines.push(current);
current = word;
});
// Remove the zoom transform from the container - the viewBox now handles positioning
containerGroup.removeAttribute("transform");
if (current) lines.push(current);
if (lines.length === 0) lines.push(label);
// Add white background for better visibility
const bgRect = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect",
);
bgRect.setAttribute("x", `${bbox.x - padding}`);
bgRect.setAttribute("y", `${bbox.y - padding}`);
bgRect.setAttribute("width", `${contentWidth}`);
bgRect.setAttribute("height", `${contentHeight}`);
bgRect.setAttribute("fill", "#1c1917"); // Dark background matching the app
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
const visibleLines = lines.slice(0, maxLines);
if (lines.length > maxLines && visibleLines.length > 0) {
const lastIndex = visibleLines.length - 1;
visibleLines[lastIndex] = truncateLabel(visibleLines[lastIndex], maxChars);
}
const svgData = new XMLSerializer().serializeToString(clonedSvg);
const blob = new Blob([svgData], { type: "image/svg+xml" });
downloadBlob(blob, filename);
} catch (error) {
console.error("Failed to export graph as SVG:", error);
throw new Error("Failed to export graph");
return visibleLines;
};
const getFittedLayout = (graphData: AttackPathGraphData) => {
const { rfNodes, rfEdges } = layoutWithDagre(
graphData.nodes,
getGraphEdges(graphData),
);
if (rfNodes.length === 0) {
throw new Error("No nodes to export");
}
const minX = Math.min(...rfNodes.map((node) => node.position.x));
const minY = Math.min(...rfNodes.map((node) => node.position.y));
const maxX = Math.max(
...rfNodes.map((node) => node.position.x + (node.width ?? 0)),
);
const maxY = Math.max(
...rfNodes.map((node) => node.position.y + (node.height ?? 0)),
);
const graphWidth = Math.max(maxX - minX, 1);
const graphHeight = Math.max(maxY - minY, 1);
const scale = Math.min(
(EXPORT_IMAGE_WIDTH - EXPORT_PADDING * 2) / graphWidth,
(EXPORT_IMAGE_HEIGHT - EXPORT_PADDING * 2) / graphHeight,
2,
);
const offsetX = (EXPORT_IMAGE_WIDTH - graphWidth * scale) / 2 - minX * scale;
const offsetY =
(EXPORT_IMAGE_HEIGHT - graphHeight * scale) / 2 - minY * scale;
const toExportPoint = (x: number, y: number): Point => ({
x: x * scale + offsetX,
y: y * scale + offsetY,
});
return { rfNodes, rfEdges, toExportPoint };
};
const drawBackground = (context: CanvasRenderingContext2D) => {
context.fillStyle = EXPORT_BACKGROUND;
context.fillRect(0, 0, EXPORT_IMAGE_WIDTH, EXPORT_IMAGE_HEIGHT);
context.fillStyle = "rgba(68, 64, 60, 0.55)";
for (let x = 2; x < EXPORT_IMAGE_WIDTH; x += DOT_SPACING) {
for (let y = 2; y < EXPORT_IMAGE_HEIGHT; y += DOT_SPACING) {
context.beginPath();
context.arc(x, y, 1.4, 0, Math.PI * 2);
context.fill();
}
}
};
const movePointToward = (from: Point, to: Point, distance: number): Point => {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy);
if (length === 0) return from;
return {
x: from.x + (dx / length) * distance,
y: from.y + (dy / length) * distance,
};
};
const drawArrowHead = (
context: CanvasRenderingContext2D,
from: Point,
to: Point,
) => {
const angle = Math.atan2(to.y - from.y, to.x - from.x);
const size = 10;
context.beginPath();
context.moveTo(to.x, to.y);
context.lineTo(
to.x - size * Math.cos(angle - Math.PI / 6),
to.y - size * Math.sin(angle - Math.PI / 6),
);
context.lineTo(
to.x - size * Math.cos(angle + Math.PI / 6),
to.y - size * Math.sin(angle + Math.PI / 6),
);
context.closePath();
context.fill();
};
const drawEdges = (
context: CanvasRenderingContext2D,
edges: ReturnType<typeof layoutWithDagre>["rfEdges"],
getNodeCenter: (id: string) => Point | null,
) => {
context.strokeStyle = GRAPH_EDGE_COLOR_DARK;
context.fillStyle = GRAPH_EDGE_COLOR_DARK;
context.globalAlpha = 0.72;
context.lineWidth = 2;
edges.forEach((edge) => {
const source = getNodeCenter(edge.source);
const target = getNodeCenter(edge.target);
if (!source || !target) return;
const start = movePointToward(source, target, BADGE_RADIUS);
const end = movePointToward(target, source, BADGE_RADIUS + 8);
const midX = (start.x + end.x) / 2;
context.setLineDash(
edge.className?.includes("finding-edge") ? [10, 8] : [],
);
context.beginPath();
context.moveTo(start.x, start.y);
context.bezierCurveTo(midX, start.y, midX, end.y, end.x, end.y);
context.stroke();
context.setLineDash([]);
drawArrowHead(context, start, end);
});
context.globalAlpha = 1;
};
const drawShieldIcon = (
context: CanvasRenderingContext2D,
x: number,
y: number,
) => {
context.beginPath();
context.moveTo(x, y - 12);
context.quadraticCurveTo(x + 8, y - 8, x + 12, y - 8);
context.lineTo(x + 10, y + 3);
context.quadraticCurveTo(x + 8, y + 11, x, y + 14);
context.quadraticCurveTo(x - 8, y + 11, x - 10, y + 3);
context.lineTo(x - 12, y - 8);
context.quadraticCurveTo(x - 8, y - 8, x, y - 12);
context.stroke();
context.beginPath();
context.moveTo(x - 5, y);
context.lineTo(x - 1, y + 4);
context.lineTo(x + 7, y - 5);
context.stroke();
};
const drawKeyIcon = (
context: CanvasRenderingContext2D,
x: number,
y: number,
) => {
context.beginPath();
context.arc(x - 6, y - 2, 5, 0, Math.PI * 2);
context.moveTo(x - 1, y - 2);
context.lineTo(x + 12, y - 2);
context.moveTo(x + 8, y - 2);
context.lineTo(x + 8, y + 5);
context.moveTo(x + 12, y - 2);
context.lineTo(x + 12, y + 3);
context.stroke();
};
const drawFindingIcon = (
context: CanvasRenderingContext2D,
x: number,
y: number,
) => {
context.beginPath();
context.moveTo(x, y - 13);
context.lineTo(x + 13, y + 10);
context.lineTo(x - 13, y + 10);
context.closePath();
context.stroke();
context.font = "700 18px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("!", x, y + 3);
};
const drawNodeIcon = (
context: CanvasRenderingContext2D,
x: number,
y: number,
category: string,
description: string,
) => {
const lowerDescription = description.toLowerCase();
context.save();
context.strokeStyle = "#ffffff";
context.fillStyle = "#ffffff";
context.lineWidth = 2.2;
context.lineCap = "round";
context.lineJoin = "round";
if (category === "finding") {
drawFindingIcon(context, x, y);
} else if (lowerDescription.includes("policy statement")) {
context.font = "700 24px monospace";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("{}", x, y + 1);
} else if (lowerDescription.includes("policy")) {
drawKeyIcon(context, x, y);
} else if (category === "identity" || lowerDescription.includes("role")) {
drawShieldIcon(context, x, y);
} else if (category === "account") {
context.font = "700 11px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("AWS", x, y + 1);
} else {
context.font = "700 14px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(
description
.split(" ")
.map((word) => word[0])
.join("")
.slice(0, 2)
.toUpperCase(),
x,
y + 1,
);
}
context.restore();
};
const drawNode = (
context: CanvasRenderingContext2D,
graphNode: AttackPathGraphData["nodes"][number],
center: Point,
options: { hasFindings: boolean; selected: boolean },
) => {
const isFinding = isFindingNode(graphNode.labels);
const visual = resolveNodeVisual(graphNode);
const fill = getNodeColor(graphNode.labels, graphNode.properties);
const stroke = options.hasFindings
? GRAPH_ALERT_BORDER_COLOR
: getNodeBorderColor(graphNode.labels, graphNode.properties);
const glowOpacity = options.selected
? 0.34
: isFinding
? 0.28
: options.hasFindings
? 0.26
: 0;
const strokeWidth = options.selected
? 4
: options.hasFindings
? 3
: isFinding
? 2.5
: 1.5;
if (glowOpacity > 0) {
context.fillStyle = stroke;
context.globalAlpha = glowOpacity / 2;
context.beginPath();
context.arc(center.x, center.y, GLOW_RADIUS, 0, Math.PI * 2);
context.fill();
context.globalAlpha = 1;
}
context.fillStyle = fill;
context.strokeStyle = stroke;
context.lineWidth = strokeWidth;
context.beginPath();
context.arc(center.x, center.y, BADGE_RADIUS, 0, Math.PI * 2);
context.fill();
context.stroke();
const typeLabel = truncateLabel(visual.description, 22);
drawNodeIcon(context, center.x, center.y, visual.category, typeLabel);
context.fillStyle = "#ffffff";
context.textAlign = "center";
context.textBaseline = "middle";
context.font = "600 11px sans-serif";
getLabelLines(
visual.displayName,
RESOURCE_NAME_MAX_CHARS,
RESOURCE_NAME_MAX_LINES,
).forEach((line, index) => {
context.fillText(
line,
center.x,
center.y + (LABEL_Y - BADGE_CENTER_Y) + index * LABEL_LINE_HEIGHT,
150,
);
});
context.fillStyle = "rgba(255,255,255,0.82)";
context.font = "9px sans-serif";
context.fillText(
typeLabel,
center.x,
center.y + (TYPE_Y - BADGE_CENTER_Y),
150,
);
};
const renderGraphToPngDataUrl = (
graphData: AttackPathGraphData,
options?: ExportGraphOptions,
) => {
const canvas = document.createElement("canvas");
canvas.width = EXPORT_IMAGE_WIDTH;
canvas.height = EXPORT_IMAGE_HEIGHT;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas not available");
const visibleGraphData = getVisibleGraphData(graphData, options);
const resourcesWithFindings = getResourcesWithFindings(
graphData,
visibleGraphData,
);
const { rfNodes, rfEdges, toExportPoint } = getFittedLayout(visibleGraphData);
const nodeById = new Map(rfNodes.map((node) => [node.id, node]));
const getNodeCenter = (id: string) => {
const node = nodeById.get(id);
if (!node) return null;
return toExportPoint(
node.position.x + (node.width ?? 0) / 2,
node.position.y + BADGE_CENTER_Y,
);
};
drawBackground(context);
drawEdges(context, rfEdges, getNodeCenter);
rfNodes.forEach((rfNode) => {
const center = getNodeCenter(rfNode.id);
if (!center) return;
drawNode(context, rfNode.data.graphNode, center, {
hasFindings: resourcesWithFindings.has(rfNode.id),
selected: options?.selectedNodeId === rfNode.id,
});
});
return canvas.toDataURL("image/png");
};
/**
* Export graph as PNG image
* @param svgElement The SVG element to export
* @param filename The name of the file to download
* Export graph as PNG using graph data instead of DOM rasterization.
*/
export const exportGraphAsPNG = async (
svgElement: SVGSVGElement | null,
containerElement: HTMLDivElement | null,
bounds: Rect | null,
filename: string = "attack-path-graph.png",
graphData?: AttackPathGraphData | null,
options?: ExportGraphOptions,
) => {
if (!svgElement) return;
if (!containerElement) {
throw new Error("Graph container not mounted");
}
if (!containerElement.querySelector(".react-flow")) {
throw new Error("React Flow root not found in container");
}
if (!containerElement.querySelector(".react-flow__viewport")) {
throw new Error("React Flow viewport not found in container");
}
if (!bounds || !graphData?.nodes.length) {
throw new Error("No nodes to export");
}
try {
const svgData = new XMLSerializer().serializeToString(svgElement);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
if (!ctx) throw new Error("Could not get canvas context");
const svg = new Image();
svg.onload = () => {
canvas.width = svg.width;
canvas.height = svg.height;
ctx.drawImage(svg, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
downloadBlob(blob, filename);
}
});
};
svg.onerror = () => {
throw new Error("Failed to load SVG for PNG conversion");
};
svg.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
downloadDataUrl(renderGraphToPngDataUrl(graphData, options), filename);
} catch (error) {
console.error("Failed to export graph as PNG:", error);
throw new Error("Failed to export graph");
@@ -126,9 +563,7 @@ export const exportGraphAsPNG = async (
};
/**
* Export graph data as JSON
* @param graphData The graph data to export
* @param filename The name of the file to download
* Export graph data as JSON (format-agnostic — does not depend on DOM rendering).
*/
export const exportGraphAsJSON = (
graphData: Record<string, unknown>,
@@ -23,3 +23,7 @@ export function formatNodeLabel(label: string): string {
export function formatNodeLabels(labels: string[]): string {
return labels.map(formatNodeLabel).join(", ");
}
export function truncateLabel(text: string, maxChars: number): string {
return text.length > maxChars ? `${text.substring(0, maxChars)}...` : text;
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
GRAPH_ALERT_BORDER_COLOR,
GRAPH_EDGE_HIGHLIGHT_COLOR,
resolveNodeColors,
} from "./graph-colors";
describe("resolveNodeColors", () => {
it("prioritizes selected state over hasFindings for the border color", () => {
const selectedColors = resolveNodeColors({
labels: ["EC2Instance"],
selected: true,
hasFindings: true,
});
const alertOnlyColors = resolveNodeColors({
labels: ["EC2Instance"],
selected: false,
hasFindings: true,
});
expect(selectedColors.borderColor).toBe(GRAPH_EDGE_HIGHLIGHT_COLOR);
expect(alertOnlyColors.borderColor).toBe(GRAPH_ALERT_BORDER_COLOR);
});
});
@@ -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
@@ -128,6 +128,37 @@ export const getNodeBorderColor = (
return GRAPH_NODE_BORDER_COLORS.default;
};
interface ResolveNodeColorsParams {
labels: string[];
properties?: Record<string, unknown>;
selected?: boolean;
hasFindings?: boolean;
}
interface NodeColorResult {
fillColor: string;
borderColor: string;
}
/**
* Resolve fill and border colors for a graph node, layering selection and
* finding-alert state on top of the label/severity defaults.
*/
export const resolveNodeColors = ({
labels,
properties,
selected,
hasFindings,
}: ResolveNodeColorsParams): NodeColorResult => {
const fillColor = getNodeColor(labels, properties);
const borderColor = selected
? GRAPH_EDGE_HIGHLIGHT_COLOR
: hasFindings
? GRAPH_ALERT_BORDER_COLOR
: getNodeBorderColor(labels, properties);
return { fillColor, borderColor };
};
/**
* Check if a background color is light (for determining text color)
*/
@@ -4,21 +4,40 @@
import type { AttackPathGraphData } from "@/types/attack-paths";
/**
* Type for edge node reference - can be a string ID or an object with id property
* Note: We use `object` to match GraphEdge type from attack-paths.ts
*/
export type EdgeNodeRef = string | object;
export const resolveHiddenFindingIds = ({
expandedResources,
findingNodeIds,
findingToResources,
isFilteredView,
}: {
expandedResources: ReadonlySet<string>;
findingNodeIds: ReadonlySet<string>;
findingToResources: ReadonlyMap<string, ReadonlySet<string>>;
isFilteredView: boolean;
}): Set<string> => {
const hiddenFindingIds = new Set<string>();
/**
* Helper to get edge source/target ID from string or object
*/
export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => {
if (typeof nodeRef === "string") {
return nodeRef;
if (isFilteredView) {
return hiddenFindingIds;
}
// Edge node references are objects with an id property
return (nodeRef as { id: string }).id;
findingNodeIds.forEach((findingId) => {
const connectedResources = findingToResources.get(findingId);
if (!connectedResources) {
return;
}
const anyExpanded = Array.from(connectedResources).some((resourceId) =>
expandedResources.has(resourceId),
);
if (!anyExpanded) {
hiddenFindingIds.add(findingId);
}
});
return hiddenFindingIds;
};
/**
@@ -44,10 +63,8 @@ export const computeFilteredSubgraph = (
});
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
forwardEdges.get(sourceId)?.add(targetId);
backwardEdges.get(targetId)?.add(sourceId);
forwardEdges.get(edge.source)?.add(edge.target);
backwardEdges.get(edge.target)?.add(edge.source);
});
const visibleNodeIds = new Set<string>();
@@ -84,35 +101,30 @@ export const computeFilteredSubgraph = (
traverseDownstream(targetNodeId);
// Also include findings directly connected to the selected node
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
const sourceNode = nodes.find((n) => n.id === sourceId);
const targetNode = nodes.find((n) => n.id === targetId);
const sourceIsFinding = sourceNode?.labels.some((l) =>
const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
);
const targetIsFinding = targetNode?.labels.some((l) =>
const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
);
// Include findings connected to the selected node
if (sourceId === targetNodeId && targetIsFinding) {
visibleNodeIds.add(targetId);
if (edge.source === targetNodeId && targetIsFinding) {
visibleNodeIds.add(edge.target);
}
if (targetId === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(sourceId);
if (edge.target === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(edge.source);
}
});
// Filter nodes and edges to only include visible ones
const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id));
const filteredEdges = edges.filter((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
});
const filteredEdges = edges.filter(
(edge) =>
visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target),
);
return {
nodes: filteredNodes,
@@ -1,9 +1,5 @@
export {
exportGraphAsJSON,
exportGraphAsPNG,
exportGraphAsSVG,
} from "./export";
export { formatNodeLabel, formatNodeLabels } from "./format";
export { exportGraphAsJSON, exportGraphAsPNG } from "./export";
export { formatNodeLabel, formatNodeLabels, truncateLabel } from "./format";
export {
getNodeBorderColor,
getNodeColor,
@@ -14,10 +10,17 @@ export {
GRAPH_NODE_BORDER_COLORS,
GRAPH_NODE_COLORS,
GRAPH_SELECTION_COLOR,
resolveNodeColors,
} from "./graph-colors";
export {
computeFilteredSubgraph,
type EdgeNodeRef,
getEdgeNodeId,
getPathEdges,
resolveHiddenFindingIds,
} from "./graph-utils";
export { layoutWithDagre } from "./layout";
export {
NODE_CATEGORY,
type NodeCategory,
type NodeVisual,
resolveNodeVisual,
} from "./node-visuals";
@@ -0,0 +1,275 @@
import { Position } from "@xyflow/react";
import { describe, expect, it } from "vitest";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
import { layoutWithDagre } from "./layout";
const findingNode: GraphNode = {
id: "finding-1",
labels: ["ProwlerFinding"],
properties: { check_title: "Open S3 bucket", severity: "high" },
};
const resourceNode: GraphNode = {
id: "resource-1",
labels: ["S3Bucket"],
properties: { name: "bucket-1" },
};
const internetNode: GraphNode = {
id: "internet-1",
labels: ["Internet"],
properties: {},
};
describe("layoutWithDagre", () => {
it("returns empty arrays for empty input", () => {
const result = layoutWithDagre([], []);
expect(result.rfNodes).toEqual([]);
expect(result.rfEdges).toEqual([]);
});
it("assigns node types and dimensions from labels", () => {
const { rfNodes } = layoutWithDagre(
[findingNode, resourceNode, internetNode],
[],
);
const byId = new Map(rfNodes.map((n) => [n.id, n]));
expect(byId.get("finding-1")).toMatchObject({
type: "finding",
width: 150,
height: 112,
});
expect(byId.get("resource-1")).toMatchObject({
type: "resource",
width: 136,
height: 112,
});
expect(byId.get("internet-1")).toMatchObject({
type: "internet",
width: 80,
height: 80,
});
});
it("is deterministic: same input produces equal output across runs", () => {
const nodes = [findingNode, resourceNode];
const edges: GraphEdge[] = [
{
id: "e1",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
];
const a = layoutWithDagre(nodes, edges);
const b = layoutWithDagre(nodes, edges);
expect(a).toEqual(b);
});
it("places connected children to the right and stacks siblings within the horizontal rank", () => {
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 rootPosition = rfNodes.find(
(candidate) => candidate.id === "root",
)?.position;
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(rootPosition).toBeDefined();
siblingPositions.forEach((position) => {
expect(position.x).toBeGreaterThan(rootPosition?.x ?? 0);
});
expect(ySpread).toBeGreaterThan(xSpread);
});
it("connects edges through right and left node sides for horizontal 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.Right);
expect(node.targetPosition).toBe(Position.Left);
});
});
it("offsets dagre center positions by half of the node dimensions (top-left)", () => {
const { rfNodes } = layoutWithDagre([findingNode, resourceNode], []);
rfNodes.forEach((node) => {
expect(Number.isFinite(node.position.x)).toBe(true);
expect(Number.isFinite(node.position.y)).toBe(true);
});
// Different node types must end up with different sizes — confirms the
// dimension-aware offset is wired up.
const findingDims = rfNodes.find((n) => n.id === "finding-1");
const resourceDims = rfNodes.find((n) => n.id === "resource-1");
expect(findingDims?.width).not.toEqual(resourceDims?.width);
});
it("reverses container relationships while preserving original endpoints in edge data", () => {
const containerNode: GraphNode = {
id: "container",
labels: ["AWSAccount"],
properties: { name: "acct" },
};
const childNode: GraphNode = {
id: "child",
labels: ["S3Bucket"],
properties: { name: "bucket" },
};
const { rfEdges } = layoutWithDagre(
[containerNode, childNode],
[
{
id: "e1",
source: "container",
target: "child",
type: "RUNS_IN",
},
],
);
expect(rfEdges).toHaveLength(1);
expect(rfEdges[0]).toMatchObject({
source: "child",
target: "container",
data: { originalSource: "container", originalTarget: "child" },
});
});
it("animates edges that touch a finding node and tags them with finding-edge", () => {
const { rfEdges } = layoutWithDagre(
[findingNode, resourceNode, internetNode],
[
{
id: "e1",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
{
id: "e2",
source: "internet-1",
target: "resource-1",
type: "CONNECTS_TO",
},
],
);
const findingEdge = rfEdges.find(
(e) => e.source === "resource-1" && e.target === "finding-1",
);
const plainEdge = rfEdges.find(
(e) => e.source === "internet-1" && e.target === "resource-1",
);
expect(findingEdge).toMatchObject({
animated: true,
className: "finding-edge",
});
expect(plainEdge).toMatchObject({
animated: false,
className: "resource-edge",
});
});
it("preserves the original edge id when the graph has a single edge", () => {
const { rfEdges } = layoutWithDagre(
[findingNode, resourceNode],
[
{
id: "ignored-by-rf",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
],
);
expect(rfEdges[0]?.id).toBe("ignored-by-rf");
});
it("preserves parallel edges between the same nodes with unique ids", () => {
const { rfEdges } = layoutWithDagre(
[resourceNode, findingNode],
[
{
id: "edge-1",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
{
id: "edge-2",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
],
);
expect(rfEdges).toHaveLength(2);
expect(rfEdges.map((edge) => edge.id)).toEqual(["edge-1", "edge-2"]);
expect(rfEdges.every((edge) => edge.source === "resource-1")).toBe(true);
expect(rfEdges.every((edge) => edge.target === "finding-1")).toBe(true);
});
});
@@ -0,0 +1,161 @@
/**
* Pure Dagre layout adapter for React Flow
* Converts normalized GraphNode[] + GraphEdge[] to positioned RF nodes
*/
import { Graph, layout as dagreLayout } from "@dagrejs/dagre";
import { type Edge, type Node, Position } from "@xyflow/react";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
// 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
const CONTAINER_RELATIONS = new Set([
"RUNS_IN",
"BELONGS_TO",
"LOCATED_IN",
"PART_OF",
]);
interface NodeData extends Record<string, unknown> {
graphNode: GraphNode;
}
const NODE_TYPE = {
FINDING: "finding",
INTERNET: "internet",
RESOURCE: "resource",
} as const;
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
export const isFindingNode = (labels: string[]): boolean =>
labels.some((l) => l.toLowerCase().includes("finding"));
const getNodeType = (labels: string[]): NodeType => {
if (isFindingNode(labels)) return NODE_TYPE.FINDING;
if (labels.some((l) => l.toLowerCase() === "internet"))
return NODE_TYPE.INTERNET;
return NODE_TYPE.RESOURCE;
};
const getNodeDimensions = (
type: NodeType,
): { width: number; height: number } => {
if (type === NODE_TYPE.FINDING)
return { width: FINDING_NODE_WIDTH, height: FINDING_NODE_HEIGHT };
if (type === NODE_TYPE.INTERNET)
return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER };
return { width: RESOURCE_NODE_WIDTH, height: RESOURCE_NODE_HEIGHT };
};
/**
* Pure layout function: computes positioned React Flow nodes from graph data.
* Deterministic — same inputs always produce same outputs.
*/
export const layoutWithDagre = (
nodes: GraphNode[],
edges: GraphEdge[],
): { rfNodes: Node<NodeData>[]; rfEdges: Edge[] } => {
const g = new Graph({ multigraph: true });
g.setGraph({
rankdir: "LR",
nodesep: 80,
ranksep: 150,
marginx: 50,
marginy: 50,
});
g.setDefaultEdgeLabel(() => ({}));
// Add nodes with type-based dimensions
nodes.forEach((node) => {
const type = getNodeType(node.labels);
const { width, height } = getNodeDimensions(type);
g.setNode(node.id, { label: node.id, width, height });
});
// Add edges, reversing container relationships for proper hierarchy
edges.forEach((edge) => {
let sourceId = edge.source;
let targetId = edge.target;
if (CONTAINER_RELATIONS.has(edge.type)) {
[sourceId, targetId] = [targetId, sourceId];
}
if (sourceId && targetId) {
g.setEdge(
sourceId,
targetId,
{
id: edge.id,
originalSource: edge.source,
originalTarget: edge.target,
},
edge.id,
);
}
});
dagreLayout(g);
// Build RF nodes from layout
const rfNodes: Node<NodeData>[] = nodes.map((node) => {
const dagreNode = g.node(node.id);
const type = getNodeType(node.labels);
const { width, height } = getNodeDimensions(type);
return {
id: node.id,
type,
position: {
x: dagreNode.x - width / 2,
y: dagreNode.y - height / 2,
},
sourcePosition: Position.Right,
targetPosition: Position.Left,
data: { graphNode: node },
width,
height,
};
});
// Build RF edges from dagre edges (using layout order, not original)
const rfEdges: Edge[] = g
.edges()
.map((e: { v: string; w: string; name?: string }) => {
const edgeData = g.edge(e) as {
id?: string;
originalSource: string;
originalTarget: string;
};
// Check if either end is a finding node
const sourceNode = nodes.find((n) => n.id === e.v);
const targetNode = nodes.find((n) => n.id === e.w);
const hasFinding =
isFindingNode(sourceNode?.labels ?? []) ||
isFindingNode(targetNode?.labels ?? []);
return {
id: edgeData.id ?? e.name ?? `${e.v}-${e.w}`,
source: e.v,
target: e.w,
animated: hasFinding,
className: hasFinding ? "finding-edge" : "resource-edge",
data: {
pathKey: `${e.v}-${e.w}`,
originalSource: edgeData.originalSource,
originalTarget: edgeData.originalTarget,
},
};
});
return { rfNodes, rfEdges };
};
@@ -0,0 +1,474 @@
import {
AlertTriangle,
Bot,
Braces,
CircleAlert,
FileKey2,
Globe2,
Info,
KeyRound,
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 {
AmazonEC2Icon,
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.each([
["AWSAccount", NODE_CATEGORY.ACCOUNT, "AWS Account", AWSProviderBadge],
["S3Bucket", NODE_CATEGORY.STORAGE, "S3 Bucket", AmazonS3Icon],
["S3", NODE_CATEGORY.STORAGE, "S3", AmazonS3Icon],
["VPC", NODE_CATEGORY.NETWORK, "VPC", AmazonVPCIcon],
["Subnet", NODE_CATEGORY.NETWORK, "Subnet", Waypoints],
["SecurityGroup", NODE_CATEGORY.NETWORK, "Security Group", Shield],
["InternetGateway", NODE_CATEGORY.NETWORK, "Internet Gateway", Route],
["DefaultGateway", NODE_CATEGORY.NETWORK, "Default Gateway", Route],
["EC2Instance", NODE_CATEGORY.COMPUTE, "EC2 Instance", AmazonEC2Icon],
[
"VirtualMachine",
NODE_CATEGORY.COMPUTE,
"Virtual Machine",
AmazonEC2Icon,
],
["Compute", NODE_CATEGORY.COMPUTE, "Compute", Server],
["NIC", NODE_CATEGORY.COMPUTE, "NIC", Server],
["IAMUser", NODE_CATEGORY.IDENTITY, "IAM User", AWSIAMIcon],
["IAMRole", NODE_CATEGORY.IDENTITY, "IAM Role", AWSIAMIcon],
["ServiceAccount", NODE_CATEGORY.IDENTITY, "Service Account", Bot],
["AccessKey", NODE_CATEGORY.SECRET, "Access Key", KeyRound],
["Secret", NODE_CATEGORY.SECRET, "Secret", KeyRound],
] as const)(
"should map %s to %s with the expected icon",
(label, category, description, Icon) => {
// Given
const node = buildNode([label]);
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category,
description,
fallbackUsed: false,
});
expect(visual.Icon).toBe(Icon);
},
);
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,
};
};
@@ -0,0 +1,620 @@
/**
* Browser-mode tests for <AttackPathsPage />.
*
* Tests are grouped by user-perceived flow, not by internal spec taxonomy. Each
* test interacts with the page ONLY through `AttackPathPageHarness`. Each test:
* 1. picks a fixture
* 2. calls `mountWith(fx)` — wires MSW handlers, sets the URL, mounts the page
* 3. drives the harness
*
* If you find yourself reaching for a DOM query in a test, push it into the harness.
*/
import { beforeEach, describe, expect, test as base, vi } from "vitest";
import { handlersForFixture } from "@/__tests__/msw/handlers/attack-paths";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
const { getFindingByIdMock } = vi.hoisted(() => ({
getFindingByIdMock: vi.fn(),
}));
vi.mock("@/actions/findings", async () => {
const actual =
await vi.importActual<typeof import("@/actions/findings")>(
"@/actions/findings",
);
getFindingByIdMock.mockImplementation(actual.getFindingById);
return {
...actual,
getFindingById: getFindingByIdMock,
};
});
import { useGraphStore } from "./_hooks/use-graph-state";
import { getPathEdges } from "./_lib";
import { isFindingNode, layoutWithDagre } from "./_lib/layout";
import AttackPathsPage from "./attack-paths-page";
import { fixtures, type PageFixture } from "./attack-paths-page.fixtures";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
interface Fixtures {
mountWith: (fx?: PageFixture) => Promise<AttackPathPageHarness>;
}
// The graph store is module-scoped, so it survives across tests in the same
// file. Reset it before each test so no test sees stale state from a previous
// one (selection, filtered view, expanded resources, etc.).
beforeEach(() => {
useGraphStore.getState().reset();
getFindingByIdMock.mockClear();
});
const test = base.extend<Fixtures>({
mountWith: async ({}, use) => {
// `use` is Vitest's fixture-injection callback, not React's `use` hook.
// eslint-disable-next-line react-hooks/rules-of-hooks
await use(async (fx = fixtures.typical()) => {
worker.use(...handlersForFixture(fx));
window.history.replaceState({}, "", `/attack-paths?scanId=${fx.scanId}`);
await render(<AttackPathsPage />);
return new AttackPathPageHarness(fx);
});
},
});
describe("loading the page", () => {
test("an account with no scans shows the empty state", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.emptyScans());
expect(await graph.emptyStateMessage()).toMatch(/No scans available/i);
});
});
describe("running a query", () => {
test("the graph renders with a background, a minimap, and a viewport", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.background).toBeTruthy();
expect(graph.minimap).toBeTruthy();
expect(graph.viewport).toBeTruthy();
});
test("nodes are laid out at distinct positions", async ({ mountWith }) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
const positions = graph.nodePositions;
expect(positions.some((p) => p.x !== 0 || p.y !== 0)).toBe(true);
});
test("the toolbar exposes zoom, fit, and export controls", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(1);
expect(graph.toolbar.zoomInButton).toBeTruthy();
expect(graph.toolbar.zoomOutButton).toBeTruthy();
expect(graph.toolbar.fitButton).toBeTruthy();
expect(graph.toolbar.exportButton).toBeTruthy();
});
test("finding, resource, and internet nodes all render", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
expect(graph.findingNodes.length).toBeGreaterThan(0);
expect(graph.resourceNodes.length).toBeGreaterThan(0);
expect(graph.internetNodes.length).toBeGreaterThan(0);
});
test("only edges connected to a finding are animated", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
expect(graph.findingEdges.length).toBeGreaterThan(0);
expect(graph.resourceEdges.length).toBeGreaterThan(0);
});
test("edges connect string source and target ids", async ({ mountWith }) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(2);
const edgeIds = graph.renderedEdgeIds;
expect(edgeIds.length).toBeGreaterThan(0);
expect(new Set(edgeIds).size).toBe(edgeIds.length);
for (const id of edgeIds) {
expect(id.length).toBeGreaterThan(0);
}
});
test("a query that returns one node renders just that node", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.singleNode());
await graph.executeQuery();
await graph.waitForLayoutStable(1);
expect(graph.nodes).toHaveLength(1);
});
test("a query that returns no graph data surfaces an error without crashing", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.emptyGraph());
try {
await graph.executeQuery();
} catch {
/* expected: layout never stabilizes */
}
expect(graph.nodes).toHaveLength(0);
});
test("a 200-node graph finishes laying out within 5s", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.large(200));
const start = performance.now();
await graph.executeQuery();
await graph.waitForLayoutStable(1);
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(5000);
});
test("disconnected components are both visible", async ({ mountWith }) => {
const graph = await mountWith(fixtures.disconnected());
await graph.executeQuery();
await graph.waitForLayoutStable(4);
expect(graph.nodes.length).toBe(4);
});
test("a query that returns only resources renders no findings", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.resourcesOnly());
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.findingNodes.length).toBe(0);
expect(graph.resourceNodes.length).toBe(3);
});
test("findings without a connected resource stay visible in the full graph", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.findingsOnly());
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.findingNodes.length).toBe(3);
expect(graph.resourceNodes.length).toBe(0);
});
test("hidden findings do not reserve layout space until expanded", async ({
mountWith,
}) => {
// Given - a graph whose findings are hidden in the initial tier-1 view.
// The initial rendered positions should match a layout computed from only
// visible resources/edges, not from hidden finding nodes.
const fixture = fixtures.typical();
if (!fixture.queryResult) throw new Error("Expected graph fixture data");
const visibleNodes = fixture.queryResult.nodes.filter(
(node) => !isFindingNode(node.labels),
);
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
const visibleEdges = (fixture.queryResult.relationships ?? [])
.filter(
(edge) =>
visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target),
)
.map((edge) => ({
...edge,
type: edge.label,
}));
const expectedPositions = Object.fromEntries(
layoutWithDagre(visibleNodes, visibleEdges).rfNodes.map((node) => [
node.id,
node.position,
]),
);
const graph = await mountWith(fixture);
await graph.executeQuery();
await graph.waitForLayoutStable(3);
// Then - hidden findings do not influence initial resource coordinates.
for (const node of visibleNodes) {
expect(graph.nodePositionsById[node.id]).toEqual(
expectedPositions[node.id],
);
}
});
test("self-loops, cycles, long labels, unicode, and duplicate edges all render", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.edgeCases());
await graph.executeQuery();
await graph.waitForLayoutStable(5);
expect(graph.nodes.length).toBe(7);
expect(graph.containsText(/🔒-secure-bucket-日本語/)).toBe(true);
});
});
describe("exploring the graph", () => {
test("clicking a finding opens the filtered view and finding details", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
expect(graph.isInFilteredView).toBe(false);
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
expect(getFindingByIdMock).toHaveBeenCalledTimes(1);
expect(graph.hasNodeDetailsModal).toBe(false);
expect(graph.hasNodeActionDialog).toBe(false);
});
test("clicking a resource with findings directly reveals related finding nodes", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.findingNodes.length).toBe(0);
expect(graph.hasNodeDetailsModal).toBe(false);
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
expect(graph.hasNodeDetailsModal).toBe(false);
expect(graph.hasNodeActionDialog).toBe(false);
});
test("clicking an expanded resource with findings hides its related finding nodes", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBe(0);
expect(graph.hasNodeDetailsModal).toBe(false);
expect(graph.hasNodeActionDialog).toBe(false);
});
test("clicking a resource with 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();
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("clicking an expanded resource re-fits the remaining visible graph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitForTransition();
const expandedViewport = graph.viewportTransform;
await graph.clickFirstResourceNode();
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();
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();
expect(graph.findingNodes.length).toBeGreaterThan(0);
expect(graph.viewportTransform).toBeTruthy();
});
test("clicking a resource without findings does nothing", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.hasNodeDetailsModal).toBe(false);
expect(graph.hasNodeActionDialog).toBe(false);
expect(graph.findingNodes.length).toBe(0);
await graph.clickFirstResourceNodeWithoutFindings();
expect(graph.findingNodes.length).toBe(0);
expect(graph.hasNodeDetailsModal).toBe(false);
expect(graph.hasNodeActionDialog).toBe(false);
});
test("exiting the filtered view restores the full graph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
const fullNodes = graph.nodes.length;
await graph.clickFirstFindingNode();
await graph.exitFilteredView();
await graph.waitForLayoutStable(fullNodes);
expect(graph.isInFilteredView).toBe(false);
});
test("hovering a node highlights its path edges", async ({ mountWith }) => {
const fixture = fixtures.typical();
const graph = await mountWith(fixture);
await graph.executeQuery();
await graph.waitForLayoutStable(3);
const hoveredNodeId = graph.resourceNodes[0]?.getAttribute("data-id");
expect(hoveredNodeId).toBeTruthy();
const findingIds = new Set(
(fixture.queryResult?.nodes ?? [])
.filter((node) => isFindingNode(node.labels))
.map((node) => node.id),
);
const visibleEdges = (fixture.queryResult?.relationships ?? [])
.filter(
(edge) => !findingIds.has(edge.source) && !findingIds.has(edge.target),
)
.map((edge) => ({ sourceId: edge.source, targetId: edge.target }));
const expectedPathKeys = getPathEdges(hoveredNodeId ?? "", visibleEdges);
const expectedHighlightedIds = (fixture.queryResult?.relationships ?? [])
.filter((edge) => expectedPathKeys.has(`${edge.source}-${edge.target}`))
.map((edge) => edge.id)
.sort();
await graph.hoverFirstResourceNode();
await graph.waitForTransition(120);
expect(
graph.highlightedEdges.map((edge) => edge.dataset.id ?? "").sort(),
).toEqual(expectedHighlightedIds);
await graph.unhoverNodes();
await graph.waitForTransition(120);
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,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickEmptyCanvas();
expect(graph.isInFilteredView).toBe(false);
});
test("rapid clicks on a finding don't duplicate the filtered view", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
await graph.rapidlyClickFirstFindingNode(2);
expect(graph.isInFilteredView).toBe(true);
expect(getFindingByIdMock).toHaveBeenCalledTimes(1);
});
test("double-clicking a node doesn't break state", async ({ mountWith }) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.dblClickFirstResourceNode();
expect(graph.nodes.length).toBeGreaterThan(0);
});
});
describe("auto-fitting the viewport", () => {
test("the minimap viewport indicator has a visible border", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.minimapMaskStrokeWidth).toBeGreaterThan(0);
});
test("expanding resources re-fits the viewport when revealed findings fall off-screen", async ({
mountWith,
}) => {
const graph = await mountWith();
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
// newly visible findings off-screen.
const before = graph.viewportTransform;
expect(before).toBeTruthy();
await graph.expandAllFindings();
await graph.waitForTransition();
expect(graph.viewportTransform).not.toBe(before);
});
test("clicking a finding re-fits the viewport for the filtered subgraph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
const beforeFilter = graph.viewportTransform;
expect(beforeFilter).toBeTruthy();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.waitForTransition();
expect(graph.viewportTransform).not.toBe(beforeFilter);
});
test("Back to Full View re-fits the viewport for the full graph", 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.waitForTransition();
const filterT = graph.viewportTransform;
await graph.exitFilteredView();
await graph.waitForLayoutStable(3);
await graph.waitForTransition();
expect(graph.viewportTransform).not.toBe(filterT);
});
});
describe("exporting the graph", () => {
test("the export button is enabled when a graph is rendered", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.toolbar.isExportButtonEnabled).toBe(true);
});
test("clicking export downloads a PNG sized to the configured export canvas", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
const png = await graph.captureExportPNG();
expect(png.filename).toBe("attack-path-graph.png");
expect(png.mimeType).toBe("image/png");
expect(png.width).toBe(1920);
expect(png.height).toBe(1080);
});
});
describe("running a different query", () => {
test("the previous filtered view is cleared", 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.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.isInFilteredView).toBe(false);
});
});
@@ -0,0 +1,279 @@
/**
* Typed fixture builders for <AttackPathsPage /> browser tests.
*
* Each builder returns a self-contained snapshot of the API surface the page
* exercises: scans list, available queries, and query execution result. The
* MSW handler factory in the harness turns a fixture into HTTP mocks.
*/
import type {
AttackPathQuery,
AttackPathScan,
GraphNode,
GraphRelationship,
QueryResultAttributes,
} from "@/types/attack-paths";
export interface PageFixture {
scans: AttackPathScan[];
scanId: string;
queries: AttackPathQuery[];
queryId: string;
queryResult: QueryResultAttributes | null;
queryError?: { status: number; error: string };
}
const TYPICAL_SCAN_ID = "11111111-1111-4111-8111-111111111111";
const SECOND_SCAN_ID = "22222222-2222-4222-8222-222222222222";
const DEFAULT_QUERY_ID = "aws-public-s3-buckets";
const buildScan = (
id: string,
overrides: Partial<AttackPathScan["attributes"]> = {},
): AttackPathScan => ({
type: "attack-paths-scans",
id,
attributes: {
state: "completed",
progress: 100,
graph_data_ready: true,
provider_alias: `Provider ${id.slice(0, 4)}`,
provider_type: "aws",
provider_uid: `123456789${id.slice(0, 3)}`,
inserted_at: "2026-04-21T10:00:00Z",
started_at: "2026-04-21T10:00:00Z",
completed_at: "2026-04-21T10:05:00Z",
duration: 300,
...overrides,
},
relationships: {
provider: { data: { type: "providers", id: `provider-${id}` } },
scan: { data: { type: "scans", id: `base-scan-${id}` } },
task: { data: { type: "tasks", id: `task-${id}` } },
},
});
const buildQuery = (
id: string,
name: string,
overrides: Partial<AttackPathQuery["attributes"]> = {},
): AttackPathQuery => ({
type: "attack-paths-scans",
id,
attributes: {
name,
short_description: `Run the ${name} query`,
description: `Detailed description for ${name}.`,
provider: "aws",
parameters: [],
attribution: null,
documentation_link: null,
...overrides,
},
});
const buildResourceNode = (
id: string,
label: string,
name: string,
extraLabels: string[] = [],
): GraphNode => ({
id,
labels: [label, ...extraLabels],
properties: { id, name, arn: `arn:aws:example::${id}` },
});
const buildFindingNode = (
id: string,
title: string,
severity = "high",
): GraphNode => ({
id,
labels: ["ProwlerFinding"],
properties: { id, check_title: title, severity, status: "FAIL" },
});
const buildInternetNode = (): GraphNode => ({
id: "internet",
labels: ["Internet"],
properties: { id: "internet", name: "Internet" },
});
const buildRel = (
id: string,
source: string,
target: string,
label: string,
): GraphRelationship => ({ id, source, target, label });
export const typical = (): PageFixture => {
const nodes: GraphNode[] = [
buildInternetNode(),
buildResourceNode("ec2-1", "EC2Instance", "api-server-01"),
buildResourceNode("s3-1", "S3Bucket", "private-data-bucket"),
buildResourceNode("iam-1", "IAMRole", "AppRole"),
buildFindingNode("f-1", "S3 bucket is public", "critical"),
buildFindingNode("f-2", "EC2 exposed to internet", "high"),
];
const relationships: GraphRelationship[] = [
buildRel("r1", "internet", "ec2-1", "CAN_REACH"),
buildRel("r2", "ec2-1", "s3-1", "CAN_ACCESS"),
buildRel("r3", "ec2-1", "iam-1", "ASSUMES"),
buildRel("r4", "s3-1", "f-1", "HAS_FINDING"),
buildRel("r5", "ec2-1", "f-2", "HAS_FINDING"),
];
return {
scans: [buildScan(TYPICAL_SCAN_ID), buildScan(SECOND_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [
buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets"),
buildQuery("aws-open-security-groups", "Open security groups"),
],
queryId: DEFAULT_QUERY_ID,
queryResult: { nodes, relationships },
};
};
export const emptyScans = (): PageFixture => ({
scans: [],
scanId: TYPICAL_SCAN_ID,
queries: [],
queryId: DEFAULT_QUERY_ID,
queryResult: null,
});
export const emptyGraph = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets")],
queryId: DEFAULT_QUERY_ID,
queryResult: null,
queryError: { status: 404, error: "No data found" },
});
export const singleNode = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets")],
queryId: DEFAULT_QUERY_ID,
queryResult: {
nodes: [buildResourceNode("only-1", "S3Bucket", "solitary-bucket")],
relationships: [],
},
});
export const findingsOnly = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Findings only")],
queryId: DEFAULT_QUERY_ID,
queryResult: {
nodes: [
buildFindingNode("f-1", "Finding A", "critical"),
buildFindingNode("f-2", "Finding B", "high"),
buildFindingNode("f-3", "Finding C", "medium"),
],
relationships: [],
},
});
export const resourcesOnly = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Resources only")],
queryId: DEFAULT_QUERY_ID,
queryResult: {
nodes: [
buildResourceNode("ec2-1", "EC2Instance", "web-1"),
buildResourceNode("ec2-2", "EC2Instance", "web-2"),
buildResourceNode("s3-1", "S3Bucket", "logs"),
],
relationships: [
buildRel("r1", "ec2-1", "s3-1", "CAN_ACCESS"),
buildRel("r2", "ec2-2", "s3-1", "CAN_ACCESS"),
],
},
});
export const disconnected = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Disconnected components")],
queryId: DEFAULT_QUERY_ID,
queryResult: {
nodes: [
buildResourceNode("a-1", "EC2Instance", "alpha-ec2"),
buildResourceNode("a-2", "S3Bucket", "alpha-s3"),
buildResourceNode("b-1", "EC2Instance", "beta-ec2"),
buildResourceNode("b-2", "S3Bucket", "beta-s3"),
],
relationships: [
buildRel("r1", "a-1", "a-2", "CAN_ACCESS"),
buildRel("r2", "b-1", "b-2", "CAN_ACCESS"),
],
},
});
export const large = (count = 200): PageFixture => {
const nodes: GraphNode[] = [];
const relationships: GraphRelationship[] = [];
for (let i = 0; i < count; i++) {
const id = `n-${i}`;
if (i % 5 === 0) {
nodes.push(buildFindingNode(id, `Finding ${i}`, "high"));
} else {
nodes.push(buildResourceNode(id, "EC2Instance", `instance-${i}`));
}
if (i > 0) {
relationships.push(buildRel(`r-${i}`, `n-${i - 1}`, id, "CAN_REACH"));
}
}
return {
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Large graph")],
queryId: DEFAULT_QUERY_ID,
queryResult: { nodes, relationships },
};
};
export const edgeCases = (): PageFixture => {
const longLabel =
"a very long resource name that should be truncated ".repeat(4);
const nodes: GraphNode[] = [
buildResourceNode("self-1", "EC2Instance", "self-loop"),
buildResourceNode("cy-1", "EC2Instance", "cycle-a"),
buildResourceNode("cy-2", "EC2Instance", "cycle-b"),
buildResourceNode("long-1", "EC2Instance", longLabel),
buildResourceNode("emoji-1", "S3Bucket", "🔒-secure-bucket-日本語"),
buildResourceNode("dup-a", "EC2Instance", "dup-source"),
buildResourceNode("dup-b", "S3Bucket", "dup-target"),
];
const relationships: GraphRelationship[] = [
buildRel("self-edge", "self-1", "self-1", "REFERS_TO"),
buildRel("cy-a", "cy-1", "cy-2", "CAN_REACH"),
buildRel("cy-b", "cy-2", "cy-1", "CAN_REACH"),
buildRel("dup-1", "dup-a", "dup-b", "CAN_ACCESS"),
buildRel("dup-2", "dup-a", "dup-b", "CAN_ACCESS"),
];
return {
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [buildQuery(DEFAULT_QUERY_ID, "Edge cases")],
queryId: DEFAULT_QUERY_ID,
queryResult: { nodes, relationships },
};
};
export const fixtures = {
typical,
emptyScans,
emptyGraph,
singleNode,
findingsOnly,
resourcesOnly,
disconnected,
large,
edgeCases,
};
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { fixtures } from "./attack-paths-page.fixtures";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
describe("AttackPathPageHarness", () => {
it("should fail graph node clicks when browser pointer interaction fails", async () => {
// Given - A rendered graph node whose pointer interaction fails
const harness = new AttackPathPageHarness(fixtures.typical());
const node = document.createElement("div");
node.className = "react-flow__node react-flow__node-resource";
node.setAttribute("data-id", "ec2-1");
document.body.append(node);
const pointerError = new Error("pointer intercepted");
const clickSpy = vi
.spyOn(harness.user, "click")
.mockRejectedValue(pointerError);
const domClickSpy = vi.spyOn(node, "click");
// When / Then
await expect(harness.clickNode("ec2-1")).rejects.toThrow(
"pointer intercepted",
);
expect(clickSpy).toHaveBeenCalledWith(node);
expect(domClickSpy).not.toHaveBeenCalled();
});
it("should dispatch rapid finding clicks synchronously for race tests", async () => {
// Given - A rendered finding node and a harness rapid-click helper
const harness = new AttackPathPageHarness(fixtures.typical());
const finding = document.createElement("button");
finding.className = "react-flow__node react-flow__node-finding";
finding.setAttribute("data-id", "f-1");
document.body.append(finding);
const userClickSpy = vi.spyOn(harness.user, "click");
const domClickSpy = vi.spyOn(finding, "click");
vi.spyOn(harness, "waitForTransition").mockResolvedValue();
// When
await harness.rapidlyClickFirstFindingNode(2);
// Then
expect(userClickSpy).not.toHaveBeenCalled();
expect(domClickSpy).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,654 @@
/**
* Test harness for <AttackPathsPage /> browser-mode tests.
*
* Selectors + flows only. Mounting and MSW setup live in the test file.
*/
import { vi } from "vitest";
import { userEvent } from "vitest/browser";
import type { PageFixture } from "./attack-paths-page.fixtures";
export class AttackPathPageHarness {
private static readonly NODE_SEL = ".react-flow__node";
private static readonly EDGE_SEL = ".react-flow__edge";
private static readonly VIEWPORT_SEL = ".react-flow__viewport";
private static readonly MINIMAP_SEL = ".react-flow__minimap";
private static readonly BACKGROUND_SEL = ".react-flow__background";
private static isFindingElement(el: Element): boolean {
return (
el.classList.contains("react-flow__node-finding") ||
el.getAttribute("data-nodetype") === "finding"
);
}
private static isResourceElement(el: Element): boolean {
return (
el.classList.contains("react-flow__node-resource") ||
el.getAttribute("data-nodetype") === "resource"
);
}
private static isInternetElement(el: Element): boolean {
return (
el.classList.contains("react-flow__node-internet") ||
el.getAttribute("data-nodetype") === "internet"
);
}
readonly user = userEvent;
constructor(readonly fixture: PageFixture) {}
// --- Container ---
get container(): HTMLElement {
return document.body;
}
// --- Collections ---
get nodes(): HTMLElement[] {
return Array.from(
this.container.querySelectorAll<HTMLElement>(
AttackPathPageHarness.NODE_SEL,
),
);
}
get edges(): HTMLElement[] {
return Array.from(
this.container.querySelectorAll<HTMLElement>(
AttackPathPageHarness.EDGE_SEL,
),
);
}
get findingNodes(): HTMLElement[] {
return this.nodes.filter(AttackPathPageHarness.isFindingElement);
}
get resourceNodes(): HTMLElement[] {
return this.nodes.filter(AttackPathPageHarness.isResourceElement);
}
get internetNodes(): HTMLElement[] {
return this.nodes.filter(AttackPathPageHarness.isInternetElement);
}
get findingEdges(): HTMLElement[] {
return this.edges.filter((e) => e.classList.contains("finding-edge"));
}
get resourceEdges(): HTMLElement[] {
return this.edges.filter((e) => e.classList.contains("resource-edge"));
}
get highlightedEdges(): HTMLElement[] {
return this.edges.filter((e) => e.classList.contains("highlighted"));
}
get renderedNodeIds(): string[] {
return this.nodes.map((el) => el.getAttribute("data-id") ?? "");
}
get renderedEdgeIds(): string[] {
return this.edges.map((el) => el.getAttribute("data-id") ?? "");
}
/**
* Parsed `translate(x, y)` from each rendered node's transform style.
* Useful for asserting layout actually placed nodes (non-zero, distinct, …).
*/
get nodePositions(): Array<{ x: number; y: number }> {
return this.nodes.map((el) => {
const match = /translate\(\s*([-\d.]+)px?\s*,\s*([-\d.]+)px?\s*\)/.exec(
el.style.transform,
);
return match
? { x: Number(match[1]), y: Number(match[2]) }
: { x: 0, y: 0 };
});
}
get nodePositionsById(): Record<string, { x: number; y: number }> {
return Object.fromEntries(
this.nodes.map((el) => {
const id = el.getAttribute("data-id") ?? "";
const match = /translate\(\s*([-\d.]+)px?\s*,\s*([-\d.]+)px?\s*\)/.exec(
el.style.transform,
);
return [
id,
match ? { x: Number(match[1]), y: Number(match[2]) } : { x: 0, y: 0 },
];
}),
);
}
// --- Predicates ---
get isInFilteredView(): boolean {
return !!this.container.querySelector(
'[aria-label="Return to full graph view"]',
);
}
isNodeSelected(nodeId: string): boolean {
const el = this.getNodeById(nodeId);
return !!el && el.classList.contains("selected");
}
isEdgeHighlighted(edgeId: string): boolean {
const el = this.getEdgeById(edgeId);
return !!el && el.classList.contains("highlighted");
}
isNodeHidden(nodeId: string): boolean {
return !this.getNodeById(nodeId);
}
// --- Lookups ---
getNodeById(id: string): HTMLElement | null {
return this.container.querySelector<HTMLElement>(
`${AttackPathPageHarness.NODE_SEL}[data-id="${id}"]`,
);
}
getEdgeById(id: string): HTMLElement | null {
return this.container.querySelector<HTMLElement>(
`${AttackPathPageHarness.EDGE_SEL}[data-id="${id}"]`,
);
}
// --- Handles ---
private q(selector: string): HTMLElement | null {
return this.container.querySelector<HTMLElement>(selector);
}
get toolbar() {
const exportButton =
this.q('button[aria-label="Export graph"]') ??
this.q('button[aria-label="Export available soon"]');
return {
zoomInButton: this.q('button[aria-label="Zoom in"]'),
zoomOutButton: this.q('button[aria-label="Zoom out"]'),
fitButton: this.q('button[aria-label="Fit graph to view"]'),
exportButton,
isExportButtonEnabled:
!!exportButton && !(exportButton as HTMLButtonElement).disabled,
backToFullViewButton: this.q(
'button[aria-label="Return to full graph view"]',
),
fullscreenButton: this.q('button[aria-label="Fullscreen"]'),
};
}
// --- Page-level surface (alerts, raw text) ---
/** Wait for a `[role="alert"]` to appear and return its text. */
async emptyStateMessage(timeoutMs = 2000): Promise<string> {
const alert = await this.waitFor(
() => this.container.querySelector<HTMLElement>('[role="alert"]'),
timeoutMs,
);
return alert.textContent ?? "";
}
/** True when the rendered page contains text matching `pattern`. */
containsText(pattern: RegExp): boolean {
return pattern.test(this.container.textContent ?? "");
}
get minimap(): HTMLElement | null {
return this.q(AttackPathPageHarness.MINIMAP_SEL);
}
get background(): HTMLElement | null {
return this.q(AttackPathPageHarness.BACKGROUND_SEL);
}
get viewport(): HTMLElement | null {
return this.q(AttackPathPageHarness.VIEWPORT_SEL);
}
/**
* Inline `transform` of the React Flow viewport element. This is the
* pan/zoom matrix React Flow rewrites on every fit/zoom/pan, so comparing
* it before vs. after a user action is enough to assert that the viewport
* actually moved (or stayed put).
*/
get viewportTransform(): string {
return this.viewport?.style.transform ?? "";
}
/**
* `stroke-width` of the minimap mask SVG. The mask cuts out the area
* currently in view; the cut-out's border is what indicates the viewport
* inside the minimap. A non-zero stroke-width means the indicator has a
* visible border (rather than blending into the dark theme background).
*/
get minimapMaskStrokeWidth(): number {
const mask = this.minimap?.querySelector<SVGPathElement>(
".react-flow__minimap-mask",
);
if (!mask) return 0;
const inline = mask.getAttribute("stroke-width");
if (inline) return Number.parseFloat(inline);
const computed = getComputedStyle(mask).strokeWidth;
return Number.parseFloat(computed);
}
get fullscreenDialog(): HTMLElement | null {
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. */
async waitForLayoutStable(expected = 1, timeoutMs = 3000): Promise<void> {
await vi.waitFor(
() => {
if (this.nodes.length < expected) {
throw new Error(
`expected ${expected} nodes, got ${this.nodes.length}`,
);
}
},
{ timeout: timeoutMs, interval: 16 },
);
}
/** Wait until the predicate returns truthy and return that value. */
async waitFor<T>(
fn: () => T | null | undefined | false,
timeoutMs = 3000,
): Promise<T> {
return vi.waitFor(
() => {
const v = fn();
if (!v) throw new Error("waitFor predicate not yet truthy");
return v;
},
{ timeout: timeoutMs, interval: 16 },
) as Promise<T>;
}
async waitForTransition(ms = 350): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
// --- Action methods ---
private async clickElement(
element: HTMLElement,
options?: { fallbackToDomClick?: boolean },
): Promise<void> {
try {
await this.user.click(element);
} catch (error) {
if (!options?.fallbackToDomClick) throw error;
element.click();
}
}
private async clickGraphElement(element: HTMLElement): Promise<void> {
await this.closeFindingDrawerIfOpen();
await this.user.click(element);
}
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) {
await this.clickElement(closeButton);
await this.waitForTransition();
}
}
async selectQuery(queryId?: string): Promise<void> {
await this.closeFindingDrawerIfOpen();
const trigger = await this.waitFor<HTMLButtonElement>(
() =>
this.container.querySelector<HTMLButtonElement>(
'button[role="combobox"]',
),
10000,
);
await this.user.click(trigger);
const targetId = queryId ?? this.fixture.queryId;
const targetName = this.fixture.queries.find((q) => q.id === targetId)
?.attributes.name;
const option = await this.waitFor<HTMLElement>(
() =>
document.querySelector<HTMLElement>(
`[role="option"][data-value="${targetId}"]`,
) ??
Array.from(
document.querySelectorAll<HTMLElement>('[role="option"]'),
).find((el) => targetName && el.textContent?.includes(targetName)),
10000,
);
await this.user.click(option);
await this.waitForTransition();
}
async executeQuery(options: { selectFirst?: boolean } = {}): Promise<void> {
if (options.selectFirst !== false) {
await this.selectQuery();
}
const button = await this.waitFor<HTMLButtonElement>(
() =>
Array.from(
this.container.querySelectorAll<HTMLButtonElement>("button"),
).find(
(b) =>
!b.disabled &&
/execute query/i.test(b.textContent ?? "") &&
!/executing/i.test(b.textContent ?? ""),
),
10000,
);
await this.user.click(button);
await this.waitForLayoutStable(1, 10000);
}
async clickNode(nodeId: string): Promise<void> {
const el = this.getNodeById(nodeId);
if (!el) throw new Error(`clickNode: node "${nodeId}" not found`);
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.clickGraphElement(finding);
await this.waitForTransition();
return finding;
}
async clickFirstResourceNode(): Promise<HTMLElement> {
const [resource] = this.resourceNodes;
if (!resource)
throw new Error("clickFirstResourceNode: no resource rendered");
await this.clickGraphElement(resource);
await this.waitForTransition();
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.clickGraphElement(resource);
await this.waitForTransition();
return resource;
}
/**
* Dispatch same-tick clicks against the first finding node. This models the
* duplicate-click race before the drawer overlay can intercept later pointer
* actions, so it intentionally uses native DOM clicks instead of awaited
* user-event pointer interactions.
*/
async rapidlyClickFirstFindingNode(times = 2): Promise<HTMLElement> {
const [finding] = this.findingNodes;
if (!finding)
throw new Error("rapidlyClickFirstFindingNode: no finding rendered");
await this.closeFindingDrawerIfOpen();
for (let i = 0; i < times; i++) {
finding.click();
}
await this.waitForTransition();
return finding;
}
async dblClickFirstResourceNode(): Promise<HTMLElement> {
const [resource] = this.resourceNodes;
if (!resource)
throw new Error("dblClickFirstResourceNode: no resource rendered");
await this.user.dblClick(resource);
await this.waitForTransition();
return resource;
}
/**
* Click the React Flow background pane (anywhere not on a node/edge), used
* to verify that empty-canvas clicks don't open the filtered view.
*/
async clickEmptyCanvas(): Promise<void> {
const pane = this.q(".react-flow__pane") ?? this.q(".react-flow__renderer");
if (!pane) throw new Error("clickEmptyCanvas: pane not rendered");
await this.user.click(pane);
await this.waitForTransition();
}
/**
* Click every resource that the fixture's relationships connect to a finding.
* Findings are hidden by default in the full graph view (Tier 1) — clicking
* their adjacent resources reveals them.
*/
async expandAllFindings(): Promise<void> {
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);
}
for (const id of Array.from(resourceWithFindingIds)) {
const el = this.getNodeById(id);
if (el) {
await this.user.click(el);
await this.waitForTransition(50);
}
}
}
async hoverNode(nodeId: string): Promise<void> {
const el = this.getNodeById(nodeId);
if (!el) throw new Error(`hoverNode: node "${nodeId}" not found`);
await this.user.hover(el);
await this.waitForTransition(80);
}
async hoverFirstResourceNode(): Promise<HTMLElement> {
const [resource] = this.resourceNodes;
if (!resource)
throw new Error("hoverFirstResourceNode: no resource rendered");
await this.user.hover(resource);
await this.waitForTransition(80);
return resource;
}
async unhoverNodes(): Promise<void> {
const canvas =
this.q(".react-flow__pane") ?? this.q(".react-flow__renderer");
if (canvas) await this.user.hover(canvas);
await this.waitForTransition(80);
}
async zoomIn(): Promise<void> {
const btn = this.toolbar.zoomInButton;
if (!btn) throw new Error("zoomIn: toolbar not rendered");
await this.user.click(btn);
}
async zoomOut(): Promise<void> {
const btn = this.toolbar.zoomOutButton;
if (!btn) throw new Error("zoomOut: toolbar not rendered");
await this.user.click(btn);
}
async fit(): Promise<void> {
const btn = this.toolbar.fitButton;
if (!btn) throw new Error("fit: toolbar not rendered");
await this.user.click(btn);
}
async exitFilteredView(): Promise<void> {
await this.closeFindingDrawerIfOpen();
const btn = this.toolbar.backToFullViewButton;
if (!btn) throw new Error("exitFilteredView: not in filtered view");
await this.clickElement(btn);
await this.waitForTransition();
}
async openFullscreen(): Promise<void> {
const btn = this.toolbar.fullscreenButton;
if (!btn) throw new Error("openFullscreen: button not found");
await this.user.click(btn);
await this.waitFor(() => this.fullscreenDialog, 3000);
await this.waitForTransition();
}
async closeFullscreen(): Promise<void> {
const dialog = this.fullscreenDialog;
if (!dialog) return;
const close = dialog.querySelector<HTMLButtonElement>(
'button[aria-label="Close"]',
);
if (close) await this.user.click(close);
else await this.user.keyboard("{Escape}");
await this.waitForTransition();
}
async exportAsPNG(target: "main" | "fullscreen" = "main"): Promise<void> {
const scope =
target === "fullscreen" ? this.fullscreenDialog : this.container;
if (!scope) throw new Error("exportAsPNG: target scope missing");
const btn = scope.querySelector<HTMLButtonElement>(
'button[aria-label="Export graph"]',
);
if (!btn) throw new Error("exportAsPNG: export button disabled or missing");
await this.user.click(btn);
await this.waitForTransition(300);
}
/**
* Trigger an export and capture the resulting download as a structured
* record. Intercepts `HTMLAnchorElement.prototype.click` so the test
* environment doesn't actually navigate, and parses width/height from the
* PNG IHDR chunk so callers can assert on canvas size.
*/
async captureExportPNG(
target: "main" | "fullscreen" = "main",
timeoutMs = 10000,
): Promise<{
filename: string;
href: string;
mimeType: string;
width: number;
height: number;
}> {
const downloads: Array<{ href: string; download: string }> = [];
const originalClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function () {
if (this.download) {
downloads.push({ href: this.href, download: this.download });
return;
}
originalClick.call(this);
};
try {
await this.exportAsPNG(target);
await this.waitFor(() => downloads.length > 0, timeoutMs);
} finally {
HTMLAnchorElement.prototype.click = originalClick;
}
const [download] = downloads;
const [meta, base64 = ""] = download.href.split(",");
const mimeType = /^data:([^;]+)/.exec(meta ?? "")?.[1] ?? "";
// PNG IHDR chunk: bytes 16-19 = width (uint32 BE), 20-23 = height.
const bytes = atob(base64);
const u32BE = (offset: number) =>
((bytes.charCodeAt(offset) << 24) |
(bytes.charCodeAt(offset + 1) << 16) |
(bytes.charCodeAt(offset + 2) << 8) |
bytes.charCodeAt(offset + 3)) >>>
0;
return {
filename: download.download,
href: download.href,
mimeType,
width: u32BE(16),
height: u32BE(20),
};
}
}
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("AttackPathsPage", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "attack-paths-page.tsx");
const source = readFileSync(filePath, "utf8");
it("keeps the page description without rendering a duplicate Attack Paths heading", () => {
// Then
expect(source).not.toContain(">\n Attack Paths\n </h2>");
expect(source).toContain(
"Select a scan, build a query, and visualize Attack Paths in your",
);
});
});
@@ -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,17 +22,15 @@ import {
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
} from "@/components/shadcn";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/shadcn/dialog";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { useToast } from "@/components/ui";
import type {
AttackPathQuery,
@@ -48,17 +46,16 @@ import {
GraphControls,
GraphLegend,
GraphLoading,
NodeDetailContent,
QueryDescription,
QueryExecutionError,
QueryParametersForm,
QuerySelector,
ScanListTable,
} from "./_components";
import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph";
import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsSVG, formatNodeLabel } from "./_lib";
import { exportGraphAsPNG } from "./_lib";
/**
* Attack Paths
@@ -76,10 +73,10 @@ export default function AttackPathsPage() {
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const graphRef = useRef<AttackPathGraphRef>(null);
const fullscreenGraphRef = useRef<AttackPathGraphRef>(null);
const graphRef = useRef<GraphHandle>(null);
const fullscreenGraphRef = useRef<GraphHandle>(null);
const findingNavigationInFlightRef = useRef(false);
const hasResetRef = useRef(false);
const nodeDetailsRef = useRef<HTMLDivElement>(null);
const graphContainerRef = useRef<HTMLDivElement>(null);
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
@@ -95,6 +92,11 @@ export default function AttackPathsPage() {
}
}, [graphState]);
// Reset graph state when scan changes
useEffect(() => {
graphState.resetGraph();
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
// Load available scans on mount
useEffect(() => {
const loadScans = async () => {
@@ -189,10 +191,6 @@ export default function AttackPathsPage() {
loadQueries();
}, [scanId, toast]);
const handleQueryChange = (queryId: string) => {
queryBuilder.handleQueryChange(queryId);
};
const showErrorToast = (title: string, description: string) => {
toast({
title,
@@ -289,21 +287,39 @@ export default function AttackPathsPage() {
};
const handleNodeClick = (node: GraphNode) => {
// Enter filtered view showing only paths containing this node
graphState.enterFilteredView(node.id);
// For findings, also scroll to the details section
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
if (isFinding) {
setTimeout(() => {
nodeDetailsRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}, 100);
if (findingNavigationInFlightRef.current) {
return;
}
findingNavigationInFlightRef.current = true;
// 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);
void handleViewFinding(String(node.properties?.id || node.id));
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) {
graphState.toggleExpandedResource(node.id);
}
};
@@ -311,37 +327,42 @@ export default function AttackPathsPage() {
graphState.exitFilteredView();
};
const handleCloseDetails = () => {
graphState.selectNode(null);
};
const getFindingId = (node: GraphNode | null) =>
node ? String(node.properties?.id || node.id) : "";
const handleViewFinding = (findingId: string) => {
const handleViewFinding = async (findingId: string) => {
if (!findingId) return;
void finding.navigateToFinding(findingId);
try {
await finding.navigateToFinding(findingId);
} finally {
findingNavigationInFlightRef.current = false;
}
};
const handleGraphExport = (svgElement: SVGSVGElement | null) => {
const handleGraphExport = async (target: "main" | "fullscreen") => {
const ref = target === "fullscreen" ? fullscreenGraphRef : graphRef;
const handle = ref.current;
if (!handle) return;
try {
if (svgElement) {
exportGraphAsSVG(svgElement, "attack-path-graph.svg");
toast({
title: "Success",
description: "Graph exported as SVG",
variant: "default",
});
} else {
throw new Error("Could not find graph element");
}
} catch (error) {
await exportGraphAsPNG(
handle.getContainerElement(),
handle.getNodesBounds(),
"attack-path-graph.png",
graphState.data,
{
expandedResources: graphState.expandedResources,
isFilteredView: graphState.isFilteredView,
selectedNodeId: graphState.selectedNodeId,
},
);
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Failed to export graph",
variant: "destructive",
title: "Success",
description: "Graph exported",
variant: "default",
});
} catch (error) {
const description =
error instanceof Error ? error.message : "Failed to export graph";
showErrorToast("Export failed", description);
}
};
@@ -353,12 +374,9 @@ export default function AttackPathsPage() {
onRefresh={refreshScans}
/>
{/* Header */}
{/* Page introduction */}
<div>
<h2 className="dark:text-prowler-theme-pale/90 text-xl font-semibold">
Attack Paths
</h2>
<p className="text-text-neutral-secondary mt-2 text-sm">
<p className="text-text-neutral-secondary text-sm">
Select a scan, build a query, and visualize Attack Paths in your
infrastructure.
</p>
@@ -423,7 +441,7 @@ export default function AttackPathsPage() {
<QuerySelector
queries={queries}
selectedQueryId={queryBuilder.selectedQuery}
onQueryChange={handleQueryChange}
onQueryChange={queryBuilder.handleQueryChange}
/>
{queryBuilder.selectedQueryData && (
@@ -512,8 +530,9 @@ export default function AttackPathsPage() {
💡
</span>
<span className="flex-1">
Click on any node to filter and view its connected
paths
Click a finding to focus its connected path, or click
a resource with findings to show or hide its related
findings
</span>
</div>
)}
@@ -524,11 +543,7 @@ export default function AttackPathsPage() {
onZoomIn={() => graphRef.current?.zoomIn()}
onZoomOut={() => graphRef.current?.zoomOut()}
onFitToScreen={() => graphRef.current?.resetZoom()}
onExport={() =>
handleGraphExport(
graphRef.current?.getSVGElement() || null,
)
}
onExport={() => handleGraphExport("main")}
/>
{/* Fullscreen button */}
@@ -550,6 +565,10 @@ export default function AttackPathsPage() {
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 rounded-none border-0 p-0 sm:max-w-full">
<DialogHeader className="sr-only">
<DialogTitle>Fullscreen graph view</DialogTitle>
<DialogDescription>
Explore the attack path graph at full size. Use
the toolbar to zoom, fit, or export the graph.
</DialogDescription>
</DialogHeader>
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
<GraphControls
@@ -562,15 +581,10 @@ export default function AttackPathsPage() {
onFitToScreen={() =>
fullscreenGraphRef.current?.resetZoom()
}
onExport={() =>
handleGraphExport(
fullscreenGraphRef.current?.getSVGElement() ||
null,
)
}
onExport={() => handleGraphExport("fullscreen")}
/>
</div>
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6 lg:flex-row">
<div className="flex flex-1 items-center justify-center">
<AttackPathGraph
ref={fullscreenGraphRef}
@@ -578,64 +592,11 @@ export default function AttackPathsPage() {
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
expandedResources={
graphState.expandedResources
}
/>
</div>
{/* Node Detail Panel - Side by side */}
{graphState.selectedNode && (
<section aria-labelledby="node-details-heading">
<Card className="w-96 overflow-y-auto">
<CardContent className="p-4">
<div className="mb-4 flex items-center justify-between">
<h3
id="node-details-heading"
className="text-sm font-semibold"
>
Node Details
</h3>
<Button
onClick={handleCloseDetails}
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
aria-label="Close node details"
>
<X size={16} />
</Button>
</div>
<p className="text-text-neutral-secondary mb-4 text-xs">
{graphState.selectedNode?.labels.some(
(label) =>
label
.toLowerCase()
.includes("finding"),
)
? graphState.selectedNode?.properties
?.check_title ||
graphState.selectedNode?.properties
?.id ||
"Unknown Finding"
: graphState.selectedNode?.properties
?.name ||
graphState.selectedNode?.properties
?.id ||
"Unknown Resource"}
</p>
<div className="flex flex-col gap-4">
<div>
<h4 className="mb-2 text-xs font-semibold">
Type
</h4>
<p className="text-text-neutral-secondary text-xs">
{graphState.selectedNode?.labels
.map(formatNodeLabel)
.join(", ")}
</p>
</div>
</div>
</CardContent>
</Card>
</section>
)}
</div>
</DialogContent>
</Dialog>
@@ -654,82 +615,23 @@ export default function AttackPathsPage() {
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
expandedResources={graphState.expandedResources}
/>
</div>
{/* Legend below */}
<div className="hidden justify-center lg:flex">
<GraphLegend data={graphState.data} />
<div className="flex justify-center overflow-x-auto">
<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"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold">Node Details</h3>
<p className="text-text-neutral-secondary mt-1 text-sm">
{String(
graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
)
? graphState.selectedNode.properties?.check_title ||
graphState.selectedNode.properties?.id ||
"Unknown Finding"
: graphState.selectedNode.properties?.name ||
graphState.selectedNode.properties?.id ||
"Unknown Resource",
)}
</p>
</div>
<div className="flex items-center gap-2">
{graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
) && (
<Button
variant="default"
size="sm"
onClick={() =>
handleViewFinding(getFindingId(graphState.selectedNode))
}
disabled={finding.findingDetailLoading}
aria-label={`View finding ${getFindingId(graphState.selectedNode)}`}
>
{finding.findingDetailLoading ? (
<Spinner className="size-4" />
) : (
"View Finding"
)}
</Button>
)}
<Button
onClick={handleCloseDetails}
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Close node details"
>
<X size={16} />
</Button>
</div>
</div>
<NodeDetailContent
node={graphState.selectedNode}
allNodes={graphState.data.nodes}
onViewFinding={handleViewFinding}
viewFindingLoading={finding.findingDetailLoading}
/>
</div>
)}
{finding.findingDetails && (
<FindingDetailDrawer
key={finding.findingDetails.id}
+1 -1
View File
@@ -15,7 +15,7 @@ export function Navbar({ title, icon }: NavbarProps) {
title={title}
icon={icon}
feedsSlot={
<Suspense fallback={<FeedsLoadingFallback />}>
<Suspense key="feeds" fallback={<FeedsLoadingFallback />}>
<FeedsServer limit={15} />
</Suspense>
}
+66 -18
View File
@@ -39,6 +39,14 @@
"strategy": "installed",
"generatedAt": "2026-03-26T08:39:34.728Z"
},
{
"section": "dependencies",
"name": "@dagrejs/dagre",
"from": "3.0.0",
"to": "3.0.0",
"strategy": "installed",
"generatedAt": "2026-04-16T12:15:51.357Z"
},
{
"section": "dependencies",
"name": "@extractus/feed-extractor",
@@ -327,14 +335,6 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "@types/dagre",
"from": "0.7.53",
"to": "0.7.53",
"strategy": "installed",
"generatedAt": "2025-11-27T11:47:22.908Z"
},
{
"section": "dependencies",
"name": "@types/js-yaml",
@@ -351,6 +351,14 @@
"strategy": "installed",
"generatedAt": "2026-03-26T08:39:34.728Z"
},
{
"section": "dependencies",
"name": "@xyflow/react",
"from": "12.10.2",
"to": "12.10.2",
"strategy": "installed",
"generatedAt": "2026-04-16T12:15:51.357Z"
},
{
"section": "dependencies",
"name": "ai",
@@ -391,14 +399,6 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "dagre",
"from": "0.8.5",
"to": "0.8.5",
"strategy": "installed",
"generatedAt": "2025-11-27T11:47:22.908Z"
},
{
"section": "dependencies",
"name": "date-fns",
@@ -455,6 +455,22 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "marked",
"from": "15.0.12",
"to": "15.0.12",
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "modern-screenshot",
"from": "4.7.0",
"to": "4.7.0",
"strategy": "installed",
"generatedAt": "2026-04-21T12:43:22.528Z"
},
{
"section": "dependencies",
"name": "nanoid",
@@ -759,6 +775,22 @@
"strategy": "installed",
"generatedAt": "2026-01-29T16:42:27.795Z"
},
{
"section": "devDependencies",
"name": "@vitest/browser",
"from": "4.0.18",
"to": "4.0.18",
"strategy": "installed",
"generatedAt": "2026-04-30T13:13:39.682Z"
},
{
"section": "devDependencies",
"name": "@vitest/browser-playwright",
"from": "4.0.18",
"to": "4.0.18",
"strategy": "installed",
"generatedAt": "2026-04-30T13:13:39.682Z"
},
{
"section": "devDependencies",
"name": "@vitest/coverage-v8",
@@ -779,9 +811,9 @@
"section": "devDependencies",
"name": "dotenv",
"from": "16.6.1",
"to": "16.6.1",
"to": null,
"strategy": "installed",
"generatedAt": "2026-05-11T15:12:01.207Z"
"generatedAt": "2026-05-12T10:13:36.469Z"
},
{
"section": "devDependencies",
@@ -887,6 +919,14 @@
"strategy": "installed",
"generatedAt": "2026-04-10T11:55:26.693Z"
},
{
"section": "devDependencies",
"name": "msw",
"from": "2.13.4",
"to": "2.13.4",
"strategy": "installed",
"generatedAt": "2026-04-30T13:13:39.682Z"
},
{
"section": "devDependencies",
"name": "postcss",
@@ -934,5 +974,13 @@
"to": "4.0.18",
"strategy": "installed",
"generatedAt": "2026-01-29T16:42:27.795Z"
},
{
"section": "devDependencies",
"name": "vitest-browser-react",
"from": "2.0.4",
"to": "2.0.4",
"strategy": "installed",
"generatedAt": "2026-04-30T13:23:20.132Z"
}
]
+20 -6
View File
@@ -16,8 +16,11 @@
"lint:knip:fix": "knip --fix --max-issues 494",
"format:check": "./node_modules/.bin/prettier --check ./app",
"format:write": "./node_modules/.bin/prettier --config .prettierrc.json --write ./app",
"test": "vitest",
"test:run": "vitest run",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --project unit",
"test:browser": "vitest run --project browser",
"test:browser:watch": "vitest --project browser",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans",
"test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --ui",
@@ -33,6 +36,7 @@
"@codemirror/language": "6.12.2",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.40.0",
"@dagrejs/dagre": "3.0.0",
"@extractus/feed-extractor": "7.1.7",
"@heroui/react": "2.8.4",
"@hookform/resolvers": "5.2.2",
@@ -69,15 +73,14 @@
"@tailwindcss/postcss": "4.1.18",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@types/dagre": "0.7.53",
"@types/js-yaml": "4.0.9",
"@uiw/react-codemirror": "4.25.8",
"@xyflow/react": "12.10.2",
"ai": "5.0.109",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"d3": "7.9.0",
"dagre": "0.8.5",
"date-fns": "4.1.0",
"framer-motion": "11.18.2",
"import-in-the-middle": "2.0.0",
@@ -85,6 +88,8 @@
"jwt-decode": "4.0.0",
"langchain": "1.2.10",
"lucide-react": "0.543.0",
"marked": "15.0.12",
"modern-screenshot": "4.7.0",
"nanoid": "5.1.6",
"next": "16.2.3",
"next-auth": "5.0.0-beta.30",
@@ -125,6 +130,8 @@
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"@vitest/browser": "4.0.18",
"@vitest/browser-playwright": "4.0.18",
"@vitest/coverage-v8": "4.0.18",
"babel-plugin-react-compiler": "1.0.0",
"dotenv": "16.6.1",
@@ -141,12 +148,14 @@
"globals": "17.0.0",
"jsdom": "27.4.0",
"knip": "6.3.1",
"msw": "2.13.4",
"postcss": "8.4.38",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "4.1.18",
"typescript": "5.5.4",
"vitest": "4.0.18"
"vitest": "4.0.18",
"vitest-browser-react": "2.0.4"
},
"pnpm": {
"overrides": {
@@ -173,5 +182,10 @@
}
},
"version": "0.0.1",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"msw": {
"workerDirectory": [
"public"
]
}
}
+277 -177
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -26,6 +26,8 @@ onlyBuiltDependencies:
- "@heroui/shared-utils"
# unrs-resolver: Rust module resolver (NAPI-RS). Verifies the correct native binding is available for the platform.
- unrs-resolver
# msw: Copies mockServiceWorker.js into the directories listed in package.json's `msw.workerDirectory` (here: `public/`) so the runtime worker stays in sync with the installed msw version. Pure file copy — no native binary, no network access. Required for vitest browser tests to intercept fetches via the service worker.
- msw
# --- Level 3: Trust Policy + Exotic Subdeps ---
# Fail when a package's trust evidence is downgraded (e.g., new publisher).
+354
View File
@@ -0,0 +1,354 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.13.4'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
// Only accept messages from pages served from the same origin as this worker.
if (event.origin !== self.location.origin) {
return
}
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+61
View File
@@ -25,8 +25,69 @@ function runScriptIfExists(scriptPath, scriptName) {
}
}
function hardenMswServiceWorker() {
const workerPath = path.join(
__dirname,
"..",
"public",
"mockServiceWorker.js",
);
let workerFile;
try {
workerFile = fs.openSync(workerPath, "r+");
} catch (error) {
if (error.code === "ENOENT") {
console.log("Skip MSW service worker hardening (worker missing)");
return;
}
throw error;
}
try {
const workerSource = fs.readFileSync(workerFile, "utf8");
const originGuard = "event.origin !== self.location.origin";
if (workerSource.includes(originGuard)) {
return;
}
const messageHandlerStart =
"addEventListener('message', async function (event) {\n const clientId = Reflect.get(event.source || {}, 'id')\n";
const hardenedMessageHandlerStart =
"addEventListener('message', async function (event) {\n" +
" // Only accept messages from pages served from the same origin as this worker.\n" +
" if (event.origin !== self.location.origin) {\n" +
" return\n" +
" }\n\n" +
" const clientId = Reflect.get(event.source || {}, 'id')\n";
if (!workerSource.includes(messageHandlerStart)) {
console.warn(
"⚠️ Unable to harden MSW service worker: message handler changed",
);
return;
}
const hardenedWorkerSource = workerSource.replace(
messageHandlerStart,
hardenedMessageHandlerStart,
);
fs.ftruncateSync(workerFile, 0);
fs.writeSync(workerFile, hardenedWorkerSource, 0, "utf8");
console.log("Hardened MSW service worker message origin handling");
} finally {
fs.closeSync(workerFile);
}
}
// Run dependency log update
runScriptIfExists("./update-dependency-log.js", "deps:log");
// Re-apply local hardening after MSW regenerates the worker during install.
// Keep this before setup-git-hooks because that script can exit the process.
hardenMswServiceWorker();
// Run git hooks setup
runScriptIfExists("./setup-git-hooks.js", "setup-git-hooks");
+2 -39
View File
@@ -192,8 +192,8 @@ export interface GraphNode {
export interface GraphEdge {
id: string;
source: string | object;
target: string | object;
source: string;
target: string;
type: string;
properties?: GraphNodeProperties;
}
@@ -232,40 +232,6 @@ export interface AttackPathQueryError {
status: number;
}
// Finding severity and status constants
export const FINDING_SEVERITIES = {
CRITICAL: "critical",
HIGH: "high",
MEDIUM: "medium",
LOW: "low",
INFO: "info",
} as const;
type FindingSeverity =
(typeof FINDING_SEVERITIES)[keyof typeof FINDING_SEVERITIES];
export const FINDING_STATUSES = {
PASS: "PASS",
FAIL: "FAIL",
MANUAL: "MANUAL",
} as const;
type FindingStatus = (typeof FINDING_STATUSES)[keyof typeof FINDING_STATUSES];
export interface RelatedFinding {
id: string;
title: string;
severity: FindingSeverity;
status: FindingStatus;
}
// Node Detail Types
export interface NodeDetailData extends GraphNode {
relatedFindings?: RelatedFinding[];
incomingEdges?: GraphEdge[];
outgoingEdges?: GraphEdge[];
}
// Wizard State Types
export interface WizardState {
currentStep: 1 | 2;
@@ -280,9 +246,6 @@ export interface GraphState {
selectedNodeId: string | null;
loading: boolean;
error: string | null;
zoomLevel: number;
panX: number;
panY: number;
}
// Provider Integration
+94
View File
@@ -0,0 +1,94 @@
// Global stylesheet (Tailwind + design tokens) is imported by the Next.js
// layouts in the real app. Tests render the page in isolation, bypassing the
// layout, so without this import Tailwind classes resolve to nothing — the
// page collapses to unstyled HTML and stacked elements end up overlapping
// the graph nodes, blocking Playwright clicks. Pull the stylesheet directly
// so the test bundle gets the same CSS the production page receives.
import "@/styles/globals.css";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { worker } from "./__tests__/msw/worker";
// Server Actions ("use server") are bundled by Vite as plain async functions
// — the directive is a Next.js compiler concept, not part of Vite. When the
// page invokes one, it runs in the browser and reaches `auth()` from
// next-auth, which calls `next/headers` (request-scoped AsyncLocalStorage
// only set up by Next's request handler) and throws "headers was called
// outside a request scope". That kills every action before it can hit
// MSW. Stub `auth.config` with a fake session so the action proceeds to
// `fetch()` and MSW takes over.
vi.mock("@/auth.config", () => ({
auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
signIn: vi.fn(),
signOut: vi.fn(),
handlers: {},
}));
// Next.js's App Router context (`useRouter`, `useSearchParams`, `usePathname`)
// is not available in vitest browser — there's no Next runtime mounting the
// providers. We back the hooks with the real `window.location` so navigating
// via `history.replaceState` in tests is enough to drive the page.
vi.mock("next/navigation", () => {
const router = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(() => Promise.resolve()),
};
return {
useSearchParams: () => new URLSearchParams(window.location.search),
useRouter: () => router,
usePathname: () => window.location.pathname,
useParams: () => ({}),
redirect: vi.fn(),
notFound: vi.fn(),
};
});
beforeAll(async () => {
await worker.start({
serviceWorker: { url: "/mockServiceWorker.js" },
onUnhandledRequest: "error",
});
});
afterEach(() => {
worker.resetHandlers();
});
afterAll(() => {
worker.stop();
});
// React Flow's pan/drag handlers dispatch pointer events that access
// `event.view.document` on the node. When user-event synthesises these
// events the `view` property can be null, producing harmless
// "Cannot read properties of null (reading 'document')" errors.
// Swallow only that specific unhandled error; everything else propagates.
const isReactFlowNullViewError = (reason: unknown): boolean => {
const message =
reason instanceof Error
? reason.message
: typeof reason === "string"
? reason
: "";
return message.includes(
"Cannot read properties of null (reading 'document')",
);
};
window.addEventListener("error", (event) => {
if (isReactFlowNullViewError(event.error)) {
event.preventDefault();
event.stopImmediatePropagation();
}
});
window.addEventListener("unhandledrejection", (event) => {
if (isReactFlowNullViewError(event.reason)) {
event.preventDefault();
}
});
+173 -32
View File
@@ -1,39 +1,180 @@
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import path from "path";
import type { TestProjectConfiguration } from "vitest/config";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
restoreMocks: true,
mockReset: true,
unstubEnvs: true,
unstubGlobals: true,
setupFiles: ["./vitest.setup.ts"],
include: ["**/*.test.{ts,tsx}"],
exclude: [
"node_modules",
".next",
"tests/**/*", // Playwright E2E tests
],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: [
"node_modules",
".next",
"tests/**/*",
"**/*.test.{ts,tsx}",
"vitest.config.ts",
"vitest.setup.ts",
export default defineConfig(() => {
const apiBaseUrl =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost/api/v1";
return {
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./"),
},
},
test: {
globals: true,
restoreMocks: true,
mockReset: true,
unstubEnvs: true,
unstubGlobals: true,
coverage: {
provider: "v8" as const,
reporter: ["text", "json", "html"],
exclude: [
"node_modules",
".next",
"tests/**/*",
"**/*.test.{ts,tsx}",
"**/*.browser.test.{ts,tsx}",
"vitest.config.ts",
"vitest.setup.ts",
"vitest.browser.setup.ts",
"__tests__/**/*",
],
},
projects: [
{
extends: true,
test: {
name: "unit",
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
include: ["**/*.test.{ts,tsx}"],
exclude: [
"node_modules",
".next",
"tests/**/*",
"**/*.browser.test.{ts,tsx}",
],
},
},
{
extends: true,
test: {
name: "browser",
setupFiles: ["./vitest.browser.setup.ts"],
include: ["**/*.browser.test.{ts,tsx}"],
exclude: ["node_modules", ".next", "tests/**/*"],
browser: {
enabled: true,
// Vitest's browser default viewport is 414×896 (phone-sized),
// which collapses the responsive layout: the legend stacks
// vertically and ends up overlapping the graph, so Playwright
// can't click nodes. Use a standard desktop viewport.
viewport: { width: 1280, height: 800 },
provider: playwright(),
headless: true,
instances: [{ browser: "chromium" }],
},
},
},
] as TestProjectConfiguration[],
},
define: {
"process.env.NEXT_PUBLIC_API_BASE_URL": JSON.stringify(apiBaseUrl),
// `next/dist/server/web/spec-extension/user-agent.js` references
// `__dirname` directly and is pulled in transitively via `next-auth`.
// Vite serves it to the browser where that global doesn't exist, so we
// replace it at bundle time. `optimizeDeps` alone doesn't help —
// pre-bundling doesn't patch the identifier.
__dirname: JSON.stringify("/"),
__filename: JSON.stringify("/__browser_test__.js"),
},
optimizeDeps: {
// Pre-bundle every dep that the attack-paths page transitively imports.
// Without this, Vite optimizes them on demand at the first request and
// reloads the page, killing the test run. Keep this list aligned with
// imports through the page's render tree.
include: [
// Test stack
"vitest-browser-react",
"msw/browser",
// Next runtime
"next/navigation",
"next/link",
"next/image",
"next/cache",
"next/server",
"next-auth",
"next-auth/react",
"next-auth/providers/credentials",
"next-themes",
// App component lib
"@heroui/react",
"@heroui/accordion",
"@heroui/breadcrumbs",
"@heroui/card",
"@heroui/chip",
"@heroui/divider",
"@heroui/input",
"@heroui/switch",
"@heroui/theme",
"@heroui/tooltip",
"@heroui/use-clipboard",
"@iconify/react",
// Radix
"@radix-ui/react-alert-dialog",
"@radix-ui/react-avatar",
"@radix-ui/react-checkbox",
"@radix-ui/react-collapsible",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-icons",
"@radix-ui/react-label",
"@radix-ui/react-popover",
"@radix-ui/react-radio-group",
"@radix-ui/react-scroll-area",
"@radix-ui/react-select",
"@radix-ui/react-separator",
"@radix-ui/react-tabs",
"@radix-ui/react-toast",
"@radix-ui/react-tooltip",
"@radix-ui/react-slot",
// Graph
"@xyflow/react",
"@dagrejs/dagre",
// Forms / state
"react-hook-form",
"@hookform/resolvers/zod",
"zod",
"zustand",
"zustand/middleware",
// Styling helpers
"lucide-react",
"clsx",
"tailwind-merge",
"class-variance-authority",
"tailwind-variants",
// App-level deps the page (or its children) pull in
"@tanstack/react-table",
"@react-aria/ssr",
"@react-aria/visually-hidden",
"modern-screenshot",
"framer-motion",
"vaul",
"cmdk",
"react-markdown",
"jwt-decode",
"date-fns",
"js-yaml",
"@codemirror/language",
"@codemirror/state",
"@lezer/highlight",
"@uiw/react-codemirror",
"@sentry/nextjs",
"@extractus/feed-extractor",
],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./"),
},
},
};
});