mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-23 20:04:27 +00:00
731 lines
21 KiB
TypeScript
731 lines
21 KiB
TypeScript
/**
|
|
* Export utilities for attack path graphs.
|
|
*
|
|
* React Flow DOM screenshotting proved unreliable in-app, so PNG export draws a
|
|
* deterministic canvas from the same graph data + Dagre layout used by the UI.
|
|
*/
|
|
|
|
import type { Rect } from "@xyflow/react";
|
|
|
|
import { truncateLabel } from "@/lib/attack-paths/format";
|
|
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 "@/lib/attack-paths/graph-colors";
|
|
import {
|
|
GROUP_NODE_LABEL,
|
|
GROUP_PROPS,
|
|
OUTCOME_NODE_LABEL,
|
|
OUTCOME_PROPS,
|
|
} from "@/lib/attack-paths/group-graph";
|
|
import { layoutWithDagre } from "@/lib/attack-paths/layout";
|
|
import {
|
|
FINDING_NODE_DIMENSIONS,
|
|
GROUP_NODE_DIMENSIONS,
|
|
OUTCOME_NODE_DIMENSIONS,
|
|
RESOURCE_NODE_DIMENSIONS,
|
|
} from "@/lib/attack-paths/node-dimensions";
|
|
import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines";
|
|
import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
|
|
import { resolveNodeVisual } from "@/lib/attack-paths/node-visuals";
|
|
import type { AttackPathGraphData, GraphEdge } from "@/types/attack-paths";
|
|
|
|
interface ExportGraphOptions {
|
|
expandedResources?: ReadonlySet<string>;
|
|
isFilteredView?: boolean;
|
|
selectedNodeId?: string | null;
|
|
}
|
|
|
|
interface Point {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
const EXPORT_IMAGE_WIDTH = 1920;
|
|
const EXPORT_IMAGE_HEIGHT = 1080;
|
|
const EXPORT_BACKGROUND = "#1c1917";
|
|
const EXPORT_PADDING = 96;
|
|
const DOT_SPACING = 32;
|
|
const BADGE_RADIUS = 22;
|
|
const BADGE_CENTER_Y = 26;
|
|
const GLOW_RADIUS = 30;
|
|
const LABEL_Y = 66;
|
|
const LABEL_LINE_HEIGHT = 13;
|
|
const TYPE_Y = 118;
|
|
|
|
const downloadBlob = (blob: Blob, filename: string) => {
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const downloadDataUrl = (dataUrl: string, filename: string) => {
|
|
const link = document.createElement("a");
|
|
link.href = dataUrl;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
};
|
|
|
|
const isFindingNode = isProwlerFindingNode;
|
|
|
|
const getGraphEdges = (graphData: AttackPathGraphData): GraphEdge[] => {
|
|
if (graphData.edges?.length) return graphData.edges;
|
|
|
|
return (graphData.relationships ?? []).map((relationship) => ({
|
|
id: relationship.id,
|
|
source: relationship.source,
|
|
target: relationship.target,
|
|
type: relationship.label,
|
|
properties: relationship.properties,
|
|
}));
|
|
};
|
|
|
|
const getVisibleGraphData = (
|
|
graphData: AttackPathGraphData,
|
|
options: ExportGraphOptions = {},
|
|
): AttackPathGraphData => {
|
|
if (options.isFilteredView) return graphData;
|
|
|
|
const edges = getGraphEdges(graphData);
|
|
const expandedResources = options.expandedResources ?? new Set<string>();
|
|
const nodeById = new Map(graphData.nodes.map((node) => [node.id, node]));
|
|
const hiddenFindingIds = new Set<string>();
|
|
|
|
graphData.nodes.forEach((node) => {
|
|
if (!isFindingNode(node.labels)) return;
|
|
|
|
const connectedResources = edges
|
|
.flatMap((edge) => {
|
|
if (edge.source === node.id) return [edge.target];
|
|
if (edge.target === node.id) return [edge.source];
|
|
return [];
|
|
})
|
|
.filter((id) => {
|
|
const connectedNode = nodeById.get(id);
|
|
return connectedNode && !isFindingNode(connectedNode.labels);
|
|
});
|
|
|
|
const hasExpandedResource = connectedResources.some((resourceId) =>
|
|
expandedResources.has(resourceId),
|
|
);
|
|
if (connectedResources.length > 0 && !hasExpandedResource) {
|
|
hiddenFindingIds.add(node.id);
|
|
}
|
|
});
|
|
|
|
return {
|
|
nodes: graphData.nodes.filter((node) => !hiddenFindingIds.has(node.id)),
|
|
edges: edges.filter(
|
|
(edge) =>
|
|
!hiddenFindingIds.has(edge.source) &&
|
|
!hiddenFindingIds.has(edge.target),
|
|
),
|
|
};
|
|
};
|
|
|
|
const getResourcesWithFindings = (
|
|
sourceGraphData: AttackPathGraphData,
|
|
visibleGraphData: AttackPathGraphData,
|
|
) => {
|
|
const visibleNodeIds = new Set(visibleGraphData.nodes.map((node) => node.id));
|
|
const sourceNodeById = new Map(
|
|
sourceGraphData.nodes.map((node) => [node.id, node]),
|
|
);
|
|
const findingNodeIds = new Set(
|
|
sourceGraphData.nodes
|
|
.filter((node) => isFindingNode(node.labels))
|
|
.map((node) => node.id),
|
|
);
|
|
const resourcesWithFindings = new Set<string>();
|
|
|
|
getGraphEdges(sourceGraphData).forEach((edge) => {
|
|
const sourceIsFinding = findingNodeIds.has(edge.source);
|
|
const targetIsFinding = findingNodeIds.has(edge.target);
|
|
const sourceNode = sourceNodeById.get(edge.source);
|
|
const targetNode = sourceNodeById.get(edge.target);
|
|
|
|
if (
|
|
sourceIsFinding &&
|
|
targetNode &&
|
|
!isFindingNode(targetNode.labels) &&
|
|
visibleNodeIds.has(edge.target)
|
|
) {
|
|
resourcesWithFindings.add(edge.target);
|
|
}
|
|
|
|
if (
|
|
targetIsFinding &&
|
|
sourceNode &&
|
|
!isFindingNode(sourceNode.labels) &&
|
|
visibleNodeIds.has(edge.source)
|
|
) {
|
|
resourcesWithFindings.add(edge.source);
|
|
}
|
|
});
|
|
|
|
return resourcesWithFindings;
|
|
};
|
|
|
|
const getFittedLayout = (graphData: AttackPathGraphData) => {
|
|
const { rfNodes, rfEdges } = layoutWithDagre(
|
|
graphData.nodes,
|
|
getGraphEdges(graphData),
|
|
);
|
|
|
|
if (rfNodes.length === 0) {
|
|
throw new Error("No nodes to export");
|
|
}
|
|
|
|
const minX = Math.min(...rfNodes.map((node) => node.position.x));
|
|
const minY = Math.min(...rfNodes.map((node) => node.position.y));
|
|
const maxX = Math.max(
|
|
...rfNodes.map((node) => node.position.x + (node.width ?? 0)),
|
|
);
|
|
const maxY = Math.max(
|
|
...rfNodes.map((node) => node.position.y + (node.height ?? 0)),
|
|
);
|
|
const graphWidth = Math.max(maxX - minX, 1);
|
|
const graphHeight = Math.max(maxY - minY, 1);
|
|
const scale = Math.min(
|
|
(EXPORT_IMAGE_WIDTH - EXPORT_PADDING * 2) / graphWidth,
|
|
(EXPORT_IMAGE_HEIGHT - EXPORT_PADDING * 2) / graphHeight,
|
|
2,
|
|
);
|
|
const offsetX = (EXPORT_IMAGE_WIDTH - graphWidth * scale) / 2 - minX * scale;
|
|
const offsetY =
|
|
(EXPORT_IMAGE_HEIGHT - graphHeight * scale) / 2 - minY * scale;
|
|
|
|
const toExportPoint = (x: number, y: number): Point => ({
|
|
x: x * scale + offsetX,
|
|
y: y * scale + offsetY,
|
|
});
|
|
|
|
return { rfNodes, rfEdges, toExportPoint };
|
|
};
|
|
|
|
const drawBackground = (context: CanvasRenderingContext2D) => {
|
|
context.fillStyle = EXPORT_BACKGROUND;
|
|
context.fillRect(0, 0, EXPORT_IMAGE_WIDTH, EXPORT_IMAGE_HEIGHT);
|
|
|
|
context.fillStyle = "rgba(68, 64, 60, 0.55)";
|
|
for (let x = 2; x < EXPORT_IMAGE_WIDTH; x += DOT_SPACING) {
|
|
for (let y = 2; y < EXPORT_IMAGE_HEIGHT; y += DOT_SPACING) {
|
|
context.beginPath();
|
|
context.arc(x, y, 1.4, 0, Math.PI * 2);
|
|
context.fill();
|
|
}
|
|
}
|
|
};
|
|
|
|
const movePointToward = (from: Point, to: Point, distance: number): Point => {
|
|
const dx = to.x - from.x;
|
|
const dy = to.y - from.y;
|
|
const length = Math.hypot(dx, dy);
|
|
if (length === 0) return from;
|
|
|
|
return {
|
|
x: from.x + (dx / length) * distance,
|
|
y: from.y + (dy / length) * distance,
|
|
};
|
|
};
|
|
|
|
const drawArrowHead = (
|
|
context: CanvasRenderingContext2D,
|
|
from: Point,
|
|
to: Point,
|
|
) => {
|
|
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
|
const size = 10;
|
|
|
|
context.beginPath();
|
|
context.moveTo(to.x, to.y);
|
|
context.lineTo(
|
|
to.x - size * Math.cos(angle - Math.PI / 6),
|
|
to.y - size * Math.sin(angle - Math.PI / 6),
|
|
);
|
|
context.lineTo(
|
|
to.x - size * Math.cos(angle + Math.PI / 6),
|
|
to.y - size * Math.sin(angle + Math.PI / 6),
|
|
);
|
|
context.closePath();
|
|
context.fill();
|
|
};
|
|
|
|
const drawEdges = (
|
|
context: CanvasRenderingContext2D,
|
|
edges: ReturnType<typeof layoutWithDagre>["rfEdges"],
|
|
getNodeCenter: (id: string) => Point | null,
|
|
) => {
|
|
context.strokeStyle = GRAPH_EDGE_COLOR_DARK;
|
|
context.fillStyle = GRAPH_EDGE_COLOR_DARK;
|
|
context.globalAlpha = 0.72;
|
|
context.lineWidth = 2;
|
|
|
|
edges.forEach((edge) => {
|
|
const source = getNodeCenter(edge.source);
|
|
const target = getNodeCenter(edge.target);
|
|
if (!source || !target) return;
|
|
|
|
const start = movePointToward(source, target, BADGE_RADIUS);
|
|
const end = movePointToward(target, source, BADGE_RADIUS + 8);
|
|
const midX = (start.x + end.x) / 2;
|
|
context.setLineDash(
|
|
edge.className?.includes("finding-edge") ? [10, 8] : [],
|
|
);
|
|
context.beginPath();
|
|
context.moveTo(start.x, start.y);
|
|
context.bezierCurveTo(midX, start.y, midX, end.y, end.x, end.y);
|
|
context.stroke();
|
|
context.setLineDash([]);
|
|
drawArrowHead(context, start, end);
|
|
});
|
|
|
|
context.globalAlpha = 1;
|
|
};
|
|
|
|
const drawShieldIcon = (
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
) => {
|
|
context.beginPath();
|
|
context.moveTo(x, y - 12);
|
|
context.quadraticCurveTo(x + 8, y - 8, x + 12, y - 8);
|
|
context.lineTo(x + 10, y + 3);
|
|
context.quadraticCurveTo(x + 8, y + 11, x, y + 14);
|
|
context.quadraticCurveTo(x - 8, y + 11, x - 10, y + 3);
|
|
context.lineTo(x - 12, y - 8);
|
|
context.quadraticCurveTo(x - 8, y - 8, x, y - 12);
|
|
context.stroke();
|
|
context.beginPath();
|
|
context.moveTo(x - 5, y);
|
|
context.lineTo(x - 1, y + 4);
|
|
context.lineTo(x + 7, y - 5);
|
|
context.stroke();
|
|
};
|
|
|
|
const drawKeyIcon = (
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
) => {
|
|
context.beginPath();
|
|
context.arc(x - 6, y - 2, 5, 0, Math.PI * 2);
|
|
context.moveTo(x - 1, y - 2);
|
|
context.lineTo(x + 12, y - 2);
|
|
context.moveTo(x + 8, y - 2);
|
|
context.lineTo(x + 8, y + 5);
|
|
context.moveTo(x + 12, y - 2);
|
|
context.lineTo(x + 12, y + 3);
|
|
context.stroke();
|
|
};
|
|
|
|
const drawFindingIcon = (
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
) => {
|
|
context.beginPath();
|
|
context.moveTo(x, y - 13);
|
|
context.lineTo(x + 13, y + 10);
|
|
context.lineTo(x - 13, y + 10);
|
|
context.closePath();
|
|
context.stroke();
|
|
context.font = "700 18px sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.fillText("!", x, y + 3);
|
|
};
|
|
|
|
const drawNodeIcon = (
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
category: string,
|
|
description: string,
|
|
) => {
|
|
const lowerDescription = description.toLowerCase();
|
|
context.save();
|
|
context.strokeStyle = "#ffffff";
|
|
context.fillStyle = "#ffffff";
|
|
context.lineWidth = 2.2;
|
|
context.lineCap = "round";
|
|
context.lineJoin = "round";
|
|
|
|
if (category === "finding") {
|
|
drawFindingIcon(context, x, y);
|
|
} else if (lowerDescription.includes("policy statement")) {
|
|
context.font = "700 24px monospace";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.fillText("{}", x, y + 1);
|
|
} else if (lowerDescription.includes("policy")) {
|
|
drawKeyIcon(context, x, y);
|
|
} else if (category === "identity" || lowerDescription.includes("role")) {
|
|
drawShieldIcon(context, x, y);
|
|
} else if (category === "account") {
|
|
context.font = "700 11px sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.fillText("AWS", x, y + 1);
|
|
} else {
|
|
context.font = "700 14px sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.fillText(
|
|
description
|
|
.split(" ")
|
|
.map((word) => word[0])
|
|
.join("")
|
|
.slice(0, 2)
|
|
.toUpperCase(),
|
|
x,
|
|
y + 1,
|
|
);
|
|
}
|
|
|
|
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);
|
|
const stroke = options.hasFindings
|
|
? GRAPH_ALERT_BORDER_COLOR
|
|
: getNodeBorderColor(graphNode.labels, graphNode.properties);
|
|
const glowOpacity = options.selected
|
|
? 0.34
|
|
: isFinding
|
|
? 0.28
|
|
: options.hasFindings
|
|
? 0.26
|
|
: 0;
|
|
const strokeWidth = options.selected
|
|
? 4
|
|
: options.hasFindings
|
|
? 3
|
|
: isFinding
|
|
? 2.5
|
|
: 1.5;
|
|
|
|
if (glowOpacity > 0) {
|
|
context.fillStyle = stroke;
|
|
context.globalAlpha = glowOpacity / 2;
|
|
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 = strokeWidth;
|
|
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);
|
|
|
|
context.fillStyle = "#ffffff";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.font = "600 11px sans-serif";
|
|
const dimensions = isFinding
|
|
? FINDING_NODE_DIMENSIONS
|
|
: RESOURCE_NODE_DIMENSIONS;
|
|
const labelMaxWidth = dimensions.WIDTH;
|
|
|
|
getNodeLabelDisplay(
|
|
visual.displayName,
|
|
dimensions.LABEL_MAX_CHARS,
|
|
dimensions.LABEL_MAX_LINES,
|
|
).lines.forEach((line, index) => {
|
|
context.fillText(
|
|
line,
|
|
center.x,
|
|
center.y + (LABEL_Y - BADGE_CENTER_Y) + index * LABEL_LINE_HEIGHT,
|
|
labelMaxWidth,
|
|
);
|
|
});
|
|
|
|
context.fillStyle = "rgba(255,255,255,0.82)";
|
|
context.font = "9px sans-serif";
|
|
context.fillText(
|
|
typeLabel,
|
|
center.x,
|
|
center.y + (TYPE_Y - BADGE_CENTER_Y),
|
|
labelMaxWidth,
|
|
);
|
|
};
|
|
|
|
const renderGraphToPngDataUrl = (
|
|
graphData: AttackPathGraphData,
|
|
options?: ExportGraphOptions,
|
|
) => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = EXPORT_IMAGE_WIDTH;
|
|
canvas.height = EXPORT_IMAGE_HEIGHT;
|
|
const context = canvas.getContext("2d");
|
|
if (!context) throw new Error("Canvas not available");
|
|
|
|
const visibleGraphData = getVisibleGraphData(graphData, options);
|
|
const resourcesWithFindings = getResourcesWithFindings(
|
|
graphData,
|
|
visibleGraphData,
|
|
);
|
|
const { rfNodes, rfEdges, toExportPoint } = getFittedLayout(visibleGraphData);
|
|
const nodeById = new Map(rfNodes.map((node) => [node.id, node]));
|
|
const getNodeCenter = (id: string) => {
|
|
const node = nodeById.get(id);
|
|
if (!node) return null;
|
|
return toExportPoint(
|
|
node.position.x + (node.width ?? 0) / 2,
|
|
node.position.y + BADGE_CENTER_Y,
|
|
);
|
|
};
|
|
|
|
drawBackground(context);
|
|
drawEdges(context, rfEdges, getNodeCenter);
|
|
rfNodes.forEach((rfNode) => {
|
|
const center = getNodeCenter(rfNode.id);
|
|
if (!center) return;
|
|
|
|
drawNode(context, rfNode.data.graphNode, center, {
|
|
hasFindings: resourcesWithFindings.has(rfNode.id),
|
|
selected: options?.selectedNodeId === rfNode.id,
|
|
});
|
|
});
|
|
|
|
return canvas.toDataURL("image/png");
|
|
};
|
|
|
|
/**
|
|
* Export graph as PNG using graph data instead of DOM rasterization.
|
|
*/
|
|
export const exportGraphAsPNG = async (
|
|
containerElement: HTMLDivElement | null,
|
|
bounds: Rect | null,
|
|
filename: string = "attack-path-graph.png",
|
|
graphData?: AttackPathGraphData | null,
|
|
options?: ExportGraphOptions,
|
|
) => {
|
|
if (!containerElement) {
|
|
throw new Error("Graph container not mounted");
|
|
}
|
|
|
|
if (!containerElement.querySelector(".react-flow")) {
|
|
throw new Error("React Flow root not found in container");
|
|
}
|
|
|
|
if (!containerElement.querySelector(".react-flow__viewport")) {
|
|
throw new Error("React Flow viewport not found in container");
|
|
}
|
|
|
|
if (!bounds || !graphData?.nodes.length) {
|
|
throw new Error("No nodes to export");
|
|
}
|
|
|
|
try {
|
|
downloadDataUrl(renderGraphToPngDataUrl(graphData, options), filename);
|
|
} catch (error) {
|
|
console.error("Failed to export graph as PNG:", error);
|
|
throw new Error("Failed to export graph");
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Export graph data as JSON (format-agnostic — does not depend on DOM rendering).
|
|
*/
|
|
export const exportGraphAsJSON = (
|
|
graphData: Record<string, unknown>,
|
|
filename: string = "attack-path-graph.json",
|
|
) => {
|
|
try {
|
|
const jsonString = JSON.stringify(graphData, null, 2);
|
|
const blob = new Blob([jsonString], { type: "application/json" });
|
|
downloadBlob(blob, filename);
|
|
} catch (error) {
|
|
console.error("Failed to export graph as JSON:", error);
|
|
throw new Error("Failed to export graph");
|
|
}
|
|
};
|