();
+ 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() {