mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(ui): grouped Attack Paths graph with expandable resource classes and outcome node (#12381)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
5cfc22040a
commit
3074f02a63
+80
-7
@@ -22,8 +22,13 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths";
|
||||
import type {
|
||||
AttackPathGraphData,
|
||||
AttackPathOutcome,
|
||||
GraphNode,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
computeFilteredSubgraph,
|
||||
@@ -34,10 +39,16 @@ import {
|
||||
isProwlerFindingNode,
|
||||
resolveHiddenFindingIds,
|
||||
} from "../../_lib";
|
||||
import { layoutWithDagre } from "../../_lib/layout";
|
||||
import {
|
||||
type AttackPathView,
|
||||
buildAttackPathView,
|
||||
} from "../../_lib/group-graph";
|
||||
import { layoutWithDagre, NODE_TYPE } 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 ---
|
||||
@@ -57,7 +68,15 @@ interface AttackPathGraphProps {
|
||||
isFilteredView?: boolean;
|
||||
initialNodeId?: string;
|
||||
expandedResources?: Set<string>;
|
||||
// Which resource-class groups are expanded to their members.
|
||||
expandedClasses?: Set<string>;
|
||||
// Terminal outcome for the current query; injected as the graph's end node.
|
||||
outcome?: AttackPathOutcome | null;
|
||||
// Pre-built attack-path view shared with the PNG export so both render the
|
||||
// same grouped/outcome graph. Falls back to an internal build when omitted.
|
||||
view?: AttackPathView | null;
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
onNodeDoubleClick?: (node: GraphNode) => void;
|
||||
onInitialFilter?: (filteredData: AttackPathGraphData) => void;
|
||||
ref?: Ref<GraphHandle>;
|
||||
className?: string;
|
||||
@@ -68,9 +87,11 @@ const EMPTY_EXPANDED: ReadonlySet<string> = new Set();
|
||||
// --- Node type registry (stable reference) ---
|
||||
|
||||
const NODE_TYPES = {
|
||||
finding: FindingNode,
|
||||
internet: InternetNode,
|
||||
resource: ResourceNode,
|
||||
[NODE_TYPE.FINDING]: FindingNode,
|
||||
[NODE_TYPE.INTERNET]: InternetNode,
|
||||
[NODE_TYPE.RESOURCE]: ResourceNode,
|
||||
[NODE_TYPE.GROUP]: GroupNode,
|
||||
[NODE_TYPE.OUTCOME]: OutcomeNode,
|
||||
} as const;
|
||||
|
||||
// --- CSS for animated dashed edges, selected node pulse, and edge highlight ---
|
||||
@@ -205,7 +226,11 @@ const GraphCanvas = ({
|
||||
isFilteredView = false,
|
||||
initialNodeId,
|
||||
expandedResources,
|
||||
expandedClasses,
|
||||
outcome,
|
||||
view,
|
||||
onNodeClick,
|
||||
onNodeDoubleClick,
|
||||
onInitialFilter,
|
||||
ref,
|
||||
}: GraphCanvasProps) => {
|
||||
@@ -229,6 +254,23 @@ const GraphCanvas = ({
|
||||
// survives the data-swaps that happen on filtered-view enter/exit.
|
||||
const expanded = expandedResources ?? EMPTY_EXPANDED;
|
||||
|
||||
// Re-frame when a class group is expanded/collapsed so the new member set
|
||||
// fits smoothly instead of overflowing the viewport. Newly revealed members
|
||||
// are measured asynchronously, so poll until they have real dimensions rather
|
||||
// than fitting on a single frame where measured.width can still be 0.
|
||||
useEffect(() => {
|
||||
return scheduleMeasuredFit(
|
||||
() => {
|
||||
const visibleNodes = getNodes().filter((n) => !n.hidden);
|
||||
return (
|
||||
visibleNodes.length > 0 &&
|
||||
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
|
||||
);
|
||||
},
|
||||
() => fitView(AUTO_FIT_OPTIONS),
|
||||
);
|
||||
}, [expandedClasses, fitView, getNodes]);
|
||||
|
||||
// --- initialNodeId: synchronous filtered-view derivation on first render ---
|
||||
// Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph
|
||||
let effectiveData = data;
|
||||
@@ -380,8 +422,25 @@ const GraphCanvas = ({
|
||||
);
|
||||
}, [expanded, fitView, getNodes]);
|
||||
|
||||
const nodes = effectiveData.nodes ?? [];
|
||||
const edges = effectiveData.edges ?? [];
|
||||
// Cloud reshapes into the attack-path view: drop the account hub, collapse
|
||||
// each resource class into one expandable node, and inject the outcome node.
|
||||
// OSS keeps the original flat graph, so the grouped/outcome view is Cloud-only.
|
||||
// Findings pass through either way; the finding-hide logic below reveals them
|
||||
// per-resource, preserving existing behaviour.
|
||||
//
|
||||
// The parent builds and shares this view with the PNG export so both stay in
|
||||
// sync; only fall back to an internal build when it is not provided.
|
||||
const resolvedView =
|
||||
view ??
|
||||
(isCloud()
|
||||
? buildAttackPathView({
|
||||
data: effectiveData,
|
||||
expandedClasses: expandedClasses ?? EMPTY_EXPANDED,
|
||||
outcome,
|
||||
})
|
||||
: { nodes: effectiveData.nodes ?? [], edges: effectiveData.edges ?? [] });
|
||||
const nodes = resolvedView.nodes;
|
||||
const edges = resolvedView.edges;
|
||||
|
||||
// Pre-compute which resources have findings connected (O(n+e))
|
||||
const findingNodeIds = new Set<string>();
|
||||
@@ -510,6 +569,11 @@ const GraphCanvas = ({
|
||||
onNodeClick?.(graphNode);
|
||||
};
|
||||
|
||||
const handleNodeDoubleClick = (_event: MouseEvent, node: Node) => {
|
||||
const graphNode = (node.data as { graphNode: GraphNode }).graphNode;
|
||||
onNodeDoubleClick?.(graphNode);
|
||||
};
|
||||
|
||||
// Path highlight on hover
|
||||
const handleNodeMouseEnter = (_event: MouseEvent, node: Node) => {
|
||||
setHoveredNodeId(node.id);
|
||||
@@ -526,6 +590,7 @@ const GraphCanvas = ({
|
||||
edges={enrichedEdges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeMouseEnter={handleNodeMouseEnter}
|
||||
onNodeMouseLeave={handleNodeMouseLeave}
|
||||
fitView
|
||||
@@ -577,7 +642,11 @@ export const AttackPathGraph = ({
|
||||
isFilteredView,
|
||||
initialNodeId,
|
||||
expandedResources,
|
||||
expandedClasses,
|
||||
outcome,
|
||||
view,
|
||||
onNodeClick,
|
||||
onNodeDoubleClick,
|
||||
onInitialFilter,
|
||||
ref,
|
||||
className,
|
||||
@@ -601,7 +670,11 @@ export const AttackPathGraph = ({
|
||||
isFilteredView={isFilteredView}
|
||||
initialNodeId={initialNodeId}
|
||||
expandedResources={expandedResources}
|
||||
expandedClasses={expandedClasses}
|
||||
outcome={outcome}
|
||||
view={view}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onInitialFilter={onInitialFilter}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
|
||||
+25
-5
@@ -19,9 +19,6 @@ describe("GraphControls", () => {
|
||||
});
|
||||
|
||||
expect(exportButton).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /^export graph$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables the export button and invokes the callback when onExport is provided", async () => {
|
||||
@@ -34,10 +31,33 @@ describe("GraphControls", () => {
|
||||
name: /^export graph$/i,
|
||||
});
|
||||
|
||||
expect(exportButton).toBeEnabled();
|
||||
|
||||
await user.click(exportButton);
|
||||
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows collapse all only when available and invokes its handler", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onCollapse = vi.fn();
|
||||
const { rerender } = render(
|
||||
<GraphControls {...baseProps} collapseAll={{ can: false, onCollapse }} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /collapse all groups/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
rerender(
|
||||
<GraphControls {...baseProps} collapseAll={{ can: true, onCollapse }} />,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /collapse all groups/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(onCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
+38
-9
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
Download,
|
||||
Minimize2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
@@ -10,11 +16,21 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
|
||||
// Collapse-all is all-or-nothing: the enabled flag and its handler always
|
||||
// travel together, so a button can never be enabled without something to do.
|
||||
interface GraphCollapseAll {
|
||||
can: boolean;
|
||||
onCollapse: () => void;
|
||||
}
|
||||
|
||||
interface GraphControlsProps {
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onFitToScreen: () => void;
|
||||
onExport?: () => void;
|
||||
// Collapse every expanded resource-class group. Omitted where unsupported;
|
||||
// the button hides itself while nothing is expanded.
|
||||
collapseAll?: GraphCollapseAll;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,18 +42,34 @@ export const GraphControls = ({
|
||||
onZoomOut,
|
||||
onFitToScreen,
|
||||
onExport,
|
||||
collapseAll,
|
||||
}: GraphControlsProps) => {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
|
||||
<TooltipProvider>
|
||||
{collapseAll?.can && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={collapseAll.onCollapse}
|
||||
aria-label="Collapse all groups"
|
||||
>
|
||||
<ChevronsDownUp size={18} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Collapse all groups</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon-sm"
|
||||
onClick={onZoomIn}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn size={18} />
|
||||
@@ -50,9 +82,8 @@ export const GraphControls = ({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon-sm"
|
||||
onClick={onZoomOut}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut size={18} />
|
||||
@@ -65,9 +96,8 @@ export const GraphControls = ({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon-sm"
|
||||
onClick={onFitToScreen}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Fit graph to view"
|
||||
>
|
||||
<Minimize2 size={18} />
|
||||
@@ -80,10 +110,9 @@ export const GraphControls = ({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon-sm"
|
||||
onClick={onExport}
|
||||
disabled={!onExport}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label={onExport ? "Export graph" : "Export available soon"}
|
||||
>
|
||||
<Download size={18} />
|
||||
|
||||
+34
-1
@@ -1,6 +1,7 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { AttackPathGraphData } from "@/types/attack-paths";
|
||||
|
||||
import { GraphLegend } from "./graph-legend";
|
||||
@@ -9,6 +10,15 @@ vi.mock("next-themes", () => ({
|
||||
useTheme: () => ({ resolvedTheme: "dark" }),
|
||||
}));
|
||||
|
||||
// Default to OSS (isCloud === false); individual tests flip it to Cloud.
|
||||
vi.mock("@/lib/shared/env", () => ({ isCloud: vi.fn(() => false) }));
|
||||
|
||||
const mockIsCloud = vi.mocked(isCloud);
|
||||
|
||||
afterEach(() => {
|
||||
mockIsCloud.mockReturnValue(false);
|
||||
});
|
||||
|
||||
const graphData: AttackPathGraphData = {
|
||||
nodes: [
|
||||
{
|
||||
@@ -41,6 +51,8 @@ describe("GraphLegend", () => {
|
||||
);
|
||||
|
||||
// Then
|
||||
// OSS renders the flat graph, which keeps the account/provider hub, so the
|
||||
// legend shows a "Provider roots" section.
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /provider roots/i }),
|
||||
).toBeInTheDocument();
|
||||
@@ -85,6 +97,27 @@ describe("GraphLegend", () => {
|
||||
expect(screen.queryByText(/scroll to zoom/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide the provider roots section in Cloud", () => {
|
||||
// Given - Cloud, where the view transform removes the account/provider hub
|
||||
mockIsCloud.mockReturnValue(true);
|
||||
|
||||
// When
|
||||
render(
|
||||
<GraphLegend data={graphData} expandedResources={new Set(["bucket"])} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: /provider roots/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Provider")).not.toBeInTheDocument();
|
||||
// Other legend sections still render.
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /node types/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("S3 Bucket")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide finding legend items when finding nodes are hidden", () => {
|
||||
// Given - A resource has related findings, but it is not expanded yet
|
||||
|
||||
|
||||
+12
-5
@@ -10,6 +10,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
@@ -116,6 +117,8 @@ const buildVisualItem = (
|
||||
glow,
|
||||
});
|
||||
|
||||
// Only shown in OSS, whose flat graph still renders the account/provider hub
|
||||
// (Cloud's view transform removes it). See `providerItem` below.
|
||||
const providerRootItem = buildVisualItem(
|
||||
"Provider",
|
||||
"Cloud account, tenant, project, organization, or cluster entry point.",
|
||||
@@ -481,11 +484,15 @@ export const GraphLegend = ({
|
||||
expandedResources,
|
||||
isFilteredView,
|
||||
);
|
||||
const providerItem = legendState.visibleNodes.some(
|
||||
(node) => resolveNodeVisual(node).category === NODE_CATEGORY.ACCOUNT,
|
||||
)
|
||||
? providerRootItem
|
||||
: null;
|
||||
// OSS only: the hub node is present in the flat graph but stripped by Cloud's
|
||||
// view transform, so the legend entry follows the same split.
|
||||
const providerItem =
|
||||
!isCloud() &&
|
||||
legendState.visibleNodes.some(
|
||||
(node) => resolveNodeVisual(node).category === NODE_CATEGORY.ACCOUNT,
|
||||
)
|
||||
? providerRootItem
|
||||
: null;
|
||||
const visibleNodeTypeItems = resolveNodeTypeItems(legendState.visibleNodes);
|
||||
const visibleFindingRiskItems = resolveFindingRiskItems(
|
||||
legendState.visibleNodes,
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { Position } from "@xyflow/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface GraphNodeShellProps {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Badge center — anchors the hidden left/right connection handles. */
|
||||
badgeCenterX: number;
|
||||
badgeCenterY: number;
|
||||
badgeRadius: number;
|
||||
testId: string;
|
||||
svgClassName?: string;
|
||||
/**
|
||||
* Full label to reveal when the on-node text is truncated. When set, the SVG
|
||||
* becomes keyboard-focusable and is wrapped in a tooltip; omit it otherwise.
|
||||
*/
|
||||
tooltip?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared shell for the SVG-drawn attack-path nodes: the sized `<svg>` canvas,
|
||||
* the left/right hidden connection handles positioned at the badge edges, and
|
||||
* the focusable truncated-label tooltip. Node components supply the badge,
|
||||
* icon, and label markup as children.
|
||||
*/
|
||||
export const GraphNodeShell = ({
|
||||
width,
|
||||
height,
|
||||
badgeCenterX,
|
||||
badgeCenterY,
|
||||
badgeRadius,
|
||||
testId,
|
||||
svgClassName,
|
||||
tooltip,
|
||||
children,
|
||||
}: GraphNodeShellProps) => {
|
||||
const svg = (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn("overflow-visible", svgClassName)}
|
||||
tabIndex={tooltip ? 0 : undefined}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HiddenHandles
|
||||
sourcePosition={Position.Right}
|
||||
sourceStyle={{ left: badgeCenterX + badgeRadius, top: badgeCenterY }}
|
||||
targetPosition={Position.Left}
|
||||
targetStyle={{ left: badgeCenterX - badgeRadius, top: badgeCenterY }}
|
||||
/>
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{svg}</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
svg
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { type NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
GRAPH_COUNT_BADGE_STROKE_COLOR,
|
||||
resolveNodeColors,
|
||||
resolveNodeVisual,
|
||||
} from "../../../_lib";
|
||||
import { GROUP_PROPS } from "../../../_lib/group-graph";
|
||||
import { GROUP_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
|
||||
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
|
||||
|
||||
import { GraphNodeShell } from "./graph-node-shell";
|
||||
|
||||
interface GroupNodeData {
|
||||
graphNode: GraphNode;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = GROUP_NODE_DIMENSIONS.WIDTH;
|
||||
const NODE_HEIGHT = GROUP_NODE_DIMENSIONS.HEIGHT;
|
||||
const NAME_MAX_CHARS = GROUP_NODE_DIMENSIONS.LABEL_MAX_CHARS;
|
||||
const NAME_MAX_LINES = GROUP_NODE_DIMENSIONS.LABEL_MAX_LINES;
|
||||
const BADGE_SIZE = 44;
|
||||
const BADGE_RADIUS = BADGE_SIZE / 2;
|
||||
const BADGE_CENTER_X = NODE_WIDTH / 2;
|
||||
const BADGE_CENTER_Y = 26;
|
||||
const ICON_SIZE = 28;
|
||||
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
|
||||
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
|
||||
// Count badge, top-right of the class icon.
|
||||
const COUNT_CX = BADGE_CENTER_X + BADGE_RADIUS - 2;
|
||||
const COUNT_CY = BADGE_CENTER_Y - BADGE_RADIUS + 2;
|
||||
const COUNT_R = 11;
|
||||
const TEXT_X = BADGE_CENTER_X;
|
||||
const NAME_Y = 62;
|
||||
const NAME_LINE_HEIGHT = 13;
|
||||
const COUNT_LABEL_Y = 96;
|
||||
const HINT_Y = 112;
|
||||
|
||||
export const GroupNode = ({ data, selected }: NodeProps) => {
|
||||
const { graphNode } = data as GroupNodeData;
|
||||
const classLabel = String(graphNode.properties[GROUP_PROPS.CLASS] ?? "");
|
||||
const className = String(graphNode.properties[GROUP_PROPS.CLASS_NAME] ?? "");
|
||||
const count = Number(graphNode.properties[GROUP_PROPS.COUNT] ?? 0);
|
||||
// Members carry findings -> red cue, so the user knows to expand (matches the
|
||||
// per-resource "red = findings" convention).
|
||||
const hasFindings = Boolean(graphNode.properties[GROUP_PROPS.HAS_FINDINGS]);
|
||||
|
||||
// Resolve the class icon/colors from a representative node of the class.
|
||||
const representative: GraphNode = {
|
||||
id: graphNode.id,
|
||||
labels: [classLabel],
|
||||
properties: {},
|
||||
};
|
||||
const visual = resolveNodeVisual(representative);
|
||||
const Icon = visual.Icon;
|
||||
const { fillColor, borderColor } = resolveNodeColors({
|
||||
labels: [classLabel],
|
||||
properties: {},
|
||||
selected,
|
||||
hasFindings,
|
||||
});
|
||||
|
||||
const fullName = className || visual.description;
|
||||
const name = getNodeLabelDisplay(fullName, NAME_MAX_CHARS, NAME_MAX_LINES);
|
||||
|
||||
return (
|
||||
<GraphNodeShell
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
badgeCenterX={BADGE_CENTER_X}
|
||||
badgeCenterY={BADGE_CENTER_Y}
|
||||
badgeRadius={BADGE_RADIUS}
|
||||
testId="attack-path-group-node"
|
||||
svgClassName="cursor-pointer"
|
||||
tooltip={name.isTruncated ? fullName : undefined}
|
||||
>
|
||||
{hasFindings && (
|
||||
<circle
|
||||
cx={BADGE_CENTER_X}
|
||||
cy={BADGE_CENTER_Y}
|
||||
r={BADGE_RADIUS + 5}
|
||||
fill={borderColor}
|
||||
fillOpacity={0.26}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={BADGE_CENTER_X}
|
||||
cy={BADGE_CENTER_Y}
|
||||
r={BADGE_RADIUS}
|
||||
fill={fillColor}
|
||||
fillOpacity={0.92}
|
||||
stroke={borderColor}
|
||||
strokeWidth={selected ? 4 : hasFindings ? 3 : 1.5}
|
||||
/>
|
||||
<g
|
||||
aria-label={`${visual.description} icon`}
|
||||
role="img"
|
||||
transform={`translate(${ICON_X}, ${ICON_Y})`}
|
||||
>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
height={ICON_SIZE}
|
||||
role="presentation"
|
||||
size={ICON_SIZE}
|
||||
width={ICON_SIZE}
|
||||
/>
|
||||
</g>
|
||||
{/* count badge */}
|
||||
<circle
|
||||
cx={COUNT_CX}
|
||||
cy={COUNT_CY}
|
||||
r={COUNT_R}
|
||||
fill={borderColor}
|
||||
stroke={GRAPH_COUNT_BADGE_STROKE_COLOR}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={COUNT_CX}
|
||||
y={COUNT_CY + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="11px"
|
||||
fontWeight="700"
|
||||
fill="#ffffff"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{count}
|
||||
</text>
|
||||
<text
|
||||
x={TEXT_X}
|
||||
y={NAME_Y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#ffffff"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{name.lines.map((line, index) => (
|
||||
<tspan
|
||||
key={`${line}-${index}`}
|
||||
x={TEXT_X}
|
||||
y={NAME_Y + index * NAME_LINE_HEIGHT}
|
||||
fontSize="11px"
|
||||
fontWeight="600"
|
||||
>
|
||||
{line}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
<text
|
||||
x={TEXT_X}
|
||||
y={COUNT_LABEL_Y}
|
||||
textAnchor="middle"
|
||||
fontSize="9px"
|
||||
fill="rgba(255,255,255,0.8)"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{count} {count === 1 ? "resource" : "resources"}
|
||||
</text>
|
||||
<text
|
||||
x={TEXT_X}
|
||||
y={HINT_Y}
|
||||
textAnchor="middle"
|
||||
fontSize="8px"
|
||||
fill="rgba(255,255,255,0.55)"
|
||||
pointerEvents="none"
|
||||
>
|
||||
click to expand
|
||||
</text>
|
||||
</GraphNodeShell>
|
||||
);
|
||||
};
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { type NodeProps } from "@xyflow/react";
|
||||
import { Crosshair } from "lucide-react";
|
||||
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
GRAPH_OUTCOME_BORDER_COLOR,
|
||||
GRAPH_OUTCOME_FILL_COLOR,
|
||||
} from "../../../_lib";
|
||||
import { OUTCOME_PROPS } from "../../../_lib/group-graph";
|
||||
import { OUTCOME_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
|
||||
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
|
||||
|
||||
import { GraphNodeShell } from "./graph-node-shell";
|
||||
|
||||
interface OutcomeNodeData {
|
||||
graphNode: GraphNode;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = OUTCOME_NODE_DIMENSIONS.WIDTH;
|
||||
const NODE_HEIGHT = OUTCOME_NODE_DIMENSIONS.HEIGHT;
|
||||
const NAME_MAX_CHARS = OUTCOME_NODE_DIMENSIONS.LABEL_MAX_CHARS;
|
||||
const NAME_MAX_LINES = OUTCOME_NODE_DIMENSIONS.LABEL_MAX_LINES;
|
||||
const BADGE_RADIUS = 24;
|
||||
const BADGE_CENTER_X = NODE_WIDTH / 2;
|
||||
const BADGE_CENTER_Y = 26;
|
||||
const ICON_SIZE = 26;
|
||||
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
|
||||
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
|
||||
const TEXT_X = BADGE_CENTER_X;
|
||||
const KICKER_Y = 64;
|
||||
const LABEL_Y = 78;
|
||||
const LABEL_LINE_HEIGHT = 13;
|
||||
|
||||
export const OutcomeNode = ({ data, selected }: NodeProps) => {
|
||||
const { graphNode } = data as OutcomeNodeData;
|
||||
const label = String(graphNode.properties[OUTCOME_PROPS.LABEL] ?? "Outcome");
|
||||
const partial = Boolean(graphNode.properties[OUTCOME_PROPS.PARTIAL]);
|
||||
|
||||
const name = getNodeLabelDisplay(label, NAME_MAX_CHARS, NAME_MAX_LINES);
|
||||
|
||||
return (
|
||||
<GraphNodeShell
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
badgeCenterX={BADGE_CENTER_X}
|
||||
badgeCenterY={BADGE_CENTER_Y}
|
||||
badgeRadius={BADGE_RADIUS}
|
||||
testId="attack-path-outcome-node"
|
||||
tooltip={name.isTruncated ? label : undefined}
|
||||
>
|
||||
<circle
|
||||
cx={BADGE_CENTER_X}
|
||||
cy={BADGE_CENTER_Y}
|
||||
r={BADGE_RADIUS + 5}
|
||||
fill={GRAPH_OUTCOME_BORDER_COLOR}
|
||||
fillOpacity={0.22}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<circle
|
||||
cx={BADGE_CENTER_X}
|
||||
cy={BADGE_CENTER_Y}
|
||||
r={BADGE_RADIUS}
|
||||
fill={GRAPH_OUTCOME_FILL_COLOR}
|
||||
stroke={GRAPH_OUTCOME_BORDER_COLOR}
|
||||
strokeWidth={selected ? 4 : 2}
|
||||
// Partial/latent outcomes are drawn with a dashed ring as a first pass.
|
||||
strokeDasharray={partial ? "4 3" : undefined}
|
||||
/>
|
||||
<g
|
||||
aria-label="Outcome"
|
||||
role="img"
|
||||
transform={`translate(${ICON_X}, ${ICON_Y})`}
|
||||
>
|
||||
<Crosshair
|
||||
aria-hidden="true"
|
||||
color="#ffffff"
|
||||
focusable="false"
|
||||
height={ICON_SIZE}
|
||||
role="presentation"
|
||||
size={ICON_SIZE}
|
||||
width={ICON_SIZE}
|
||||
/>
|
||||
</g>
|
||||
<text
|
||||
x={TEXT_X}
|
||||
y={KICKER_Y}
|
||||
textAnchor="middle"
|
||||
fontSize="8px"
|
||||
fontWeight="700"
|
||||
letterSpacing="1"
|
||||
fill={GRAPH_OUTCOME_BORDER_COLOR}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{partial ? "LATENT OUTCOME" : "OUTCOME"}
|
||||
</text>
|
||||
<text
|
||||
x={TEXT_X}
|
||||
y={LABEL_Y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#ffffff"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{name.lines.map((line, index) => (
|
||||
<tspan
|
||||
key={`${line}-${index}`}
|
||||
x={TEXT_X}
|
||||
y={LABEL_Y + index * LABEL_LINE_HEIGHT}
|
||||
fontSize="11px"
|
||||
fontWeight="700"
|
||||
>
|
||||
{line}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
</GraphNodeShell>
|
||||
);
|
||||
};
|
||||
+101
@@ -32,4 +32,105 @@ describe("useGraphStore", () => {
|
||||
// Then
|
||||
expect(useGraphStore.getState().expandedResources.size).toBe(0);
|
||||
});
|
||||
|
||||
it("clears the selected node when fresh graph data loads", () => {
|
||||
// Given - a node is selected in the current graph
|
||||
useGraphStore.getState().setSelectedNodeId("resource-a");
|
||||
|
||||
// When - a new query loads
|
||||
useGraphStore.getState().setGraphData({ nodes: [], edges: [] }, null);
|
||||
|
||||
// Then
|
||||
expect(useGraphStore.getState().selectedNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it("expands multiple resource classes at once", () => {
|
||||
// When
|
||||
useGraphStore.getState().toggleExpandedClass("0::AWSRole");
|
||||
useGraphStore.getState().toggleExpandedClass("1::AWSPolicy");
|
||||
|
||||
// Then
|
||||
expect(Array.from(useGraphStore.getState().expandedClasses)).toEqual([
|
||||
"0::AWSRole",
|
||||
"1::AWSPolicy",
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes an expanded class when toggled again", () => {
|
||||
// Given
|
||||
useGraphStore.getState().toggleExpandedClass("0::AWSRole");
|
||||
|
||||
// When
|
||||
useGraphStore.getState().toggleExpandedClass("0::AWSRole");
|
||||
|
||||
// Then
|
||||
expect(useGraphStore.getState().expandedClasses.size).toBe(0);
|
||||
});
|
||||
|
||||
it("collapses every expanded class", () => {
|
||||
// Given
|
||||
useGraphStore.getState().toggleExpandedClass("0::AWSRole");
|
||||
useGraphStore.getState().toggleExpandedClass("1::AWSPolicy");
|
||||
|
||||
// When
|
||||
useGraphStore.getState().collapseAllClasses();
|
||||
|
||||
// Then
|
||||
expect(useGraphStore.getState().expandedClasses.size).toBe(0);
|
||||
});
|
||||
|
||||
it("clears expanded classes when fresh graph data loads", () => {
|
||||
// Given
|
||||
useGraphStore.getState().toggleExpandedClass("0::AWSRole");
|
||||
|
||||
// When
|
||||
useGraphStore.getState().setGraphData({ nodes: [], edges: [] }, null);
|
||||
|
||||
// Then
|
||||
expect(useGraphStore.getState().expandedClasses.size).toBe(0);
|
||||
});
|
||||
|
||||
it("prunes findings-expansion and selection for members a collapsed class hides", () => {
|
||||
// Given - a member of an expanded class has its findings open and is selected
|
||||
useGraphStore.getState().toggleExpandedClass("1::AWSPolicy");
|
||||
useGraphStore.getState().toggleExpandedResource("pol-1");
|
||||
useGraphStore.getState().setSelectedNodeId("pol-1");
|
||||
|
||||
// When - the class collapses, hiding pol-1
|
||||
useGraphStore.getState().collapseAllClasses(["pol-1", "pol-2"]);
|
||||
|
||||
// Then - no stale state survives to restore on re-expand
|
||||
expect(useGraphStore.getState().expandedResources.size).toBe(0);
|
||||
expect(useGraphStore.getState().selectedNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps selection and expansion for members outside the collapsed class", () => {
|
||||
// Given
|
||||
useGraphStore.getState().toggleExpandedResource("role-1");
|
||||
useGraphStore.getState().setSelectedNodeId("role-1");
|
||||
|
||||
// When - a different class collapses (role-1 is not among its members)
|
||||
useGraphStore.getState().collapseAllClasses(["pol-1", "pol-2"]);
|
||||
|
||||
// Then
|
||||
expect(Array.from(useGraphStore.getState().expandedResources)).toEqual([
|
||||
"role-1",
|
||||
]);
|
||||
expect(useGraphStore.getState().selectedNodeId).toBe("role-1");
|
||||
});
|
||||
|
||||
it("prunes hidden-member state when a single class is toggled closed", () => {
|
||||
// Given
|
||||
useGraphStore.getState().toggleExpandedClass("1::AWSPolicy");
|
||||
useGraphStore.getState().toggleExpandedResource("pol-1");
|
||||
useGraphStore.getState().setSelectedNodeId("pol-1");
|
||||
|
||||
// When
|
||||
useGraphStore.getState().toggleExpandedClass("1::AWSPolicy", ["pol-1"]);
|
||||
|
||||
// Then
|
||||
expect(useGraphStore.getState().expandedClasses.size).toBe(0);
|
||||
expect(useGraphStore.getState().expandedResources.size).toBe(0);
|
||||
expect(useGraphStore.getState().selectedNodeId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ interface FilteredViewState {
|
||||
// swaps that happen when entering/exiting filtered view. Reset only on
|
||||
// fresh data loads (new query / scan) — see `setGraphData`.
|
||||
expandedResources: Set<string>;
|
||||
// Which resource-class groups are expanded to their members. Multi-select:
|
||||
// any number of classes can be open at once. Reset on fresh data loads.
|
||||
expandedClasses: Set<string>;
|
||||
}
|
||||
|
||||
interface GraphStore extends GraphState, FilteredViewState {
|
||||
@@ -37,9 +40,31 @@ interface GraphStore extends GraphState, FilteredViewState {
|
||||
fullData: AttackPathGraphData | null,
|
||||
) => void;
|
||||
toggleExpandedResource: (resourceId: string) => void;
|
||||
toggleExpandedClass: (classKey: string, memberIds?: string[]) => void;
|
||||
collapseAllClasses: (memberIds?: string[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// Collapsing a class hides its members. Any of those members might still be the
|
||||
// findings-expanded resource or the selected (green) node; drop that state so it
|
||||
// does not silently reappear when the class is expanded again.
|
||||
const pruneHiddenMembers = (
|
||||
state: FilteredViewState & GraphState,
|
||||
memberIds: string[],
|
||||
): Partial<FilteredViewState & GraphState> => {
|
||||
if (memberIds.length === 0) return {};
|
||||
const hidden = new Set(memberIds);
|
||||
return {
|
||||
expandedResources: new Set(
|
||||
Array.from(state.expandedResources).filter((id) => !hidden.has(id)),
|
||||
),
|
||||
selectedNodeId:
|
||||
state.selectedNodeId && hidden.has(state.selectedNodeId)
|
||||
? null
|
||||
: state.selectedNodeId,
|
||||
};
|
||||
};
|
||||
|
||||
const initialState: GraphState & FilteredViewState = {
|
||||
data: null,
|
||||
execution: null,
|
||||
@@ -50,6 +75,7 @@ const initialState: GraphState & FilteredViewState = {
|
||||
filteredNodeId: null,
|
||||
fullData: null,
|
||||
expandedResources: new Set(),
|
||||
expandedClasses: new Set(),
|
||||
};
|
||||
|
||||
export const useGraphStore = create<GraphStore>((set) => ({
|
||||
@@ -62,8 +88,10 @@ export const useGraphStore = create<GraphStore>((set) => ({
|
||||
error: null,
|
||||
isFilteredView: false,
|
||||
filteredNodeId: null,
|
||||
// Fresh data → drop any stale expansion from the previous graph.
|
||||
// Fresh data → drop any stale selection/expansion from the previous graph.
|
||||
selectedNodeId: null,
|
||||
expandedResources: new Set(),
|
||||
expandedClasses: new Set(),
|
||||
}),
|
||||
setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
@@ -83,6 +111,25 @@ export const useGraphStore = create<GraphStore>((set) => ({
|
||||
: new Set([resourceId]);
|
||||
return { expandedResources: next };
|
||||
}),
|
||||
toggleExpandedClass: (classKey, memberIds = []) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.expandedClasses);
|
||||
if (next.has(classKey)) {
|
||||
// Closing a class: prune state that pointed at a now-hidden member.
|
||||
next.delete(classKey);
|
||||
return {
|
||||
expandedClasses: next,
|
||||
...pruneHiddenMembers(state, memberIds),
|
||||
};
|
||||
}
|
||||
next.add(classKey);
|
||||
return { expandedClasses: next };
|
||||
}),
|
||||
collapseAllClasses: (memberIds = []) =>
|
||||
set((state) => ({
|
||||
expandedClasses: new Set(),
|
||||
...pruneHiddenMembers(state, memberIds),
|
||||
})),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
@@ -181,6 +228,9 @@ export const useGraphState = () => {
|
||||
filteredNode: getFilteredNode(),
|
||||
expandedResources: store.expandedResources,
|
||||
toggleExpandedResource: store.toggleExpandedResource,
|
||||
expandedClasses: store.expandedClasses,
|
||||
toggleExpandedClass: store.toggleExpandedClass,
|
||||
collapseAllClasses: store.collapseAllClasses,
|
||||
updateGraphData,
|
||||
selectNode,
|
||||
startLoading,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared edge-direction normalization.
|
||||
*
|
||||
* Container relationships (e.g. a resource RUNS_IN a VPC) arrive as
|
||||
* `child -> container`, but the attack path reads best as `container -> child`.
|
||||
* The Dagre layout reverses them for hierarchy, so any code that reasons about
|
||||
* flow direction *before* layout — ranking, grouping, sink detection for the
|
||||
* terminal outcome node — must apply the same reversal or it will disagree with
|
||||
* what the user finally sees (the outcome node lands mid-path instead of at the
|
||||
* end). Keeping the rule here lets the layout and the view transform share it.
|
||||
*/
|
||||
|
||||
// Container relationships that get reversed for proper hierarchy.
|
||||
const CONTAINER_RELATIONS = new Set([
|
||||
"RUNS_IN",
|
||||
"BELONGS_TO",
|
||||
"LOCATED_IN",
|
||||
"PART_OF",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the [source, target] an edge should use once laid out. Container
|
||||
* relationships are reversed; every other edge keeps its original direction.
|
||||
*/
|
||||
export const orientEdgeForLayout = (
|
||||
source: string,
|
||||
target: string,
|
||||
type: string,
|
||||
): [string, string] =>
|
||||
CONTAINER_RELATIONS.has(type) ? [target, source] : [source, target];
|
||||
@@ -14,11 +14,22 @@ import {
|
||||
getNodeBorderColor,
|
||||
getNodeColor,
|
||||
GRAPH_ALERT_BORDER_COLOR,
|
||||
GRAPH_COUNT_BADGE_STROKE_COLOR,
|
||||
GRAPH_EDGE_COLOR_DARK,
|
||||
GRAPH_OUTCOME_BORDER_COLOR,
|
||||
GRAPH_OUTCOME_FILL_COLOR,
|
||||
} from "./graph-colors";
|
||||
import {
|
||||
GROUP_NODE_LABEL,
|
||||
GROUP_PROPS,
|
||||
OUTCOME_NODE_LABEL,
|
||||
OUTCOME_PROPS,
|
||||
} from "./group-graph";
|
||||
import { layoutWithDagre } from "./layout";
|
||||
import {
|
||||
FINDING_NODE_DIMENSIONS,
|
||||
GROUP_NODE_DIMENSIONS,
|
||||
OUTCOME_NODE_DIMENSIONS,
|
||||
RESOURCE_NODE_DIMENSIONS,
|
||||
} from "./node-dimensions";
|
||||
import { getNodeLabelDisplay } from "./node-label-lines";
|
||||
@@ -389,12 +400,171 @@ const drawNodeIcon = (
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const drawWrappedLabel = (
|
||||
context: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
center: Point,
|
||||
maxChars: number,
|
||||
maxLines: number,
|
||||
topY: number,
|
||||
) => {
|
||||
getNodeLabelDisplay(text, maxChars, maxLines).lines.forEach((line, index) => {
|
||||
context.fillText(
|
||||
line,
|
||||
center.x,
|
||||
center.y + (topY - BADGE_CENTER_Y) + index * LABEL_LINE_HEIGHT,
|
||||
RESOURCE_NODE_DIMENSIONS.WIDTH,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// A collapsed resource-class group: class badge + count, mirroring GroupNode.
|
||||
const drawGroupNode = (
|
||||
context: CanvasRenderingContext2D,
|
||||
graphNode: AttackPathGraphData["nodes"][number],
|
||||
center: Point,
|
||||
) => {
|
||||
const classLabel = String(graphNode.properties[GROUP_PROPS.CLASS] ?? "");
|
||||
const className = String(graphNode.properties[GROUP_PROPS.CLASS_NAME] ?? "");
|
||||
const count = Number(graphNode.properties[GROUP_PROPS.COUNT] ?? 0);
|
||||
const hasFindings = Boolean(graphNode.properties[GROUP_PROPS.HAS_FINDINGS]);
|
||||
const visual = resolveNodeVisual({
|
||||
id: graphNode.id,
|
||||
labels: [classLabel],
|
||||
properties: {},
|
||||
});
|
||||
const fill = getNodeColor([classLabel]);
|
||||
const stroke = hasFindings
|
||||
? GRAPH_ALERT_BORDER_COLOR
|
||||
: getNodeBorderColor([classLabel]);
|
||||
|
||||
if (hasFindings) {
|
||||
context.fillStyle = stroke;
|
||||
context.globalAlpha = 0.13;
|
||||
context.beginPath();
|
||||
context.arc(center.x, center.y, GLOW_RADIUS, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
|
||||
context.fillStyle = fill;
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = hasFindings ? 3 : 1.5;
|
||||
context.beginPath();
|
||||
context.arc(center.x, center.y, BADGE_RADIUS, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
|
||||
const typeLabel = truncateLabel(visual.description, 22);
|
||||
drawNodeIcon(context, center.x, center.y, visual.category, typeLabel);
|
||||
|
||||
// Count badge, top-right of the class icon.
|
||||
const countCx = center.x + BADGE_RADIUS - 2;
|
||||
const countCy = center.y - BADGE_RADIUS + 2;
|
||||
context.fillStyle = stroke;
|
||||
context.strokeStyle = GRAPH_COUNT_BADGE_STROKE_COLOR;
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.arc(countCx, countCy, 11, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.fillStyle = "#ffffff";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.font = "700 11px sans-serif";
|
||||
context.fillText(String(count), countCx, countCy + 1);
|
||||
|
||||
context.fillStyle = "#ffffff";
|
||||
context.font = "600 11px sans-serif";
|
||||
drawWrappedLabel(
|
||||
context,
|
||||
className || visual.description,
|
||||
center,
|
||||
GROUP_NODE_DIMENSIONS.LABEL_MAX_CHARS,
|
||||
GROUP_NODE_DIMENSIONS.LABEL_MAX_LINES,
|
||||
LABEL_Y,
|
||||
);
|
||||
|
||||
context.fillStyle = "rgba(255,255,255,0.82)";
|
||||
context.font = "9px sans-serif";
|
||||
context.fillText(
|
||||
`${count} ${count === 1 ? "resource" : "resources"}`,
|
||||
center.x,
|
||||
center.y + (TYPE_Y - BADGE_CENTER_Y),
|
||||
RESOURCE_NODE_DIMENSIONS.WIDTH,
|
||||
);
|
||||
};
|
||||
|
||||
// The terminal outcome node: distinct orange badge, mirroring OutcomeNode.
|
||||
const drawOutcomeNode = (
|
||||
context: CanvasRenderingContext2D,
|
||||
graphNode: AttackPathGraphData["nodes"][number],
|
||||
center: Point,
|
||||
) => {
|
||||
const label = String(graphNode.properties[OUTCOME_PROPS.LABEL] ?? "Outcome");
|
||||
const partial = Boolean(graphNode.properties[OUTCOME_PROPS.PARTIAL]);
|
||||
|
||||
context.fillStyle = GRAPH_OUTCOME_BORDER_COLOR;
|
||||
context.globalAlpha = 0.18;
|
||||
context.beginPath();
|
||||
context.arc(center.x, center.y, GLOW_RADIUS, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.globalAlpha = 1;
|
||||
|
||||
context.fillStyle = GRAPH_OUTCOME_FILL_COLOR;
|
||||
context.strokeStyle = GRAPH_OUTCOME_BORDER_COLOR;
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash(partial ? [4, 3] : []);
|
||||
context.beginPath();
|
||||
context.arc(center.x, center.y, BADGE_RADIUS, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
|
||||
// Simple target glyph in place of the Crosshair icon.
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.arc(center.x, center.y, 8, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
|
||||
context.fillStyle = GRAPH_OUTCOME_BORDER_COLOR;
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.font = "700 8px sans-serif";
|
||||
context.fillText(
|
||||
partial ? "LATENT OUTCOME" : "OUTCOME",
|
||||
center.x,
|
||||
center.y + (64 - BADGE_CENTER_Y),
|
||||
);
|
||||
|
||||
context.fillStyle = "#ffffff";
|
||||
context.font = "700 11px sans-serif";
|
||||
drawWrappedLabel(
|
||||
context,
|
||||
label,
|
||||
center,
|
||||
OUTCOME_NODE_DIMENSIONS.LABEL_MAX_CHARS,
|
||||
OUTCOME_NODE_DIMENSIONS.LABEL_MAX_LINES,
|
||||
78,
|
||||
);
|
||||
};
|
||||
|
||||
const drawNode = (
|
||||
context: CanvasRenderingContext2D,
|
||||
graphNode: AttackPathGraphData["nodes"][number],
|
||||
center: Point,
|
||||
options: { hasFindings: boolean; selected: boolean },
|
||||
) => {
|
||||
if (graphNode.labels.includes(OUTCOME_NODE_LABEL)) {
|
||||
drawOutcomeNode(context, graphNode, center);
|
||||
return;
|
||||
}
|
||||
if (graphNode.labels.includes(GROUP_NODE_LABEL)) {
|
||||
drawGroupNode(context, graphNode, center);
|
||||
return;
|
||||
}
|
||||
|
||||
const isFinding = isFindingNode(graphNode.labels);
|
||||
const visual = resolveNodeVisual(graphNode);
|
||||
const fill = getNodeColor(graphNode.labels, graphNode.properties);
|
||||
|
||||
@@ -3,8 +3,16 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
GRAPH_ALERT_BORDER_COLOR,
|
||||
GRAPH_EDGE_HIGHLIGHT_COLOR,
|
||||
GRAPH_NODE_COLORS,
|
||||
GRAPH_OUTCOME_BORDER_COLOR,
|
||||
GRAPH_OUTCOME_FILL_COLOR,
|
||||
resolveNodeColors,
|
||||
} from "./graph-colors";
|
||||
import {
|
||||
GROUP_NODE_LABEL,
|
||||
GROUP_PROPS,
|
||||
OUTCOME_NODE_LABEL,
|
||||
} from "./group-graph";
|
||||
|
||||
describe("resolveNodeColors", () => {
|
||||
it("prioritizes selected state over hasFindings for the border color", () => {
|
||||
@@ -23,4 +31,29 @@ describe("resolveNodeColors", () => {
|
||||
expect(selectedColors.borderColor).toBe(GRAPH_EDGE_HIGHLIGHT_COLOR);
|
||||
expect(alertOnlyColors.borderColor).toBe(GRAPH_ALERT_BORDER_COLOR);
|
||||
});
|
||||
|
||||
it("uses the synthetic node palettes", () => {
|
||||
// Given
|
||||
const properties = {
|
||||
[GROUP_PROPS.CLASS]: "IAMRole",
|
||||
[GROUP_PROPS.HAS_FINDINGS]: true,
|
||||
};
|
||||
|
||||
// When
|
||||
const groupColors = resolveNodeColors({
|
||||
labels: [GROUP_NODE_LABEL],
|
||||
properties,
|
||||
});
|
||||
const outcomeColors = resolveNodeColors({ labels: [OUTCOME_NODE_LABEL] });
|
||||
|
||||
// Then
|
||||
expect(groupColors).toEqual({
|
||||
fillColor: GRAPH_NODE_COLORS.iamRole,
|
||||
borderColor: GRAPH_ALERT_BORDER_COLOR,
|
||||
});
|
||||
expect(outcomeColors).toEqual({
|
||||
fillColor: GRAPH_OUTCOME_FILL_COLOR,
|
||||
borderColor: GRAPH_OUTCOME_BORDER_COLOR,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
GROUP_NODE_LABEL,
|
||||
GROUP_PROPS,
|
||||
OUTCOME_NODE_LABEL,
|
||||
} from "./group-graph";
|
||||
import { isProwlerFindingNode } from "./node-types";
|
||||
|
||||
/**
|
||||
@@ -62,6 +67,12 @@ export const GRAPH_SELECTION_COLOR = "#ffffff";
|
||||
export const GRAPH_BORDER_COLOR = "#374151";
|
||||
export const GRAPH_ALERT_BORDER_COLOR = "#ef4444"; // Red 500 - for resources with findings
|
||||
|
||||
// Terminal outcome node: distinct orange, independent of the resource palette.
|
||||
export const GRAPH_OUTCOME_FILL_COLOR = "#c2410c"; // Orange 700
|
||||
export const GRAPH_OUTCOME_BORDER_COLOR = "#f97316"; // Orange 500
|
||||
// Outline behind a group node's count badge, for contrast on any fill.
|
||||
export const GRAPH_COUNT_BADGE_STROKE_COLOR = "#0b1120"; // Slate 950
|
||||
|
||||
/**
|
||||
* Get node fill color based on labels and properties
|
||||
*/
|
||||
@@ -69,6 +80,15 @@ export const getNodeColor = (
|
||||
labels: string[],
|
||||
properties?: Record<string, unknown>,
|
||||
): string => {
|
||||
if (labels.includes(OUTCOME_NODE_LABEL)) {
|
||||
return GRAPH_OUTCOME_FILL_COLOR;
|
||||
}
|
||||
|
||||
if (labels.includes(GROUP_NODE_LABEL)) {
|
||||
const classLabel = String(properties?.[GROUP_PROPS.CLASS] ?? "");
|
||||
return getNodeColor([classLabel]);
|
||||
}
|
||||
|
||||
const isFinding = isProwlerFindingNode(labels);
|
||||
if (isFinding && properties?.severity) {
|
||||
const severity = String(properties.severity).toLowerCase();
|
||||
@@ -102,6 +122,19 @@ export const getNodeBorderColor = (
|
||||
labels: string[],
|
||||
properties?: Record<string, unknown>,
|
||||
): string => {
|
||||
if (labels.includes(OUTCOME_NODE_LABEL)) {
|
||||
return GRAPH_OUTCOME_BORDER_COLOR;
|
||||
}
|
||||
|
||||
if (labels.includes(GROUP_NODE_LABEL)) {
|
||||
if (properties?.[GROUP_PROPS.HAS_FINDINGS]) {
|
||||
return GRAPH_ALERT_BORDER_COLOR;
|
||||
}
|
||||
|
||||
const classLabel = String(properties?.[GROUP_PROPS.CLASS] ?? "");
|
||||
return getNodeBorderColor([classLabel]);
|
||||
}
|
||||
|
||||
const isFinding = isProwlerFindingNode(labels);
|
||||
if (isFinding && properties?.severity) {
|
||||
const severity = String(properties.severity).toLowerCase();
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
AttackPathGraphData,
|
||||
AttackPathOutcome,
|
||||
GraphNode,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
buildAttackPathView,
|
||||
GROUP_NODE_LABEL,
|
||||
GROUP_PROPS,
|
||||
groupKey,
|
||||
OUTCOME_NODE_LABEL,
|
||||
OUTCOME_PROPS,
|
||||
} from "./group-graph";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Attack-path view transform (pure).
|
||||
*
|
||||
* Reshapes the raw query graph into the attack-path view before layout:
|
||||
* 1. drop the account/provider hub node(s)
|
||||
* 2. group each hop by resource class into a single collapsible node
|
||||
* (collapsed by default; expanding shows the individual members)
|
||||
* 3. inject a terminal outcome node from the query's outcome metadata
|
||||
*
|
||||
* Finding nodes are deliberately KEPT: they pass through for members whose class
|
||||
* is expanded, and the canvas hides/reveals them per the existing per-resource
|
||||
* behaviour (expandedResources). Findings of a collapsed class are omitted, so a
|
||||
* collapsed group never drags its members' findings onto the canvas.
|
||||
*
|
||||
* Ranking and outcome-sink detection use the same edge orientation as the Dagre
|
||||
* layout (see edge-orientation), so the injected outcome node stays terminal
|
||||
* even when the path runs through reversed container relationships.
|
||||
*
|
||||
* Kept pure and separate from the store so expand/collapse is a view recompute
|
||||
* over stable raw data, never a data mutation.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AttackPathGraphData,
|
||||
AttackPathOutcome,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphRelationship,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import { orientEdgeForLayout } from "./edge-orientation";
|
||||
import { isProwlerFindingNode } from "./node-types";
|
||||
import { NODE_CATEGORY, resolveNodeVisual } from "./node-visuals";
|
||||
|
||||
// Synthetic labels so layout/getNodeType and the node registry can identify the
|
||||
// injected nodes. Real graph labels never collide with these.
|
||||
export const GROUP_NODE_LABEL = "__AttackPathGroup";
|
||||
export const OUTCOME_NODE_LABEL = "__AttackPathOutcome";
|
||||
|
||||
// Property keys carried on the synthetic nodes (read by their components).
|
||||
export const GROUP_PROPS = {
|
||||
CLASS: "__groupClass",
|
||||
CLASS_NAME: "__groupClassName",
|
||||
COUNT: "__groupCount",
|
||||
KEY: "__groupKey",
|
||||
HAS_FINDINGS: "__groupHasFindings",
|
||||
// Set on an expanded member so a double-click can collapse its owning class.
|
||||
MEMBER_KEY: "__memberGroupKey",
|
||||
} as const;
|
||||
|
||||
export const OUTCOME_PROPS = {
|
||||
KIND: "__outcomeKind",
|
||||
LABEL: "__outcomeLabel",
|
||||
PARTIAL: "__outcomePartial",
|
||||
} as const;
|
||||
|
||||
const HAS_FINDING = "HAS_FINDING";
|
||||
|
||||
interface AttackPathViewInput {
|
||||
data: AttackPathGraphData;
|
||||
/** Group keys (see groupKey()) that are currently expanded to their members. */
|
||||
expandedClasses: ReadonlySet<string>;
|
||||
/** From the selected query's metadata; absent for custom queries. */
|
||||
outcome?: AttackPathOutcome | null;
|
||||
}
|
||||
|
||||
export interface AttackPathView {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
/**
|
||||
* groupKey -> ids of the member nodes it represents. Consumed when a class is
|
||||
* collapsed so the store can prune expansion/selection state that pointed at a
|
||||
* member the collapse just hid.
|
||||
*/
|
||||
groupMembers: Map<string, string[]>;
|
||||
}
|
||||
|
||||
const isAccountHub = (node: GraphNode): boolean =>
|
||||
resolveNodeVisual(node).category === NODE_CATEGORY.ACCOUNT;
|
||||
|
||||
const isInternet = (node: GraphNode): boolean =>
|
||||
resolveNodeVisual(node).category === NODE_CATEGORY.INTERNET;
|
||||
|
||||
/** Primary semantic label used as the class key (e.g. "AWSRole"). */
|
||||
const classLabelOf = (node: GraphNode): string => node.labels[0] ?? "Resource";
|
||||
|
||||
/** Deterministic group key: same class at different hops stays distinct. */
|
||||
export const groupKey = (rank: number, classLabel: string): string =>
|
||||
`${rank}::${classLabel}`;
|
||||
|
||||
const edgesOf = (data: AttackPathGraphData): GraphEdge[] => {
|
||||
if (data.edges && data.edges.length > 0) return data.edges;
|
||||
return (data.relationships ?? []).map((r: GraphRelationship) => ({
|
||||
id: r.id,
|
||||
source: r.source,
|
||||
target: r.target,
|
||||
type: r.label,
|
||||
properties: r.properties,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Shortest-hop rank from the fragment sources (in-degree 0). Cycle-safe: ranks
|
||||
* only decrease toward the true minimum and are bounded at 0, so it terminates.
|
||||
*/
|
||||
const computeRanks = (
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
): Map<string, number> => {
|
||||
const adjacency = new Map<string, string[]>();
|
||||
const inDegree = new Map<string, number>();
|
||||
nodes.forEach((n) => inDegree.set(n.id, 0));
|
||||
edges.forEach((e) => {
|
||||
adjacency.set(e.source, [...(adjacency.get(e.source) ?? []), e.target]);
|
||||
inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1);
|
||||
});
|
||||
|
||||
const rank = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
nodes.forEach((n) => {
|
||||
if ((inDegree.get(n.id) ?? 0) === 0) {
|
||||
rank.set(n.id, 0);
|
||||
queue.push(n.id);
|
||||
}
|
||||
});
|
||||
// Pure cycle with no source: seed everything at rank 0.
|
||||
if (queue.length === 0) {
|
||||
nodes.forEach((n) => {
|
||||
rank.set(n.id, 0);
|
||||
queue.push(n.id);
|
||||
});
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as string;
|
||||
const next = (rank.get(current) as number) + 1;
|
||||
for (const neighbor of adjacency.get(current) ?? []) {
|
||||
if (!rank.has(neighbor) || (rank.get(neighbor) as number) > next) {
|
||||
rank.set(neighbor, next);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes.forEach((n) => {
|
||||
if (!rank.has(n.id)) rank.set(n.id, 0);
|
||||
});
|
||||
return rank;
|
||||
};
|
||||
|
||||
const makeGroupNode = (
|
||||
key: string,
|
||||
classLabel: string,
|
||||
className: string,
|
||||
total: number,
|
||||
hasFindings: boolean,
|
||||
): GraphNode => ({
|
||||
id: `group:${key}`,
|
||||
labels: [GROUP_NODE_LABEL],
|
||||
properties: {
|
||||
[GROUP_PROPS.CLASS]: classLabel,
|
||||
[GROUP_PROPS.CLASS_NAME]: className,
|
||||
[GROUP_PROPS.COUNT]: total,
|
||||
[GROUP_PROPS.KEY]: key,
|
||||
[GROUP_PROPS.HAS_FINDINGS]: hasFindings,
|
||||
},
|
||||
});
|
||||
|
||||
const makeOutcomeNode = (outcome: AttackPathOutcome): GraphNode => ({
|
||||
id: `outcome:${outcome.kind}`,
|
||||
labels: [OUTCOME_NODE_LABEL],
|
||||
properties: {
|
||||
[OUTCOME_PROPS.KIND]: outcome.kind,
|
||||
[OUTCOME_PROPS.LABEL]: outcome.label,
|
||||
[OUTCOME_PROPS.PARTIAL]: outcome.partial ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
export const buildAttackPathView = ({
|
||||
data,
|
||||
expandedClasses,
|
||||
outcome,
|
||||
}: AttackPathViewInput): AttackPathView => {
|
||||
const rawNodes = data?.nodes ?? [];
|
||||
const allEdges = edgesOf(data);
|
||||
|
||||
// Partition: findings are kept (see module docs); the account hub is dropped.
|
||||
const findingIds = new Set(
|
||||
rawNodes.filter((n) => isProwlerFindingNode(n.labels)).map((n) => n.id),
|
||||
);
|
||||
const resourceNodes = rawNodes.filter(
|
||||
(n) => !isAccountHub(n) && !isProwlerFindingNode(n.labels),
|
||||
);
|
||||
const resourceIds = new Set(resourceNodes.map((n) => n.id));
|
||||
|
||||
// Resource-level edges drive ranking/grouping (findings + hub edges excluded).
|
||||
const resourceEdges = allEdges.filter(
|
||||
(e) =>
|
||||
e.type !== HAS_FINDING &&
|
||||
resourceIds.has(e.source) &&
|
||||
resourceIds.has(e.target),
|
||||
);
|
||||
|
||||
// Rank/sink reasoning must follow the same direction the layout renders, so
|
||||
// orient container relationships up front (see edge-orientation).
|
||||
const orientedResourceEdges = resourceEdges.map((e) => {
|
||||
const [source, target] = orientEdgeForLayout(e.source, e.target, e.type);
|
||||
return { ...e, source, target };
|
||||
});
|
||||
|
||||
// Which resources carry findings (for the group "has findings" indicator).
|
||||
const resourceHasFinding = new Set<string>();
|
||||
for (const edge of allEdges) {
|
||||
if (edge.type !== HAS_FINDING) continue;
|
||||
const findingEnd = findingIds.has(edge.source) ? edge.source : edge.target;
|
||||
const resourceEnd = findingEnd === edge.source ? edge.target : edge.source;
|
||||
if (findingIds.has(findingEnd) && resourceIds.has(resourceEnd)) {
|
||||
resourceHasFinding.add(resourceEnd);
|
||||
}
|
||||
}
|
||||
|
||||
// Group members by (rank, class).
|
||||
const ranks = computeRanks(resourceNodes, orientedResourceEdges);
|
||||
const groups = new Map<string, GraphNode[]>();
|
||||
for (const node of resourceNodes) {
|
||||
const key = groupKey(ranks.get(node.id) ?? 0, classLabelOf(node));
|
||||
groups.set(key, [...(groups.get(key) ?? []), node]);
|
||||
}
|
||||
|
||||
const groupMembers = new Map<string, string[]>();
|
||||
const viewIdOf = new Map<string, string>();
|
||||
const outNodes: GraphNode[] = [];
|
||||
|
||||
for (const [key, members] of Array.from(groups.entries())) {
|
||||
groupMembers.set(
|
||||
key,
|
||||
members.map((m) => m.id),
|
||||
);
|
||||
const isSingleton = members.length === 1;
|
||||
const isExpanded = expandedClasses.has(key);
|
||||
|
||||
if (isSingleton || isExpanded) {
|
||||
// Expanding a class renders every member — never a subset — so the graph
|
||||
// can't present an incomplete attack path as complete.
|
||||
for (const member of members) {
|
||||
viewIdOf.set(member.id, member.id);
|
||||
// Tag expanded members (not lone singletons) so a double-click can
|
||||
// collapse the whole class. Clone so raw data stays untouched.
|
||||
outNodes.push(
|
||||
isSingleton
|
||||
? member
|
||||
: {
|
||||
...member,
|
||||
properties: {
|
||||
...member.properties,
|
||||
[GROUP_PROPS.MEMBER_KEY]: key,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const groupId = `group:${key}`;
|
||||
const className = resolveNodeVisual(members[0]).description;
|
||||
const hasFindings = members.some((m) => resourceHasFinding.has(m.id));
|
||||
outNodes.push(
|
||||
makeGroupNode(
|
||||
key,
|
||||
classLabelOf(members[0]),
|
||||
className,
|
||||
members.length,
|
||||
hasFindings,
|
||||
),
|
||||
);
|
||||
for (const member of members) viewIdOf.set(member.id, groupId);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate resource-level edges between view nodes; dedupe, drop intra-group.
|
||||
const edgeMap = new Map<string, GraphEdge>();
|
||||
for (const edge of resourceEdges) {
|
||||
const source = viewIdOf.get(edge.source);
|
||||
const target = viewIdOf.get(edge.target);
|
||||
if (!source || !target || source === target) continue;
|
||||
const id = `${source}->${target}:${edge.type}`;
|
||||
const existing = edgeMap.get(id);
|
||||
if (existing) {
|
||||
const count = Number(existing.properties?.__count ?? 1) + 1;
|
||||
existing.properties = { ...existing.properties, __count: count };
|
||||
} else {
|
||||
edgeMap.set(id, {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
type: edge.type,
|
||||
properties: { ...edge.properties, __count: 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
const resourceViewEdges = Array.from(edgeMap.values());
|
||||
|
||||
// Keep finding nodes + HAS_FINDING edges for members that are visible (i.e.
|
||||
// whose class is expanded — a collapsed group hides its members' findings).
|
||||
// The canvas then reveals them per the existing per-resource behaviour.
|
||||
// Findings not attached to any resource (orphans) pass through as-is, so a
|
||||
// findings-only graph still renders, matching current behaviour.
|
||||
const findingNodes: GraphNode[] = [];
|
||||
const findingEdges: GraphEdge[] = [];
|
||||
const includedFindings = new Set<string>();
|
||||
const findingsWithResourceEdge = new Set<string>();
|
||||
|
||||
const includeFinding = (findingId: string) => {
|
||||
if (includedFindings.has(findingId)) return;
|
||||
includedFindings.add(findingId);
|
||||
const findingNode = rawNodes.find((n) => n.id === findingId);
|
||||
if (findingNode) findingNodes.push(findingNode);
|
||||
};
|
||||
|
||||
for (const edge of allEdges) {
|
||||
if (edge.type !== HAS_FINDING) continue;
|
||||
const findingEnd = findingIds.has(edge.source) ? edge.source : edge.target;
|
||||
const resourceEnd = findingEnd === edge.source ? edge.target : edge.source;
|
||||
if (!findingIds.has(findingEnd) || !resourceIds.has(resourceEnd)) continue;
|
||||
findingsWithResourceEdge.add(findingEnd);
|
||||
// Visible only when the resource renders as itself (not collapsed).
|
||||
if (viewIdOf.get(resourceEnd) !== resourceEnd) continue;
|
||||
findingEdges.push(edge);
|
||||
includeFinding(findingEnd);
|
||||
}
|
||||
|
||||
// Orphan findings (no HAS_FINDING to any resource) render standalone.
|
||||
for (const findingId of Array.from(findingIds)) {
|
||||
if (!findingsWithResourceEdge.has(findingId)) includeFinding(findingId);
|
||||
}
|
||||
outNodes.push(...findingNodes);
|
||||
|
||||
const outEdges = [...resourceViewEdges, ...findingEdges];
|
||||
|
||||
// Inject the outcome node at the resource-level sinks (no outgoing resource
|
||||
// edge). Findings/outcome edges do not count toward "has an outgoing edge".
|
||||
if (outcome) {
|
||||
// A sink has no outgoing edge in the *laid-out* direction, so resolve
|
||||
// "outgoing" from the oriented edges — otherwise a reversed container edge
|
||||
// makes a terminal node look like it still points onward.
|
||||
const hasOutgoing = new Set<string>();
|
||||
for (const edge of orientedResourceEdges) {
|
||||
const source = viewIdOf.get(edge.source);
|
||||
const target = viewIdOf.get(edge.target);
|
||||
if (source && target && source !== target) hasOutgoing.add(source);
|
||||
}
|
||||
const attachable = outNodes.filter(
|
||||
(n) =>
|
||||
!isInternet(n) &&
|
||||
!findingIds.has(n.id) &&
|
||||
!n.labels.includes(OUTCOME_NODE_LABEL),
|
||||
);
|
||||
let sinks = attachable.filter((n) => !hasOutgoing.has(n.id));
|
||||
// No sink (SSO-style principal-only, or a cycle): attach to every node.
|
||||
if (sinks.length === 0) sinks = attachable;
|
||||
if (sinks.length > 0) {
|
||||
const outcomeNode = makeOutcomeNode(outcome);
|
||||
outNodes.push(outcomeNode);
|
||||
for (const sink of sinks) {
|
||||
outEdges.push({
|
||||
id: `${sink.id}->${outcomeNode.id}:OUTCOME`,
|
||||
source: sink.id,
|
||||
target: outcomeNode.id,
|
||||
type: "OUTCOME",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: outNodes, edges: outEdges, groupMembers };
|
||||
};
|
||||
@@ -4,11 +4,14 @@ export {
|
||||
getNodeBorderColor,
|
||||
getNodeColor,
|
||||
GRAPH_ALERT_BORDER_COLOR,
|
||||
GRAPH_COUNT_BADGE_STROKE_COLOR,
|
||||
GRAPH_EDGE_COLOR_DARK,
|
||||
GRAPH_EDGE_COLOR_LIGHT,
|
||||
GRAPH_EDGE_HIGHLIGHT_COLOR,
|
||||
GRAPH_NODE_BORDER_COLORS,
|
||||
GRAPH_NODE_COLORS,
|
||||
GRAPH_OUTCOME_BORDER_COLOR,
|
||||
GRAPH_OUTCOME_FILL_COLOR,
|
||||
GRAPH_SELECTION_COLOR,
|
||||
resolveNodeColors,
|
||||
} from "./graph-colors";
|
||||
|
||||
@@ -8,29 +8,31 @@ import { type Edge, type Node, Position } from "@xyflow/react";
|
||||
|
||||
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { orientEdgeForLayout } from "./edge-orientation";
|
||||
import { GROUP_NODE_LABEL, OUTCOME_NODE_LABEL } from "./group-graph";
|
||||
import {
|
||||
FINDING_NODE_DIMENSIONS,
|
||||
GROUP_NODE_DIMENSIONS,
|
||||
INTERNET_NODE_DIMENSIONS,
|
||||
OUTCOME_NODE_DIMENSIONS,
|
||||
RESOURCE_NODE_DIMENSIONS,
|
||||
} from "./node-dimensions";
|
||||
import { isProwlerFindingNode } from "./node-types";
|
||||
|
||||
// Container relationships that get reversed for proper hierarchy
|
||||
const CONTAINER_RELATIONS = new Set([
|
||||
"RUNS_IN",
|
||||
"BELONGS_TO",
|
||||
"LOCATED_IN",
|
||||
"PART_OF",
|
||||
]);
|
||||
|
||||
interface NodeData extends Record<string, unknown> {
|
||||
graphNode: GraphNode;
|
||||
}
|
||||
|
||||
const NODE_TYPE = {
|
||||
// Shared with the `NODE_TYPES` component registry in attack-path-graph so the
|
||||
// type strings can't drift apart.
|
||||
export const NODE_TYPE = {
|
||||
FINDING: "finding",
|
||||
INTERNET: "internet",
|
||||
RESOURCE: "resource",
|
||||
// React Flow reserves "group" for its built-in container node (styled via
|
||||
// .react-flow__node-group), so use a distinct type for our custom node.
|
||||
GROUP: "classGroup",
|
||||
OUTCOME: "outcome",
|
||||
} as const;
|
||||
|
||||
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
|
||||
@@ -38,6 +40,9 @@ type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
|
||||
const isFindingNode = isProwlerFindingNode;
|
||||
|
||||
const getNodeType = (labels: string[]): NodeType => {
|
||||
// Synthetic view nodes are tagged with a reserved label.
|
||||
if (labels.includes(OUTCOME_NODE_LABEL)) return NODE_TYPE.OUTCOME;
|
||||
if (labels.includes(GROUP_NODE_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 +62,16 @@ const getNodeDimensions = (
|
||||
width: INTERNET_NODE_DIMENSIONS.DIAMETER,
|
||||
height: INTERNET_NODE_DIMENSIONS.DIAMETER,
|
||||
};
|
||||
if (type === NODE_TYPE.GROUP)
|
||||
return {
|
||||
width: GROUP_NODE_DIMENSIONS.WIDTH,
|
||||
height: GROUP_NODE_DIMENSIONS.HEIGHT,
|
||||
};
|
||||
if (type === NODE_TYPE.OUTCOME)
|
||||
return {
|
||||
width: OUTCOME_NODE_DIMENSIONS.WIDTH,
|
||||
height: OUTCOME_NODE_DIMENSIONS.HEIGHT,
|
||||
};
|
||||
return {
|
||||
width: RESOURCE_NODE_DIMENSIONS.WIDTH,
|
||||
height: RESOURCE_NODE_DIMENSIONS.HEIGHT,
|
||||
@@ -90,12 +105,11 @@ export const layoutWithDagre = (
|
||||
|
||||
// Add edges, reversing container relationships for proper hierarchy
|
||||
edges.forEach((edge) => {
|
||||
let sourceId = edge.source;
|
||||
let targetId = edge.target;
|
||||
|
||||
if (CONTAINER_RELATIONS.has(edge.type)) {
|
||||
[sourceId, targetId] = [targetId, sourceId];
|
||||
}
|
||||
const [sourceId, targetId] = orientEdgeForLayout(
|
||||
edge.source,
|
||||
edge.target,
|
||||
edge.type,
|
||||
);
|
||||
|
||||
if (sourceId && targetId) {
|
||||
g.setEdge(
|
||||
|
||||
@@ -15,3 +15,20 @@ export const FINDING_NODE_DIMENSIONS = {
|
||||
export const INTERNET_NODE_DIMENSIONS = {
|
||||
DIAMETER: 80,
|
||||
} as const;
|
||||
|
||||
// A collapsed resource-class group: same footprint as a resource node so the
|
||||
// layout stays even, plus a count badge and an "expand" hint.
|
||||
export const GROUP_NODE_DIMENSIONS = {
|
||||
WIDTH: 136,
|
||||
HEIGHT: 124,
|
||||
LABEL_MAX_CHARS: 16,
|
||||
LABEL_MAX_LINES: 2,
|
||||
} as const;
|
||||
|
||||
// The terminal outcome node (circular, visually distinct endpoint).
|
||||
export const OUTCOME_NODE_DIMENSIONS = {
|
||||
WIDTH: 136,
|
||||
HEIGHT: 124,
|
||||
LABEL_MAX_CHARS: 16,
|
||||
LABEL_MAX_LINES: 2,
|
||||
} as const;
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
AttackPathQuery,
|
||||
AttackPathQueryError,
|
||||
GraphNode,
|
||||
AttackPathOutcome,
|
||||
} from "@/types/attack-paths";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
@@ -73,6 +74,12 @@ import {
|
||||
getGraphBuildingProgress,
|
||||
isScanInFlight,
|
||||
} from "./_lib/get-attack-paths-view-state";
|
||||
import {
|
||||
buildAttackPathView,
|
||||
GROUP_NODE_LABEL,
|
||||
GROUP_PROPS,
|
||||
OUTCOME_NODE_LABEL,
|
||||
} from "./_lib/group-graph";
|
||||
|
||||
const SCROLL_CONTAINER_CLASS =
|
||||
"minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm";
|
||||
@@ -107,6 +114,10 @@ export default function AttackPathsPage() {
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
|
||||
// Snapshot of the executed query's outcome, so the graph's terminal node
|
||||
// reflects the query that produced the current graph (not a later selection).
|
||||
const [executedOutcome, setExecutedOutcome] =
|
||||
useState<AttackPathOutcome | null>(null);
|
||||
|
||||
const queryBuilder = useQueryBuilder(queries);
|
||||
|
||||
@@ -269,6 +280,11 @@ export default function AttackPathsPage() {
|
||||
queryBuilder.selectedQueryData?.attributes.name ?? queryId;
|
||||
const parameters = { ...queryBuilder.getQueryParameters() };
|
||||
const isCustomQuery = queryId === ATTACK_PATH_QUERY_IDS.CUSTOM;
|
||||
// Snapshot before awaiting: the selected query can change while the
|
||||
// request is in flight. Custom queries have no catalog outcome → null.
|
||||
const queryOutcome = isCustomQuery
|
||||
? null
|
||||
: (queryBuilder.selectedQueryData?.attributes.outcome ?? null);
|
||||
const result = isCustomQuery
|
||||
? await executeCustomQuery(scanId, String(parameters?.query ?? ""))
|
||||
: await executeQuery(scanId, queryId, parameters);
|
||||
@@ -305,6 +321,7 @@ export default function AttackPathsPage() {
|
||||
: ATTACK_PATH_QUERY_KIND.PREDEFINED,
|
||||
parameters,
|
||||
});
|
||||
setExecutedOutcome(queryOutcome);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Query executed successfully",
|
||||
@@ -339,7 +356,41 @@ export default function AttackPathsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Shared attack-path view: the same grouped/outcome transform the graph
|
||||
// renders, computed here so the PNG export and collapse-state pruning use the
|
||||
// exact view the user sees. Cloud-only (OSS keeps the flat graph).
|
||||
const attackPathView =
|
||||
isCloud() && graphState.data
|
||||
? buildAttackPathView({
|
||||
data: graphState.data,
|
||||
expandedClasses: graphState.expandedClasses,
|
||||
outcome: executedOutcome,
|
||||
})
|
||||
: null;
|
||||
|
||||
const membersOfClass = (classKey: string): string[] =>
|
||||
attackPathView?.groupMembers.get(classKey) ?? [];
|
||||
|
||||
// Collapse every open class, pruning findings-expansion/selection that pointed
|
||||
// at any member the collapse hides.
|
||||
const handleCollapseAll = () => {
|
||||
const memberIds = Array.from(graphState.expandedClasses).flatMap(
|
||||
membersOfClass,
|
||||
);
|
||||
graphState.collapseAllClasses(memberIds);
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
// A collapsed class group expands to its members; the outcome node is inert.
|
||||
if (node.labels.includes(GROUP_NODE_LABEL)) {
|
||||
const key = String(node.properties[GROUP_PROPS.KEY] ?? "");
|
||||
if (key) graphState.toggleExpandedClass(key, membersOfClass(key));
|
||||
return;
|
||||
}
|
||||
if (node.labels.includes(OUTCOME_NODE_LABEL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFinding = isProwlerFindingNode(node.labels);
|
||||
|
||||
if (isFinding) {
|
||||
@@ -364,7 +415,19 @@ export default function AttackPathsPage() {
|
||||
});
|
||||
|
||||
if (hasFindings) {
|
||||
// Highlight the resource whose findings are on screen; clear on collapse.
|
||||
const willExpand = !graphState.expandedResources.has(node.id);
|
||||
graphState.toggleExpandedResource(node.id);
|
||||
graphState.selectNode(willExpand ? node.id : null);
|
||||
}
|
||||
};
|
||||
|
||||
// Double-click a member (or its group) collapses its class back.
|
||||
const handleNodeDoubleClick = (node: GraphNode) => {
|
||||
const memberKey = node.properties[GROUP_PROPS.MEMBER_KEY];
|
||||
if (memberKey) {
|
||||
const key = String(memberKey);
|
||||
graphState.toggleExpandedClass(key, membersOfClass(key));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -387,12 +450,18 @@ export default function AttackPathsPage() {
|
||||
const handle = ref.current;
|
||||
if (!handle) return;
|
||||
|
||||
// Export the same grouped/outcome view the canvas renders (Cloud); OSS
|
||||
// exports the raw flat graph.
|
||||
const exportData = attackPathView
|
||||
? { nodes: attackPathView.nodes, edges: attackPathView.edges }
|
||||
: graphState.data;
|
||||
|
||||
try {
|
||||
await exportGraphAsPNG(
|
||||
handle.getContainerElement(),
|
||||
handle.getNodesBounds(),
|
||||
"attack-path-graph.png",
|
||||
graphState.data,
|
||||
exportData,
|
||||
{
|
||||
expandedResources: graphState.expandedResources,
|
||||
isFilteredView: graphState.isFilteredView,
|
||||
@@ -623,6 +692,10 @@ export default function AttackPathsPage() {
|
||||
onZoomOut={() => graphRef.current?.zoomOut()}
|
||||
onFitToScreen={() => graphRef.current?.resetZoom()}
|
||||
onExport={() => handleGraphExport("main")}
|
||||
collapseAll={{
|
||||
can: graphState.expandedClasses.size > 0,
|
||||
onCollapse: handleCollapseAll,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
|
||||
@@ -660,6 +733,10 @@ export default function AttackPathsPage() {
|
||||
fullscreenGraphRef.current?.resetZoom()
|
||||
}
|
||||
onExport={() => handleGraphExport("fullscreen")}
|
||||
collapseAll={{
|
||||
can: graphState.expandedClasses.size > 0,
|
||||
onCollapse: handleCollapseAll,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6 lg:flex-row">
|
||||
@@ -668,11 +745,15 @@ export default function AttackPathsPage() {
|
||||
ref={fullscreenGraphRef}
|
||||
data={graphState.data}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
expandedResources={
|
||||
graphState.expandedResources
|
||||
}
|
||||
expandedClasses={graphState.expandedClasses}
|
||||
outcome={executedOutcome}
|
||||
view={attackPathView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -690,9 +771,13 @@ export default function AttackPathsPage() {
|
||||
ref={graphRef}
|
||||
data={graphState.data}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
expandedResources={graphState.expandedResources}
|
||||
expandedClasses={graphState.expandedClasses}
|
||||
outcome={executedOutcome}
|
||||
view={attackPathView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Attack Paths graph groups resources by class into expandable nodes and marks the query outcome as the terminal node, with the clicked resource highlighted while its findings are expanded (Prowler Cloud only)
|
||||
@@ -151,6 +151,14 @@ export interface AttackPathQueryResultSummary {
|
||||
has_data: boolean | null;
|
||||
}
|
||||
|
||||
// Terminal impact of an attack-path query, rendered as the graph's outcome node.
|
||||
// Optional: custom queries have no outcome, and older backends omit the field.
|
||||
export interface AttackPathOutcome {
|
||||
kind: string;
|
||||
label: string;
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
export interface AttackPathQueryAttributes {
|
||||
name: string;
|
||||
short_description: string;
|
||||
@@ -160,6 +168,7 @@ export interface AttackPathQueryAttributes {
|
||||
attribution: AttackPathQueryAttribution | null;
|
||||
documentation_link?: AttackPathQueryDocumentationLink | null;
|
||||
result_summary?: AttackPathQueryResultSummary | null;
|
||||
outcome?: AttackPathOutcome | null;
|
||||
}
|
||||
|
||||
export interface AttackPathQuery {
|
||||
|
||||
Reference in New Issue
Block a user