From e392299d2cf5514a8ae873087abeb01166cc9232 Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Tue, 26 May 2026 09:11:19 +0200 Subject: [PATCH] feat(ui): grouped template-graph view for attack paths Redesigns the Attack Paths graph around the structure of the attack: - New _lib/template-graph.ts groups concrete nodes by resource type into one synthetic node per type (with a count badge), dedupes inter-group edges, drops intra-group self-loops, and appends a terminal Outcome node wired from the sink representatives. Account and finding nodes are filtered out of this structural view. - _lib/layout.ts adds 'attackGroup' and 'outcome' node types and a MarkerType.ArrowClosed markerEnd on every edge so attack direction is explicit. 'attackGroup' is intentionally named off the reserved React Flow 'group' type, which otherwise paints a default gray container behind the node. - New GroupNode (stacked-card visual, type icon, count badge, click-to-expand) and OutcomeNode (severity-colored terminal with Crosshair) components, registered in NODE_TYPES. - useGraphStore gains templateSource, outcome, expandedTypes and toggleExpandedType; the rendered graph is the collapsed template, recomputed via buildTemplateGraph on every state change. - attack-paths-page handleNodeClick: grouped type -> expand; expanded concrete resource -> collapse its type; outcome node is inert. A key based on expandedTypes forces React Flow to refit on expansion. Banner copy updated. - graph-legend.tsx skips the outcome marker label so the legend does not list it as a resource type. - Unit tests for buildTemplateGraph (grouping, edge dedup, expand, sink->outcome wiring, finding/account drop, empty input) and for the new edge markerEnd in layoutWithDagre. --- .../_components/graph/attack-path-graph.tsx | 10 +- .../_components/graph/graph-legend.tsx | 3 + .../_components/graph/nodes/group-node.tsx | 168 ++++++++++++++++++ .../_components/graph/nodes/outcome-node.tsx | 151 ++++++++++++++++ .../query-builder/_hooks/use-graph-state.ts | 62 ++++++- .../(workflow)/query-builder/_lib/index.ts | 10 ++ .../query-builder/_lib/layout.test.ts | 17 ++ .../(workflow)/query-builder/_lib/layout.ts | 16 +- .../query-builder/_lib/template-graph.test.ts | 134 ++++++++++++++ .../query-builder/_lib/template-graph.ts | 166 +++++++++++++++++ .../query-builder/attack-paths-page.tsx | 47 ++++- ui/types/attack-paths.ts | 7 + 12 files changed, 778 insertions(+), 13 deletions(-) create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/group-node.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/outcome-node.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/template-graph.test.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/template-graph.ts 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 6e9c608f64..c012eb4c86 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 @@ -31,11 +31,14 @@ import { getNodeColor, getPathEdges, GRAPH_EDGE_HIGHLIGHT_COLOR, + isGroupNode, resolveHiddenFindingIds, } from "../../_lib"; import { isFindingNode, layoutWithDagre } from "../../_lib/layout"; import { FindingNode } from "./nodes/finding-node"; +import { GroupNode } from "./nodes/group-node"; import { InternetNode } from "./nodes/internet-node"; +import { OutcomeNode } from "./nodes/outcome-node"; import { ResourceNode } from "./nodes/resource-node"; // --- Types --- @@ -69,6 +72,8 @@ const NODE_TYPES = { finding: FindingNode, internet: InternetNode, resource: ResourceNode, + attackGroup: GroupNode, + outcome: OutcomeNode, } as const; // --- CSS for animated dashed edges, selected node pulse, and edge highlight --- @@ -460,7 +465,10 @@ const GraphCanvas = ({ className: cn( node.className, isFindingNode(node.data.graphNode.labels) || - resourcesWithFindings.has(node.id) + resourcesWithFindings.has(node.id) || + isGroupNode(node.data.graphNode) || + // Expanded type members are clickable to collapse back into their group. + node.type === "resource" ? "cursor-pointer" : "cursor-default", ), diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx index 03e17cfd3e..56523ca7a5 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx @@ -24,6 +24,7 @@ import { } from "../../_lib/graph-colors"; import { resolveHiddenFindingIds } from "../../_lib/graph-utils"; import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals"; +import { ATTACK_PATH_OUTCOME_LABEL } from "../../_lib/template-graph"; const LEGEND_PREVIEW = { BADGE_RADIUS: 16, @@ -270,6 +271,8 @@ const resolveNodeTypeItems = ( for (const node of visibleNodes) { if (isFindingNode(node)) continue; + // Outcome nodes are conceptual, not a resource type. + if (node.labels.includes(ATTACK_PATH_OUTCOME_LABEL)) continue; const visual = resolveNodeVisual(node); if (visual.category === NODE_CATEGORY.ACCOUNT) continue; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/group-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/group-node.tsx new file mode 100644 index 0000000000..9887ffd6bc --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/group-node.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { type NodeProps, Position } from "@xyflow/react"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import type { GraphNode } from "@/types/attack-paths"; + +import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; +import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; +import { HiddenHandles } from "./hidden-handles"; + +interface GroupNodeData { + graphNode: GraphNode; + [key: string]: unknown; +} + +const NODE_WIDTH = RESOURCE_NODE_DIMENSIONS.WIDTH; +const NODE_HEIGHT = RESOURCE_NODE_DIMENSIONS.HEIGHT; +const BADGE_SIZE = 48; +const BADGE_RADIUS = BADGE_SIZE / 2; +const BADGE_CENTER_X = NODE_WIDTH / 2; +const BADGE_CENTER_Y = 28; +const BADGE_LEFT_X = BADGE_CENTER_X - BADGE_RADIUS; +const BADGE_RIGHT_X = BADGE_CENTER_X + BADGE_RADIUS; +const ICON_SIZE = 26; +const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2; +const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2; +// Count chip sits at the top-right of the badge. +const CHIP_CX = BADGE_CENTER_X + BADGE_RADIUS - 2; +const CHIP_CY = BADGE_CENTER_Y - BADGE_RADIUS + 4; + +export const GroupNode = ({ data, selected }: NodeProps) => { + const { graphNode } = data as GroupNodeData; + const visual = resolveNodeVisual(graphNode); + const Icon = visual.Icon; + const { fillColor, borderColor } = resolveNodeColors({ + labels: graphNode.labels, + properties: graphNode.properties, + selected, + }); + const count = Number(graphNode.properties.count ?? 0); + const typeLabel = visual.description; + + const nodeSvg = ( + + {/* Stacked-card hint: two offset rounded rects behind the badge to signal + this single node stands for many resources. */} + + + + + + + {/* Count chip */} + + + {count > 99 ? "99+" : count} + + + + {typeLabel} + + + {count} {count === 1 ? "resource" : "resources"} + + + click to expand + + + + ); + + return ( + <> + + + {nodeSvg} + + {count} {typeLabel} {count === 1 ? "resource" : "resources"} — click + to expand + + + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/outcome-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/outcome-node.tsx new file mode 100644 index 0000000000..fee3b8b0de --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/outcome-node.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { type NodeProps, Position } from "@xyflow/react"; +import { Crosshair } from "lucide-react"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import type { GraphNode } from "@/types/attack-paths"; + +import { + GRAPH_NODE_BORDER_COLORS, + GRAPH_NODE_COLORS, +} from "../../../_lib/graph-colors"; +import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; +import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; +import { HiddenHandles } from "./hidden-handles"; + +interface OutcomeNodeData { + graphNode: GraphNode; + [key: string]: unknown; +} + +const NODE_WIDTH = RESOURCE_NODE_DIMENSIONS.WIDTH; +const NODE_HEIGHT = RESOURCE_NODE_DIMENSIONS.HEIGHT; +const BADGE_SIZE = 48; +const BADGE_RADIUS = BADGE_SIZE / 2; +const BADGE_CENTER_X = NODE_WIDTH / 2; +const BADGE_CENTER_Y = 28; +const BADGE_LEFT_X = BADGE_CENTER_X - BADGE_RADIUS; +const ICON_SIZE = 26; +const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2; +const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2; +const NAME_Y = 72; +const NAME_LINE_HEIGHT = 13; + +type Severity = keyof typeof GRAPH_NODE_COLORS; + +const resolveSeverityColors = ( + severity: string, +): { fill: string; border: string } => { + const key = severity.toLowerCase() as Severity; + if (key in GRAPH_NODE_COLORS) { + return { + fill: GRAPH_NODE_COLORS[key], + border: GRAPH_NODE_BORDER_COLORS[key as keyof typeof GRAPH_NODE_BORDER_COLORS], + }; + } + return { fill: GRAPH_NODE_COLORS.high, border: GRAPH_NODE_BORDER_COLORS.high }; +}; + +export const OutcomeNode = ({ data, selected }: NodeProps) => { + const { graphNode } = data as OutcomeNodeData; + const label = String(graphNode.properties.label ?? "Outcome"); + const description = String(graphNode.properties.description ?? ""); + const severity = String(graphNode.properties.severity ?? "high"); + const { fill, border } = resolveSeverityColors(severity); + + const displayName = getNodeLabelDisplay(label, 18, 3); + + const nodeSvg = ( + + + + + + + + + OUTCOME + + {displayName.lines.map((line, index) => ( + + {line} + + ))} + + + ); + + return ( + <> + + + {nodeSvg} + + {label} + {description ? {description} : null} + + + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts index 6484a33843..03a351a900 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts @@ -8,7 +8,11 @@ import type { GraphState, } from "@/types/attack-paths"; -import { computeFilteredSubgraph } from "../_lib"; +import { + type AttackPathOutcome, + buildTemplateGraph, + computeFilteredSubgraph, +} from "../_lib"; interface FilteredViewState { isFilteredView: boolean; @@ -19,10 +23,19 @@ interface FilteredViewState { // swaps that happen when entering/exiting filtered view. Reset only on // fresh data loads (new query / scan) — see `setGraphData`. expandedResources: Set; + // Template-graph state: the raw concrete graph + outcome, and which resource + // *types* are currently expanded into their concrete members. `data` is the + // grouped template derived from these via buildTemplateGraph. + templateSource: AttackPathGraphData | null; + outcome: AttackPathOutcome | null; + expandedTypes: Set; } interface GraphStore extends GraphState, FilteredViewState { - setGraphData: (data: AttackPathGraphData) => void; + setGraphData: ( + data: AttackPathGraphData, + outcome?: AttackPathOutcome | null, + ) => void; setSelectedNodeId: (nodeId: string | null) => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; @@ -33,6 +46,7 @@ interface GraphStore extends GraphState, FilteredViewState { fullData: AttackPathGraphData | null, ) => void; toggleExpandedResource: (resourceId: string) => void; + toggleExpandedType: (typeKey: string) => void; reset: () => void; } @@ -45,20 +59,51 @@ const initialState: GraphState & FilteredViewState = { filteredNodeId: null, fullData: null, expandedResources: new Set(), + templateSource: null, + outcome: null, + expandedTypes: new Set(), }; export const useGraphStore = create((set) => ({ ...initialState, - setGraphData: (data) => + setGraphData: (data, outcome = null) => set({ - data, + // Default view is the collapsed template graph; the raw concrete graph + // is kept as templateSource for expand/collapse. + data: buildTemplateGraph(data, new Set(), outcome), + templateSource: data, + outcome, + expandedTypes: new Set(), fullData: null, error: null, isFilteredView: false, filteredNodeId: null, + selectedNodeId: null, // Fresh data → drop any stale expansion from the previous graph. expandedResources: new Set(), }), + toggleExpandedType: (typeKey) => + set((state) => { + const expandedTypes = new Set(state.expandedTypes); + if (expandedTypes.has(typeKey)) { + expandedTypes.delete(typeKey); + } else { + expandedTypes.add(typeKey); + } + return { + expandedTypes, + data: buildTemplateGraph( + state.templateSource, + expandedTypes, + state.outcome, + ), + // Re-deriving the template invalidates any active filtered view. + isFilteredView: false, + filteredNodeId: null, + fullData: null, + selectedNodeId: null, + }; + }), setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }), setLoading: (loading) => set({ loading }), setError: (error) => set({ error }), @@ -88,8 +133,11 @@ export const useGraphState = () => { const store = useGraphStore(); // Zustand store methods are stable, no need to memoize - const updateGraphData = (data: AttackPathGraphData) => { - store.setGraphData(data); + const updateGraphData = ( + data: AttackPathGraphData, + outcome: AttackPathOutcome | null = null, + ) => { + store.setGraphData(data, outcome); }; const selectNode = (nodeId: string | null) => { @@ -171,6 +219,8 @@ export const useGraphState = () => { filteredNode: getFilteredNode(), expandedResources: store.expandedResources, toggleExpandedResource: store.toggleExpandedResource, + expandedTypes: store.expandedTypes, + toggleExpandedType: store.toggleExpandedType, updateGraphData, selectNode, startLoading, 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 261a27cf8d..ee7a635da2 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 @@ -24,3 +24,13 @@ export { type NodeVisual, resolveNodeVisual, } from "./node-visuals"; +export { + ATTACK_PATH_GROUP_LABEL, + ATTACK_PATH_OUTCOME_LABEL, + type AttackPathOutcome, + buildTemplateGraph, + isGroupNode, + isOutcomeNode, + nodeTypeKey, + OUTCOME_NODE_ID, +} from "./template-graph"; 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 7d8cb20c96..ba04575202 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 @@ -54,6 +54,23 @@ describe("layoutWithDagre", () => { }); }); + it("adds a directional arrowhead (markerEnd) to every edge", () => { + const { rfEdges } = layoutWithDagre( + [resourceNode, findingNode], + [ + { + id: "e1", + source: "resource-1", + target: "finding-1", + type: "HAS_FINDING", + }, + ], + ); + + expect(rfEdges).toHaveLength(1); + expect(rfEdges[0].markerEnd).toMatchObject({ type: "arrowclosed" }); + }); + it("is deterministic: same input produces equal output across runs", () => { const nodes = [findingNode, resourceNode]; const edges: GraphEdge[] = [ 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 cb67d11083..9526117840 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 @@ -4,7 +4,7 @@ */ import { Graph, layout as dagreLayout } from "@dagrejs/dagre"; -import { type Edge, type Node, Position } from "@xyflow/react"; +import { type Edge, MarkerType, type Node, Position } from "@xyflow/react"; import type { GraphEdge, GraphNode } from "@/types/attack-paths"; @@ -13,6 +13,10 @@ import { INTERNET_NODE_DIMENSIONS, RESOURCE_NODE_DIMENSIONS, } from "./node-dimensions"; +import { + ATTACK_PATH_GROUP_LABEL, + ATTACK_PATH_OUTCOME_LABEL, +} from "./template-graph"; // Container relationships that get reversed for proper hierarchy const CONTAINER_RELATIONS = new Set([ @@ -30,6 +34,10 @@ const NODE_TYPE = { FINDING: "finding", INTERNET: "internet", RESOURCE: "resource", + // NB: not "group" — that is a reserved React Flow node type that renders a + // default gray container box behind the node. + GROUP: "attackGroup", + OUTCOME: "outcome", } as const; type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE]; @@ -38,6 +46,8 @@ export const isFindingNode = (labels: string[]): boolean => labels.some((l) => l.toLowerCase().includes("finding")); const getNodeType = (labels: string[]): NodeType => { + if (labels.includes(ATTACK_PATH_OUTCOME_LABEL)) return NODE_TYPE.OUTCOME; + if (labels.includes(ATTACK_PATH_GROUP_LABEL)) return NODE_TYPE.GROUP; if (isFindingNode(labels)) return NODE_TYPE.FINDING; if (labels.some((l) => l.toLowerCase() === "internet")) return NODE_TYPE.INTERNET; @@ -57,6 +67,7 @@ const getNodeDimensions = ( width: INTERNET_NODE_DIMENSIONS.DIAMETER, height: INTERNET_NODE_DIMENSIONS.DIAMETER, }; + // Group and outcome nodes share the resource footprint for consistent ranks. return { width: RESOURCE_NODE_DIMENSIONS.WIDTH, height: RESOURCE_NODE_DIMENSIONS.HEIGHT, @@ -157,6 +168,9 @@ export const layoutWithDagre = ( target: e.w, animated: hasFinding, className: hasFinding ? "finding-edge" : "resource-edge", + // Arrowhead makes the attack direction explicit (in addition to the + // left-to-right layout). + markerEnd: { type: MarkerType.ArrowClosed, width: 18, height: 18 }, data: { pathKey: `${e.v}-${e.w}`, originalSource: edgeData.originalSource, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/template-graph.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/template-graph.test.ts new file mode 100644 index 0000000000..b0b91e6c78 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/template-graph.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; + +import { + ATTACK_PATH_GROUP_LABEL, + ATTACK_PATH_OUTCOME_LABEL, + buildTemplateGraph, + isGroupNode, + isOutcomeNode, + OUTCOME_NODE_ID, +} from "./template-graph"; + +const role = (id: string): GraphNode => ({ + id, + labels: ["AWSRole"], + properties: { name: id }, +}); + +const instance = (id: string): GraphNode => ({ + id, + labels: ["EC2Instance"], + properties: { name: id }, +}); + +const outcome = { + label: "Code execution", + description: "Run code with the role's privileges.", + severity: "high", +}; + +// Two roles and two instances; each role can act on each instance. +const baseData: AttackPathGraphData = { + nodes: [role("role-1"), role("role-2"), instance("ec2-1"), instance("ec2-2")], + edges: [ + { id: "e1", source: "role-1", target: "ec2-1", type: "CAN_X" }, + { id: "e2", source: "role-2", target: "ec2-2", type: "CAN_X" }, + ], +}; + +describe("buildTemplateGraph", () => { + it("collapses concrete nodes into one group node per type", () => { + const { nodes } = buildTemplateGraph(baseData, new Set(), null); + + const groups = nodes.filter(isGroupNode); + expect(groups).toHaveLength(2); + + const byType = new Map( + groups.map((g) => [String(g.properties.typeKey), g.properties.count]), + ); + expect(byType.get("AWS Role")).toBe(2); + expect(byType.get("EC2 Instance")).toBe(2); + }); + + it("dedupes and collapses edges between groups, dropping self-loops", () => { + const { edges = [] } = buildTemplateGraph(baseData, new Set(), null); + + // Both concrete edges collapse to a single AWS Role group -> EC2 group edge + const stepEdges = edges.filter((e) => e.target.startsWith("group:")); + expect(stepEdges).toHaveLength(1); + expect(stepEdges[0].source).toBe("group:AWS Role"); + expect(stepEdges[0].target).toBe("group:EC2 Instance"); + }); + + it("expands a single type into its concrete members", () => { + const { nodes } = buildTemplateGraph( + baseData, + new Set(["AWS Role"]), + null, + ); + + // Roles are now concrete; instances remain a group. + expect(nodes.some((n) => n.id === "role-1")).toBe(true); + expect(nodes.some((n) => n.id === "role-2")).toBe(true); + expect(nodes.some((n) => n.id === "group:AWS Role")).toBe(false); + expect(nodes.some((n) => n.id === "group:EC2 Instance")).toBe(true); + }); + + it("appends an outcome node connected from sink representatives", () => { + const { nodes, edges = [] } = buildTemplateGraph( + baseData, + new Set(), + outcome, + ); + + const outcomeNodes = nodes.filter(isOutcomeNode); + expect(outcomeNodes).toHaveLength(1); + expect(outcomeNodes[0].id).toBe(OUTCOME_NODE_ID); + expect(outcomeNodes[0].labels).toContain(ATTACK_PATH_OUTCOME_LABEL); + + // The EC2 group is the sink → it connects to the outcome. + const toOutcome = edges.filter((e) => e.target === OUTCOME_NODE_ID); + expect(toOutcome).toHaveLength(1); + expect(toOutcome[0].source).toBe("group:EC2 Instance"); + }); + + it("omits the outcome node when no outcome is provided", () => { + const { nodes } = buildTemplateGraph(baseData, new Set(), null); + expect(nodes.some(isOutcomeNode)).toBe(false); + }); + + it("drops finding and account nodes from the structural view", () => { + const data: AttackPathGraphData = { + nodes: [ + role("role-1"), + { id: "acc", labels: ["AWSAccount"], properties: {} }, + { + id: "f1", + labels: ["ProwlerFinding"], + properties: { severity: "high" }, + }, + ], + edges: [ + { id: "e1", source: "acc", target: "role-1", type: "RESOURCE" }, + { id: "e2", source: "role-1", target: "f1", type: "HAS_FINDING" }, + ], + }; + + const { nodes, edges = [] } = buildTemplateGraph(data, new Set(), null); + + expect(nodes.some((n) => n.labels.includes("AWSAccount"))).toBe(false); + expect(nodes.some((n) => n.labels.includes("ProwlerFinding"))).toBe(false); + // Only the AWS Role group survives; its account/finding edges are gone. + expect(nodes).toHaveLength(1); + expect(nodes[0].labels).toContain(ATTACK_PATH_GROUP_LABEL); + expect(edges).toHaveLength(0); + }); + + it("returns an empty graph for empty input", () => { + const { nodes, edges } = buildTemplateGraph(null, new Set(), outcome); + expect(nodes).toHaveLength(0); + expect(edges).toHaveLength(0); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/template-graph.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/template-graph.ts new file mode 100644 index 0000000000..7678ea45e9 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/template-graph.ts @@ -0,0 +1,166 @@ +/** + * Template (grouped-by-type) attack-path graph. + * + * The default visualization is a compact "structure of the attack" graph: one + * node per resource *type* (e.g. "AWS Role", "EC2 Instance") plus a terminal + * Outcome node, connected left-to-right in the direction of the attack. Each + * type node can be expanded to reveal the concrete resources it represents. + * + * This keeps the first read of the graph easy (the shape of the attack) and + * makes the concrete resources available on demand. Account/root nodes are + * never included (already stripped by the API; filtered defensively here), and + * findings are intentionally left out of this structural view. + */ + +import type { AttackPathGraphData, GraphEdge, GraphNode } from "@/types/attack-paths"; + +import { NODE_CATEGORY, resolveNodeVisual } from "./node-visuals"; + +// Marker labels for synthetic nodes. The graph pipeline (layout, node +// components, click handling) keys off these. +export const ATTACK_PATH_GROUP_LABEL = "AttackPathGroup"; +export const ATTACK_PATH_OUTCOME_LABEL = "AttackPathOutcome"; + +export const OUTCOME_NODE_ID = "attack-path-outcome"; + +// Synthetic edge types — chosen so they never collide with real Cartography +// relationship types (and so layout's container-reversal never touches them). +const TEMPLATE_EDGE_TYPE = "ATTACK_STEP"; +const OUTCOME_EDGE_TYPE = "LEADS_TO"; + +export interface AttackPathOutcome { + label: string; + description: string; + severity: string; +} + +const isFindingNode = (node: GraphNode): boolean => + node.labels.some((label) => label.toLowerCase().includes("finding")); + +export const isGroupNode = (node: GraphNode): boolean => + node.labels.includes(ATTACK_PATH_GROUP_LABEL); + +export const isOutcomeNode = (node: GraphNode): boolean => + node.labels.includes(ATTACK_PATH_OUTCOME_LABEL); + +/** Stable grouping key for a node: its human resource type (e.g. "AWS Role"). */ +export const nodeTypeKey = (node: GraphNode): string => + resolveNodeVisual(node).description; + +const groupNodeId = (typeKey: string): string => `group:${typeKey}`; + +const makeGroupNode = (typeKey: string, members: GraphNode[]): GraphNode => ({ + // Carry the representative member's labels (after the marker) so node-visuals + // resolves the correct icon/colors for the type. + id: groupNodeId(typeKey), + labels: [ATTACK_PATH_GROUP_LABEL, ...members[0].labels], + properties: { + typeKey, + count: members.length, + }, +}); + +const makeOutcomeNode = (outcome: AttackPathOutcome): GraphNode => ({ + id: OUTCOME_NODE_ID, + labels: [ATTACK_PATH_OUTCOME_LABEL], + properties: { + label: outcome.label, + description: outcome.description, + severity: outcome.severity, + }, +}); + +/** + * Build the grouped template graph from the concrete attack-path graph. + * + * @param data Concrete graph (nodes + edges) from the API/adapter. + * @param expandedTypes Set of type keys currently expanded into members. + * @param outcome Attack outcome metadata (terminal node), or null. + */ +export const buildTemplateGraph = ( + data: AttackPathGraphData | null, + expandedTypes: ReadonlySet, + outcome: AttackPathOutcome | null, +): AttackPathGraphData => { + const nodes = data?.nodes ?? []; + const edges = data?.edges ?? []; + + const nodeById = new Map(nodes.map((node) => [node.id, node])); + + // Keep resource + internet nodes; drop findings and (defensively) accounts. + const relevant = nodes.filter((node) => { + if (isFindingNode(node)) return false; + return resolveNodeVisual(node).category !== NODE_CATEGORY.ACCOUNT; + }); + const relevantIds = new Set(relevant.map((node) => node.id)); + + const isInternet = (node: GraphNode): boolean => + resolveNodeVisual(node).category === NODE_CATEGORY.INTERNET; + + // Group resource nodes by type. Internet nodes stay concrete (single entry). + const membersByType = new Map(); + relevant.forEach((node) => { + if (isInternet(node)) return; + const key = nodeTypeKey(node); + const list = membersByType.get(key) ?? []; + list.push(node); + membersByType.set(key, list); + }); + + // Map a concrete node id to the id of the node that represents it in the + // template: itself when its type is expanded (or internet), else its group. + const repOf = (id: string): string | null => { + const node = nodeById.get(id); + if (!node || !relevantIds.has(id)) return null; + if (isInternet(node)) return id; + const key = nodeTypeKey(node); + return expandedTypes.has(key) ? id : groupNodeId(key); + }; + + const outNodes: GraphNode[] = []; + relevant.filter(isInternet).forEach((node) => outNodes.push(node)); + membersByType.forEach((members, key) => { + if (expandedTypes.has(key)) { + members.forEach((member) => outNodes.push(member)); + } else { + outNodes.push(makeGroupNode(key, members)); + } + }); + + // Collapse concrete edges onto representative edges, de-duplicated and with + // self-loops (intra-group edges) removed. + const seen = new Set(); + const outEdges: GraphEdge[] = []; + edges.forEach((edge) => { + const source = repOf(edge.source); + const target = repOf(edge.target); + if (!source || !target || source === target) return; + const key = `${source}->${target}`; + if (seen.has(key)) return; + seen.add(key); + outEdges.push({ + id: `tmpl:${key}`, + source, + target, + type: TEMPLATE_EDGE_TYPE, + }); + }); + + // Append the outcome node and connect every sink (no outgoing edge) to it. + if (outcome && outNodes.length > 0) { + const hasOutgoing = new Set(outEdges.map((edge) => edge.source)); + outNodes + .filter((node) => !hasOutgoing.has(node.id)) + .forEach((node) => { + outEdges.push({ + id: `tmpl:outcome:${node.id}`, + source: node.id, + target: OUTCOME_NODE_ID, + type: OUTCOME_EDGE_TYPE, + }); + }); + outNodes.push(makeOutcomeNode(outcome)); + } + + return { nodes: outNodes, edges: outEdges }; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index 992fdee5ef..6d9f4fe5e0 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -55,7 +55,12 @@ import { import type { GraphHandle } from "./_components/graph/attack-path-graph"; import { useGraphState } from "./_hooks/use-graph-state"; import { useQueryBuilder } from "./_hooks/use-query-builder"; -import { exportGraphAsPNG } from "./_lib"; +import { + ATTACK_PATH_GROUP_LABEL, + ATTACK_PATH_OUTCOME_LABEL, + exportGraphAsPNG, + nodeTypeKey, +} from "./_lib"; /** * Attack Paths @@ -250,7 +255,10 @@ export default function AttackPathsPage() { } } else if (result?.data?.attributes) { const graphData = adaptQueryResultToGraphData(result.data.attributes); - graphState.updateGraphData(graphData); + graphState.updateGraphData( + graphData, + result.data.attributes.outcome ?? null, + ); toast({ title: "Success", description: "Query executed successfully", @@ -287,10 +295,32 @@ export default function AttackPathsPage() { }; const handleNodeClick = (node: GraphNode) => { + // Template type node → expand/collapse into its concrete resources. + if (node.labels.includes(ATTACK_PATH_GROUP_LABEL)) { + const typeKey = String(node.properties.typeKey ?? ""); + if (typeKey) graphState.toggleExpandedType(typeKey); + return; + } + + // Outcome node is terminal/informational — no drill-down. + if (node.labels.includes(ATTACK_PATH_OUTCOME_LABEL)) { + return; + } + const isFinding = node.labels.some((label) => label.toLowerCase().includes("finding"), ); + // A concrete resource that belongs to an expanded type → collapse it back + // into its type group. + if (!isFinding) { + const typeKey = nodeTypeKey(node); + if (graphState.expandedTypes.has(typeKey)) { + graphState.toggleExpandedType(typeKey); + return; + } + } + if (isFinding) { if (findingNavigationInFlightRef.current) { return; @@ -366,6 +396,10 @@ export default function AttackPathsPage() { } }; + // Remount the graph when the set of expanded types changes so React Flow + // re-runs its initial fitView on the new (larger/smaller) template layout. + const expansionKey = Array.from(graphState.expandedTypes).sort().join("|"); + return (
{/* Auto-refresh scans when there's an executing scan */} @@ -530,9 +564,10 @@ export default function AttackPathsPage() { 💡 - Click a finding to focus its connected path, or click - a resource with findings to show or hide its related - findings + The graph reads left to right, following the attack + toward its outcome. Click a resource type to expand it + into its individual resources, and click a resource to + collapse it back.
)} @@ -587,6 +622,7 @@ export default function AttackPathsPage() {