diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts index 11b45cd021..d23fc9ed70 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts @@ -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"; 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 new file mode 100644 index 0000000000..88aef5b9aa --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; + +import { + AmazonS3Icon, + AmazonVPCIcon, + AWSAccountIcon, + AWSIAMIcon, +} 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(AWSAccountIcon); + }); + + 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 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, + }); + }); + }); + + 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, + }); + }); + }); +}); 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 new file mode 100644 index 0000000000..dafface529 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts @@ -0,0 +1,236 @@ +import { + AlertTriangle, + Box, + Globe2, + KeyRound, + Network, + Server, + UserRound, +} from "lucide-react"; +import type { ElementType } from "react"; + +import { + AmazonEC2Icon, + AmazonS3Icon, + AmazonVPCIcon, + AWSAccountIcon, + AWSIAMIcon, +} 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: AWSAccountIcon, + }, + 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: Network, + }, + securitygroup: { + category: NODE_CATEGORY.NETWORK, + description: "Security Group", + Icon: Network, + }, + internetgateway: { + category: NODE_CATEGORY.NETWORK, + description: "Internet Gateway", + Icon: Globe2, + }, + defaultgateway: { + category: NODE_CATEGORY.NETWORK, + description: "Default Gateway", + Icon: Globe2, + }, + ec2instance: { + category: NODE_CATEGORY.COMPUTE, + description: "EC2 Instance", + Icon: AmazonEC2Icon, + }, + virtualmachine: { + category: NODE_CATEGORY.COMPUTE, + description: "Virtual Machine", + Icon: AmazonEC2Icon, + }, + 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, + }, + iamrole: { + category: NODE_CATEGORY.IDENTITY, + description: "IAM Role", + Icon: AWSIAMIcon, + }, + 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: UserRound, + }, +} as const satisfies Record; + +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 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 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: AlertTriangle, + 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, + }; +};