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:
Alan Buscaglia
2026-05-05 20:47:13 +02:00
parent 58b0fa556d
commit c5aa4ced0d
11 changed files with 766 additions and 101 deletions
@@ -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.
@@ -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();
});
});
});
@@ -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>
</>
@@ -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 }}
/>
</>
);
@@ -0,0 +1,48 @@
const splitByMaxChars = (text: string, maxChars: number): string[] => {
const words = text.trim().split(/\s+/).filter(Boolean);
const lines: string[] = [];
let currentLine = "";
for (const word of words) {
if (!currentLine) {
currentLine = word;
continue;
}
const nextLine = `${currentLine} ${word}`;
if (nextLine.length <= maxChars) {
currentLine = nextLine;
continue;
}
lines.push(currentLine);
currentLine = word;
}
if (currentLine) lines.push(currentLine);
return lines;
};
const splitLongToken = (text: string, maxChars: number): string[] => {
const lines: string[] = [];
for (let index = 0; index < text.length; index += maxChars) {
lines.push(text.slice(index, index + maxChars));
}
return lines;
};
export const getNodeLabelLines = (
text: string,
maxChars: number,
maxLines: number,
): string[] => {
if (!text.trim()) return [];
const rawLines = text.includes(" ")
? splitByMaxChars(text, maxChars)
: splitLongToken(text, maxChars);
return rawLines.slice(0, maxLines);
};
@@ -0,0 +1,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();
});
});
});
@@ -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,
};
}