From c5aa4ced0dd336f3da2f3a9ea708a7c4ae131ffb Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Tue, 5 May 2026 20:47:13 +0200 Subject: [PATCH] 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 --- .../_components/graph/attack-path-graph.tsx | 2 +- .../graph/nodes/finding-node.test.tsx | 72 ++++++ .../_components/graph/nodes/finding-node.tsx | 144 +++++++++--- .../graph/nodes/hidden-handles.tsx | 27 ++- .../graph/nodes/node-label-lines.ts | 48 ++++ .../graph/nodes/resource-node.test.tsx | 67 ++++++ .../_components/graph/nodes/resource-node.tsx | 127 ++++++---- .../query-builder/_lib/layout.test.ts | 8 +- .../(workflow)/query-builder/_lib/layout.ts | 14 +- .../query-builder/_lib/node-visuals.test.ts | 141 +++++++++++- .../query-builder/_lib/node-visuals.ts | 217 +++++++++++++++++- 11 files changed, 766 insertions(+), 101 deletions(-) create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx index 4e2fd7eef0..ca94b23909 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx @@ -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. diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx new file mode 100644 index 0000000000..8b004adfb5 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx @@ -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(); + + // 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(); + + // 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(); + }); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx index 8ccfb8bd49..01d53123d9 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx @@ -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 ( <> - - - + + + + + + - {displayTitle} + {displayTitleLines.map((line, index) => ( + + {line} + + ))} + {severity && ( + + {severity} + + )} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx index fbc64328c2..d00981a5b7 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx @@ -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) => ( <> - - + + ); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts new file mode 100644 index 0000000000..a6ed74e70a --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts @@ -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); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx new file mode 100644 index 0000000000..8060771b1f --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx @@ -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(); + + // 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(); + + // 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(); + }); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx index fe6b4ac078..e97a2fda4c 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx @@ -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 ( <> - + - 0 && ( + + )} + + + + - - {displayName} - + {displayNameLines.map((line, index) => ( + + {line} + + ))} {typeLabel && ( diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts index 09c875ff5b..55481bfe9b 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts @@ -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", diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts index 37a7e5d273..5eb88d9262 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts @@ -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 }; }; /** diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts index 88aef5b9aa..f8ff4792ba 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts @@ -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", () => { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts index dafface529..626a295b08 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts @@ -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; @@ -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; + 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, }; }