mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
feat(ui): render attack paths node icons
- Add badge-style graph nodes with wrapped labels - Map provider, resource, identity, and finding icons - Align graph layout and handles with badge nodes - Cover icon rendering and visual metadata with tests
This commit is contained in:
+1
-1
@@ -498,7 +498,7 @@ const GraphCanvas = ({
|
||||
onNodeMouseEnter={handleNodeMouseEnter}
|
||||
onNodeMouseLeave={handleNodeMouseLeave}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, includeHiddenNodes: true }}
|
||||
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.
|
||||
|
||||
+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)"
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Braces,
|
||||
CircleAlert,
|
||||
FileKey2,
|
||||
Info,
|
||||
ShieldCheck,
|
||||
Siren,
|
||||
UserCog,
|
||||
} from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
GCPProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
import {
|
||||
AmazonS3Icon,
|
||||
AmazonVPCIcon,
|
||||
AWSAccountIcon,
|
||||
AWSIAMIcon,
|
||||
} from "@/components/icons/services/IconServices";
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
@@ -32,7 +47,44 @@ describe("resolveNodeVisual", () => {
|
||||
description: "AWS Account",
|
||||
fallbackUsed: false,
|
||||
});
|
||||
expect(visual.Icon).toBe(AWSAccountIcon);
|
||||
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", () => {
|
||||
@@ -87,6 +139,49 @@ describe("resolveNodeVisual", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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"]);
|
||||
@@ -137,6 +232,48 @@ describe("resolveNodeVisual", () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback behavior", () => {
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Box,
|
||||
Braces,
|
||||
CircleAlert,
|
||||
FileKey2,
|
||||
Globe2,
|
||||
Info,
|
||||
KeyRound,
|
||||
Network,
|
||||
Route,
|
||||
Server,
|
||||
UserRound,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Siren,
|
||||
UserCog,
|
||||
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,
|
||||
AmazonS3Icon,
|
||||
AmazonVPCIcon,
|
||||
AWSAccountIcon,
|
||||
AWSIAMIcon,
|
||||
} from "@/components/icons/services/IconServices";
|
||||
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
|
||||
@@ -49,7 +74,102 @@ const KNOWN_NODE_VISUALS = {
|
||||
awsaccount: {
|
||||
category: NODE_CATEGORY.ACCOUNT,
|
||||
description: "AWS Account",
|
||||
Icon: AWSAccountIcon,
|
||||
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,
|
||||
@@ -69,22 +189,22 @@ const KNOWN_NODE_VISUALS = {
|
||||
subnet: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Subnet",
|
||||
Icon: Network,
|
||||
Icon: Waypoints,
|
||||
},
|
||||
securitygroup: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Security Group",
|
||||
Icon: Network,
|
||||
Icon: Shield,
|
||||
},
|
||||
internetgateway: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Internet Gateway",
|
||||
Icon: Globe2,
|
||||
Icon: Route,
|
||||
},
|
||||
defaultgateway: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Default Gateway",
|
||||
Icon: Globe2,
|
||||
Icon: Route,
|
||||
},
|
||||
ec2instance: {
|
||||
category: NODE_CATEGORY.COMPUTE,
|
||||
@@ -111,11 +231,51 @@ const KNOWN_NODE_VISUALS = {
|
||||
description: "IAM User",
|
||||
Icon: AWSIAMIcon,
|
||||
},
|
||||
awsuser: {
|
||||
category: NODE_CATEGORY.IDENTITY,
|
||||
description: "AWS User",
|
||||
Icon: UserCog,
|
||||
},
|
||||
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",
|
||||
@@ -129,7 +289,7 @@ const KNOWN_NODE_VISUALS = {
|
||||
serviceaccount: {
|
||||
category: NODE_CATEGORY.IDENTITY,
|
||||
description: "Service Account",
|
||||
Icon: UserRound,
|
||||
Icon: Bot,
|
||||
},
|
||||
} as const satisfies Record<string, KnownNodeVisualMapping>;
|
||||
|
||||
@@ -147,6 +307,26 @@ const isFindingLabel = (label: string): boolean =>
|
||||
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 => {
|
||||
@@ -181,6 +361,23 @@ 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 => {
|
||||
@@ -200,7 +397,7 @@ export const resolveNodeVisual = (node: GraphNode): NodeVisual => {
|
||||
category: NODE_CATEGORY.FINDING,
|
||||
displayName: resolveFindingDisplayName(node),
|
||||
description: "Prowler Finding",
|
||||
Icon: AlertTriangle,
|
||||
Icon: resolveFindingIcon(node),
|
||||
fallbackUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user