mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-23 20:04:27 +00:00
323 lines
8.5 KiB
TypeScript
323 lines
8.5 KiB
TypeScript
import { Position } from "@xyflow/react";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { layoutWithDagre } from "@/lib/attack-paths/layout";
|
|
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
|
|
|
|
const findingNode: GraphNode = {
|
|
id: "finding-1",
|
|
labels: ["ProwlerFinding"],
|
|
properties: { check_title: "Open S3 bucket", severity: "high" },
|
|
};
|
|
|
|
const resourceNode: GraphNode = {
|
|
id: "resource-1",
|
|
labels: ["S3Bucket"],
|
|
properties: { name: "bucket-1" },
|
|
};
|
|
|
|
const guardDutyNode: GraphNode = {
|
|
id: "guard-duty-1",
|
|
labels: ["GuardDutyFinding"],
|
|
properties: { title: "Port probe", severity: "high" },
|
|
};
|
|
|
|
const inspectorNode: GraphNode = {
|
|
id: "inspector-1",
|
|
labels: ["AWSInspectorFinding"],
|
|
properties: { title: "Package vulnerability", severity: "high" },
|
|
};
|
|
|
|
const internetNode: GraphNode = {
|
|
id: "internet-1",
|
|
labels: ["Internet"],
|
|
properties: {},
|
|
};
|
|
|
|
describe("layoutWithDagre", () => {
|
|
it("returns empty arrays for empty input", () => {
|
|
const result = layoutWithDagre([], []);
|
|
expect(result.rfNodes).toEqual([]);
|
|
expect(result.rfEdges).toEqual([]);
|
|
});
|
|
it("assigns node types and dimensions from labels", () => {
|
|
const { rfNodes } = layoutWithDagre(
|
|
[findingNode, resourceNode, internetNode],
|
|
[],
|
|
);
|
|
|
|
const byId = new Map(rfNodes.map((n) => [n.id, n]));
|
|
|
|
expect(byId.get("finding-1")).toMatchObject({
|
|
type: "finding",
|
|
width: 150,
|
|
height: 124,
|
|
});
|
|
expect(byId.get("resource-1")).toMatchObject({
|
|
type: "resource",
|
|
width: 136,
|
|
height: 124,
|
|
});
|
|
expect(byId.get("internet-1")).toMatchObject({
|
|
type: "internet",
|
|
width: 80,
|
|
height: 80,
|
|
});
|
|
});
|
|
|
|
it("treats cloud-provider finding resources as resource nodes", () => {
|
|
const { rfNodes } = layoutWithDagre(
|
|
[findingNode, guardDutyNode, inspectorNode],
|
|
[],
|
|
);
|
|
|
|
const byId = new Map(rfNodes.map((n) => [n.id, n]));
|
|
|
|
expect(byId.get("finding-1")?.type).toBe("finding");
|
|
expect(byId.get("guard-duty-1")).toMatchObject({
|
|
type: "resource",
|
|
width: 136,
|
|
height: 124,
|
|
});
|
|
expect(byId.get("inspector-1")?.type).toBe("resource");
|
|
});
|
|
|
|
it("does not animate edges that only touch cloud-provider finding resources", () => {
|
|
const { rfEdges } = layoutWithDagre(
|
|
[resourceNode, guardDutyNode],
|
|
[
|
|
{
|
|
id: "e1",
|
|
source: "guard-duty-1",
|
|
target: "resource-1",
|
|
type: "AFFECTS",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(rfEdges[0]).toMatchObject({
|
|
animated: false,
|
|
className: "resource-edge",
|
|
});
|
|
});
|
|
|
|
it("is deterministic: same input produces equal output across runs", () => {
|
|
const nodes = [findingNode, resourceNode];
|
|
const edges: GraphEdge[] = [
|
|
{
|
|
id: "e1",
|
|
source: "resource-1",
|
|
target: "finding-1",
|
|
type: "HAS_FINDING",
|
|
},
|
|
];
|
|
|
|
const a = layoutWithDagre(nodes, edges);
|
|
const b = layoutWithDagre(nodes, edges);
|
|
|
|
expect(a).toEqual(b);
|
|
});
|
|
|
|
it("places connected children to the right and stacks siblings within the horizontal rank", () => {
|
|
const rootNode: GraphNode = {
|
|
id: "root",
|
|
labels: ["AWSAccount"],
|
|
properties: { name: "account" },
|
|
};
|
|
const siblingNodes: GraphNode[] = [
|
|
{
|
|
id: "bucket",
|
|
labels: ["S3Bucket"],
|
|
properties: { name: "bucket" },
|
|
},
|
|
{
|
|
id: "lambda",
|
|
labels: ["AWSLambda"],
|
|
properties: { name: "function" },
|
|
},
|
|
{
|
|
id: "database",
|
|
labels: ["RDSInstance"],
|
|
properties: { name: "database" },
|
|
},
|
|
];
|
|
|
|
const { rfNodes } = layoutWithDagre(
|
|
[rootNode, ...siblingNodes],
|
|
siblingNodes.map((node) => ({
|
|
id: `root-${node.id}`,
|
|
source: "root",
|
|
target: node.id,
|
|
type: "CONNECTS_TO",
|
|
})),
|
|
);
|
|
|
|
const rootPosition = rfNodes.find(
|
|
(candidate) => candidate.id === "root",
|
|
)?.position;
|
|
const siblingPositions = siblingNodes.map((node) => {
|
|
const rfNode = rfNodes.find((candidate) => candidate.id === node.id);
|
|
|
|
expect(rfNode).toBeDefined();
|
|
|
|
return rfNode?.position ?? { x: 0, y: 0 };
|
|
});
|
|
|
|
const xSpread =
|
|
Math.max(...siblingPositions.map((position) => position.x)) -
|
|
Math.min(...siblingPositions.map((position) => position.x));
|
|
const ySpread =
|
|
Math.max(...siblingPositions.map((position) => position.y)) -
|
|
Math.min(...siblingPositions.map((position) => position.y));
|
|
|
|
expect(rootPosition).toBeDefined();
|
|
siblingPositions.forEach((position) => {
|
|
expect(position.x).toBeGreaterThan(rootPosition?.x ?? 0);
|
|
});
|
|
expect(ySpread).toBeGreaterThan(xSpread);
|
|
});
|
|
|
|
it("connects edges through right and left node sides for horizontal layout", () => {
|
|
const { rfNodes } = layoutWithDagre(
|
|
[findingNode, resourceNode],
|
|
[
|
|
{
|
|
id: "e1",
|
|
source: "resource-1",
|
|
target: "finding-1",
|
|
type: "HAS_FINDING",
|
|
},
|
|
],
|
|
);
|
|
|
|
rfNodes.forEach((node) => {
|
|
expect(node.sourcePosition).toBe(Position.Right);
|
|
expect(node.targetPosition).toBe(Position.Left);
|
|
});
|
|
});
|
|
|
|
it("offsets dagre center positions by half of the node dimensions (top-left)", () => {
|
|
const { rfNodes } = layoutWithDagre([findingNode, resourceNode], []);
|
|
|
|
rfNodes.forEach((node) => {
|
|
expect(Number.isFinite(node.position.x)).toBe(true);
|
|
expect(Number.isFinite(node.position.y)).toBe(true);
|
|
});
|
|
|
|
// Different node types must end up with different sizes — confirms the
|
|
// dimension-aware offset is wired up.
|
|
const findingDims = rfNodes.find((n) => n.id === "finding-1");
|
|
const resourceDims = rfNodes.find((n) => n.id === "resource-1");
|
|
expect(findingDims?.width).not.toEqual(resourceDims?.width);
|
|
});
|
|
|
|
it("reverses container relationships while preserving original endpoints in edge data", () => {
|
|
const containerNode: GraphNode = {
|
|
id: "container",
|
|
labels: ["AWSAccount"],
|
|
properties: { name: "acct" },
|
|
};
|
|
const childNode: GraphNode = {
|
|
id: "child",
|
|
labels: ["S3Bucket"],
|
|
properties: { name: "bucket" },
|
|
};
|
|
|
|
const { rfEdges } = layoutWithDagre(
|
|
[containerNode, childNode],
|
|
[
|
|
{
|
|
id: "e1",
|
|
source: "container",
|
|
target: "child",
|
|
type: "RUNS_IN",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(rfEdges).toHaveLength(1);
|
|
expect(rfEdges[0]).toMatchObject({
|
|
source: "child",
|
|
target: "container",
|
|
data: { originalSource: "container", originalTarget: "child" },
|
|
});
|
|
});
|
|
|
|
it("animates edges that touch a finding node and tags them with finding-edge", () => {
|
|
const { rfEdges } = layoutWithDagre(
|
|
[findingNode, resourceNode, internetNode],
|
|
[
|
|
{
|
|
id: "e1",
|
|
source: "resource-1",
|
|
target: "finding-1",
|
|
type: "HAS_FINDING",
|
|
},
|
|
{
|
|
id: "e2",
|
|
source: "internet-1",
|
|
target: "resource-1",
|
|
type: "CONNECTS_TO",
|
|
},
|
|
],
|
|
);
|
|
|
|
const findingEdge = rfEdges.find(
|
|
(e) => e.source === "resource-1" && e.target === "finding-1",
|
|
);
|
|
const plainEdge = rfEdges.find(
|
|
(e) => e.source === "internet-1" && e.target === "resource-1",
|
|
);
|
|
|
|
expect(findingEdge).toMatchObject({
|
|
animated: true,
|
|
className: "finding-edge",
|
|
});
|
|
expect(plainEdge).toMatchObject({
|
|
animated: false,
|
|
className: "resource-edge",
|
|
});
|
|
});
|
|
|
|
it("preserves the original edge id when the graph has a single edge", () => {
|
|
const { rfEdges } = layoutWithDagre(
|
|
[findingNode, resourceNode],
|
|
[
|
|
{
|
|
id: "ignored-by-rf",
|
|
source: "resource-1",
|
|
target: "finding-1",
|
|
type: "HAS_FINDING",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(rfEdges[0]?.id).toBe("ignored-by-rf");
|
|
});
|
|
|
|
it("preserves parallel edges between the same nodes with unique ids", () => {
|
|
const { rfEdges } = layoutWithDagre(
|
|
[resourceNode, findingNode],
|
|
[
|
|
{
|
|
id: "edge-1",
|
|
source: "resource-1",
|
|
target: "finding-1",
|
|
type: "HAS_FINDING",
|
|
},
|
|
{
|
|
id: "edge-2",
|
|
source: "resource-1",
|
|
target: "finding-1",
|
|
type: "HAS_FINDING",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(rfEdges).toHaveLength(2);
|
|
expect(rfEdges.map((edge) => edge.id)).toEqual(["edge-1", "edge-2"]);
|
|
expect(rfEdges.every((edge) => edge.source === "resource-1")).toBe(true);
|
|
expect(rfEdges.every((edge) => edge.target === "finding-1")).toBe(true);
|
|
});
|
|
});
|