mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
feat(ui): add attack paths node visual mapper
- Add typed resolver for graph node visual metadata - Reuse existing service icons for known resource labels - Cover exact, alias, and fallback mappings with tests
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, KnownNodeVisualMapping>;
|
||||
|
||||
type KnownNodeLabel = keyof typeof KNOWN_NODE_VISUALS;
|
||||
|
||||
const normalizeLabel = (label: string): string =>
|
||||
label.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
|
||||
const isKnownNodeLabel = (label: string): label is KnownNodeLabel =>
|
||||
label in KNOWN_NODE_VISUALS;
|
||||
|
||||
const isFindingLabel = (label: string): boolean =>
|
||||
normalizeLabel(label).includes("finding");
|
||||
|
||||
const isInternetLabel = (label: string): boolean =>
|
||||
normalizeLabel(label) === "internet";
|
||||
|
||||
const 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user