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