mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-24 12:24:17 +00:00
271 lines
8.9 KiB
TypeScript
271 lines
8.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
buildAttackPathView,
|
|
GROUP_NODE_LABEL,
|
|
GROUP_PROPS,
|
|
groupKey,
|
|
OUTCOME_NODE_LABEL,
|
|
OUTCOME_PROPS,
|
|
} from "@/lib/attack-paths/group-graph";
|
|
import type {
|
|
AttackPathGraphData,
|
|
AttackPathOutcome,
|
|
GraphNode,
|
|
} from "@/types/attack-paths";
|
|
|
|
const node = (id: string, label: string): GraphNode => ({
|
|
id,
|
|
labels: [label],
|
|
properties: { name: id },
|
|
});
|
|
|
|
// hub -> role (singleton) -> 3 policies (collapsed group).
|
|
// finding-r hangs off the visible role; finding-p hangs off a collapsed policy.
|
|
const baseGraph: AttackPathGraphData = {
|
|
nodes: [
|
|
node("acct", "AWSAccount"),
|
|
node("role-1", "AWSRole"),
|
|
node("pol-1", "AWSPolicy"),
|
|
node("pol-2", "AWSPolicy"),
|
|
node("pol-3", "AWSPolicy"),
|
|
node("finding-r", "ProwlerFinding"),
|
|
node("finding-p", "ProwlerFinding"),
|
|
],
|
|
edges: [
|
|
{ id: "e1", source: "acct", target: "role-1", type: "RESOURCE" },
|
|
{ id: "e2", source: "role-1", target: "pol-1", type: "POLICY" },
|
|
{ id: "e3", source: "role-1", target: "pol-2", type: "POLICY" },
|
|
{ id: "e4", source: "role-1", target: "pol-3", type: "POLICY" },
|
|
{ id: "e5", source: "role-1", target: "finding-r", type: "HAS_FINDING" },
|
|
{ id: "e6", source: "pol-1", target: "finding-p", type: "HAS_FINDING" },
|
|
],
|
|
};
|
|
|
|
const outcome: AttackPathOutcome = {
|
|
kind: "code_execution",
|
|
label: "Code execution",
|
|
partial: false,
|
|
};
|
|
|
|
const idsOf = (nodes: GraphNode[]) => nodes.map((n) => n.id);
|
|
|
|
describe("buildAttackPathView", () => {
|
|
it("drops the account hub", () => {
|
|
const view = buildAttackPathView({
|
|
data: baseGraph,
|
|
expandedClasses: new Set(),
|
|
});
|
|
expect(view.nodes.some((n) => n.labels.includes("AWSAccount"))).toBe(false);
|
|
});
|
|
|
|
it("keeps findings of visible members but hides findings of collapsed ones", () => {
|
|
const view = buildAttackPathView({
|
|
data: baseGraph,
|
|
expandedClasses: new Set(),
|
|
});
|
|
// role-1 is a visible singleton -> its finding stays
|
|
expect(idsOf(view.nodes)).toContain("finding-r");
|
|
// pol-1 is inside a collapsed group -> its finding is hidden
|
|
expect(idsOf(view.nodes)).not.toContain("finding-p");
|
|
});
|
|
|
|
it("renders a singleton class as a plain node, not a group", () => {
|
|
const view = buildAttackPathView({
|
|
data: baseGraph,
|
|
expandedClasses: new Set(),
|
|
});
|
|
const role = view.nodes.find((n) => n.id === "role-1");
|
|
expect(role).toBeDefined();
|
|
expect(role?.labels).not.toContain(GROUP_NODE_LABEL);
|
|
});
|
|
|
|
it("collapses a class with >=2 members into one group node with a count", () => {
|
|
const view = buildAttackPathView({
|
|
data: baseGraph,
|
|
expandedClasses: new Set(),
|
|
});
|
|
const group = view.nodes.find((n) => n.labels.includes(GROUP_NODE_LABEL));
|
|
expect(group).toBeDefined();
|
|
expect(group?.properties[GROUP_PROPS.COUNT]).toBe(3);
|
|
expect(idsOf(view.nodes)).not.toContain("pol-1");
|
|
});
|
|
|
|
it("flags a collapsed group whose members carry findings", () => {
|
|
const view = buildAttackPathView({
|
|
data: baseGraph,
|
|
expandedClasses: new Set(),
|
|
});
|
|
const group = view.nodes.find((n) => n.labels.includes(GROUP_NODE_LABEL));
|
|
expect(group?.properties[GROUP_PROPS.HAS_FINDINGS]).toBe(true);
|
|
});
|
|
|
|
it("aggregates parallel edges into one group edge with a count", () => {
|
|
const view = buildAttackPathView({
|
|
data: baseGraph,
|
|
expandedClasses: new Set(),
|
|
});
|
|
const groupId = view.nodes.find((n) =>
|
|
n.labels.includes(GROUP_NODE_LABEL),
|
|
)?.id;
|
|
const roleToGroup = view.edges.filter(
|
|
(e) => e.source === "role-1" && e.target === groupId,
|
|
);
|
|
expect(roleToGroup).toHaveLength(1);
|
|
expect(roleToGroup[0].properties?.__count).toBe(3);
|
|
});
|
|
|
|
it("expands a group to its members and reveals their findings", () => {
|
|
const key = groupKey(1, "AWSPolicy");
|
|
const view = buildAttackPathView({
|
|
data: baseGraph,
|
|
expandedClasses: new Set([key]),
|
|
});
|
|
expect(idsOf(view.nodes)).toEqual(
|
|
expect.arrayContaining(["pol-1", "pol-2", "pol-3"]),
|
|
);
|
|
expect(view.nodes.some((n) => n.labels.includes(GROUP_NODE_LABEL))).toBe(
|
|
false,
|
|
);
|
|
// pol-1 is now visible, so its finding passes through
|
|
expect(idsOf(view.nodes)).toContain("finding-p");
|
|
});
|
|
|
|
it("injects a terminal outcome node attached to the sinks", () => {
|
|
const view = buildAttackPathView({
|
|
data: baseGraph,
|
|
expandedClasses: new Set(),
|
|
outcome,
|
|
});
|
|
const outcomeNode = view.nodes.find((n) =>
|
|
n.labels.includes(OUTCOME_NODE_LABEL),
|
|
);
|
|
expect(outcomeNode).toBeDefined();
|
|
expect(outcomeNode?.properties[OUTCOME_PROPS.LABEL]).toBe("Code execution");
|
|
const intoOutcome = view.edges.filter(
|
|
(e) => e.target === outcomeNode?.id && e.type === "OUTCOME",
|
|
);
|
|
expect(intoOutcome.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("does not treat a node with only findings as a non-sink", () => {
|
|
// role-1 has an outgoing HAS_FINDING edge but no resource edge to the group
|
|
// when collapsed... it does have edges to the policy group, so the group is
|
|
// the sink. Verify the outcome attaches to the policy group, not the finding.
|
|
const view = buildAttackPathView({
|
|
data: baseGraph,
|
|
expandedClasses: new Set(),
|
|
outcome,
|
|
});
|
|
const groupId = view.nodes.find((n) =>
|
|
n.labels.includes(GROUP_NODE_LABEL),
|
|
)?.id;
|
|
const outcomeNode = view.nodes.find((n) =>
|
|
n.labels.includes(OUTCOME_NODE_LABEL),
|
|
);
|
|
expect(
|
|
view.edges.some(
|
|
(e) => e.source === groupId && e.target === outcomeNode?.id,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("injects no outcome node when outcome is absent", () => {
|
|
const view = buildAttackPathView({
|
|
data: baseGraph,
|
|
expandedClasses: new Set(),
|
|
});
|
|
expect(view.nodes.some((n) => n.labels.includes(OUTCOME_NODE_LABEL))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("attaches the outcome to all nodes when there is no clear sink", () => {
|
|
const view = buildAttackPathView({
|
|
data: { nodes: [node("principal-1", "AWSPrincipal")], edges: [] },
|
|
expandedClasses: new Set(),
|
|
outcome,
|
|
});
|
|
const outcomeNode = view.nodes.find((n) =>
|
|
n.labels.includes(OUTCOME_NODE_LABEL),
|
|
);
|
|
expect(outcomeNode).toBeDefined();
|
|
expect(
|
|
view.edges.some(
|
|
(e) => e.source === "principal-1" && e.target === outcomeNode?.id,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("renders every member of an expanded class without dropping any", () => {
|
|
const many: AttackPathGraphData = {
|
|
nodes: [
|
|
node("src", "AWSRole"),
|
|
...Array.from({ length: 40 }, (_, i) => node(`m-${i}`, "AWSPolicy")),
|
|
],
|
|
edges: Array.from({ length: 40 }, (_, i) => ({
|
|
id: `e-${i}`,
|
|
source: "src",
|
|
target: `m-${i}`,
|
|
type: "POLICY",
|
|
})),
|
|
};
|
|
const view = buildAttackPathView({
|
|
data: many,
|
|
expandedClasses: new Set([groupKey(1, "AWSPolicy")]),
|
|
});
|
|
const rendered = view.nodes.filter((n) => n.id.startsWith("m-")).length;
|
|
// Expanding shows the complete set, so the path is never presented as
|
|
// complete while silently omitting members.
|
|
expect(rendered).toBe(40);
|
|
|
|
// Every edge endpoint must resolve to a rendered node.
|
|
const nodeIds = new Set(view.nodes.map((n) => n.id));
|
|
for (const edge of view.edges) {
|
|
expect(nodeIds.has(edge.source)).toBe(true);
|
|
expect(nodeIds.has(edge.target)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("keeps the outcome terminal across reversed container relationships", () => {
|
|
// `instance-1 RUNS_IN vpc-1` is reversed by the layout, which renders
|
|
// `vpc-1 -> instance-1` (container -> child). That makes instance-1 the
|
|
// laid-out sink. Using the raw edge direction would instead treat vpc-1 as
|
|
// the sink and hang the outcome off it as a mid-path sibling; the transform
|
|
// must orient the edge the same way the layout does.
|
|
const containerGraph: AttackPathGraphData = {
|
|
nodes: [node("instance-1", "EC2Instance"), node("vpc-1", "VPC")],
|
|
edges: [
|
|
{ id: "c1", source: "instance-1", target: "vpc-1", type: "RUNS_IN" },
|
|
],
|
|
};
|
|
const view = buildAttackPathView({
|
|
data: containerGraph,
|
|
expandedClasses: new Set(),
|
|
outcome,
|
|
});
|
|
const outcomeNode = view.nodes.find((n) =>
|
|
n.labels.includes(OUTCOME_NODE_LABEL),
|
|
);
|
|
expect(outcomeNode).toBeDefined();
|
|
const intoOutcome = view.edges.filter(
|
|
(e) => e.target === outcomeNode?.id && e.type === "OUTCOME",
|
|
);
|
|
// The outcome attaches to the laid-out sink (the child), not the container.
|
|
expect(intoOutcome.map((e) => e.source)).toEqual(["instance-1"]);
|
|
});
|
|
|
|
it("returns an empty view when nothing survives hub removal", () => {
|
|
const view = buildAttackPathView({
|
|
data: {
|
|
nodes: [node("acct", "AWSAccount")],
|
|
edges: [],
|
|
},
|
|
expandedClasses: new Set(),
|
|
outcome,
|
|
});
|
|
expect(view.nodes).toHaveLength(0);
|
|
expect(view.edges).toHaveLength(0);
|
|
});
|
|
});
|