- Add interactive attack chain DAG using vis-network with hierarchical layout, status-colored nodes, risk-based sizing, and click handlers - Add pentest session export API (GET /sessions/:id/export) supporting both JSON and Markdown report formats - Redesign attack chain tab with graph/list toggle views - Add export buttons (MD/JSON) to session header with Blob download - Show exploitable badge and endpoint on finding cards - Add export_pentest_report server function for dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
235 lines
6.6 KiB
JavaScript
235 lines
6.6 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════
|
|
// Attack Chain DAG Visualization — vis-network wrapper
|
|
// Obsidian Control theme
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
// Status color palette matching Obsidian Control
|
|
var STATUS_COLORS = {
|
|
completed: { bg: "#16a34a", border: "#12873c", font: "#060a13" },
|
|
running: { bg: "#d97706", border: "#b56205", font: "#060a13" },
|
|
failed: { bg: "#dc2626", border: "#b91c1c", font: "#ffffff" },
|
|
pending: { bg: "#5e7291", border: "#3d506b", font: "#e4eaf4" },
|
|
skipped: { bg: "#374151", border: "#1f2937", font: "#e4eaf4" },
|
|
};
|
|
|
|
var EDGE_COLOR = "rgba(94, 114, 145, 0.5)";
|
|
|
|
var network = null;
|
|
var nodesDataset = null;
|
|
var edgesDataset = null;
|
|
var rawNodesMap = {};
|
|
|
|
function getStatusColor(status) {
|
|
return STATUS_COLORS[status] || STATUS_COLORS.pending;
|
|
}
|
|
|
|
function truncate(str, maxLen) {
|
|
if (!str) return "";
|
|
return str.length > maxLen ? str.substring(0, maxLen) + "…" : str;
|
|
}
|
|
|
|
function buildTooltip(node) {
|
|
var lines = [];
|
|
lines.push("Tool: " + (node.tool_name || "unknown"));
|
|
lines.push("Status: " + (node.status || "pending"));
|
|
if (node.llm_reasoning) {
|
|
lines.push("Reasoning: " + truncate(node.llm_reasoning, 200));
|
|
}
|
|
var findingsCount = node.findings_produced ? node.findings_produced.length : 0;
|
|
lines.push("Findings: " + findingsCount);
|
|
lines.push("Risk: " + (node.risk_score != null ? node.risk_score : "N/A"));
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function toVisNode(node) {
|
|
var color = getStatusColor(node.status);
|
|
// Scale node size by risk_score: min 12, max 40
|
|
var risk = typeof node.risk_score === "number" ? node.risk_score : 0;
|
|
var size = Math.max(12, Math.min(40, 12 + (risk / 100) * 28));
|
|
|
|
return {
|
|
id: node.node_id,
|
|
label: node.tool_name || "unknown",
|
|
title: buildTooltip(node),
|
|
size: size,
|
|
color: {
|
|
background: color.bg,
|
|
border: color.border,
|
|
highlight: { background: color.bg, border: "#ffffff" },
|
|
hover: { background: color.bg, border: "#ffffff" },
|
|
},
|
|
font: {
|
|
color: color.font,
|
|
size: 11,
|
|
face: "'JetBrains Mono', monospace",
|
|
strokeWidth: 2,
|
|
strokeColor: "#060a13",
|
|
},
|
|
borderWidth: 1,
|
|
borderWidthSelected: 3,
|
|
shape: "dot",
|
|
_raw: node,
|
|
};
|
|
}
|
|
|
|
function buildEdges(nodes) {
|
|
var edges = [];
|
|
var seen = {};
|
|
nodes.forEach(function (node) {
|
|
if (!node.parent_node_ids) return;
|
|
node.parent_node_ids.forEach(function (parentId) {
|
|
var key = parentId + "|" + node.node_id;
|
|
if (seen[key]) return;
|
|
seen[key] = true;
|
|
edges.push({
|
|
from: parentId,
|
|
to: node.node_id,
|
|
color: {
|
|
color: EDGE_COLOR,
|
|
highlight: "#ffffff",
|
|
hover: EDGE_COLOR,
|
|
},
|
|
width: 2,
|
|
arrows: {
|
|
to: { enabled: true, scaleFactor: 0.5 },
|
|
},
|
|
smooth: {
|
|
enabled: true,
|
|
type: "cubicBezier",
|
|
roundness: 0.5,
|
|
forceDirection: "vertical",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
return edges;
|
|
}
|
|
|
|
/**
|
|
* Load and render an attack chain DAG.
|
|
* Called from Rust via eval().
|
|
* @param {Array} nodes - Array of AttackChainNode objects
|
|
*/
|
|
window.__loadAttackChain = function (nodes) {
|
|
var container = document.getElementById("attack-chain-canvas");
|
|
if (!container) {
|
|
console.error("[attack-chain-viz] #attack-chain-canvas not found");
|
|
return;
|
|
}
|
|
|
|
// Build lookup map
|
|
rawNodesMap = {};
|
|
nodes.forEach(function (n) {
|
|
rawNodesMap[n.node_id] = n;
|
|
});
|
|
|
|
var visNodes = nodes.map(toVisNode);
|
|
var visEdges = buildEdges(nodes);
|
|
|
|
nodesDataset = new vis.DataSet(visNodes);
|
|
edgesDataset = new vis.DataSet(visEdges);
|
|
|
|
var options = {
|
|
nodes: {
|
|
font: { color: "#e4eaf4", size: 11 },
|
|
scaling: { min: 12, max: 40 },
|
|
},
|
|
edges: {
|
|
font: { color: "#5e7291", size: 9, strokeWidth: 0 },
|
|
selectionWidth: 3,
|
|
},
|
|
physics: {
|
|
enabled: false,
|
|
},
|
|
layout: {
|
|
hierarchical: {
|
|
enabled: true,
|
|
direction: "UD",
|
|
sortMethod: "directed",
|
|
levelSeparation: 120,
|
|
nodeSpacing: 160,
|
|
treeSpacing: 200,
|
|
blockShifting: true,
|
|
edgeMinimization: true,
|
|
parentCentralization: true,
|
|
},
|
|
},
|
|
interaction: {
|
|
hover: true,
|
|
tooltipDelay: 200,
|
|
hideEdgesOnDrag: false,
|
|
hideEdgesOnZoom: false,
|
|
multiselect: false,
|
|
navigationButtons: false,
|
|
keyboard: { enabled: true },
|
|
},
|
|
};
|
|
|
|
// Destroy previous instance
|
|
if (network) {
|
|
network.destroy();
|
|
}
|
|
|
|
network = new vis.Network(
|
|
container,
|
|
{ nodes: nodesDataset, edges: edgesDataset },
|
|
options
|
|
);
|
|
|
|
// Click handler — sends data to Rust
|
|
network.on("click", function (params) {
|
|
if (params.nodes.length > 0) {
|
|
var nodeId = params.nodes[0];
|
|
var visNode = nodesDataset.get(nodeId);
|
|
if (visNode && visNode._raw && window.__onAttackNodeClick) {
|
|
window.__onAttackNodeClick(JSON.stringify(visNode._raw));
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log(
|
|
"[attack-chain-viz] Loaded " + nodes.length + " nodes, " + visEdges.length + " edges"
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Callback placeholder for Rust to set.
|
|
* Called with JSON string of the clicked node's data.
|
|
*/
|
|
window.__onAttackNodeClick = null;
|
|
|
|
/**
|
|
* Fit entire attack chain DAG in view.
|
|
*/
|
|
window.__fitAttackChain = function () {
|
|
if (!network) return;
|
|
network.fit({
|
|
animation: { duration: 400, easingFunction: "easeInOutQuad" },
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Select and focus on a specific node by node_id.
|
|
*/
|
|
window.__highlightAttackNode = function (nodeId) {
|
|
if (!network || !nodesDataset) return;
|
|
|
|
var node = nodesDataset.get(nodeId);
|
|
if (!node) return;
|
|
|
|
network.selectNodes([nodeId]);
|
|
network.focus(nodeId, {
|
|
scale: 1.5,
|
|
animation: { duration: 500, easingFunction: "easeInOutQuad" },
|
|
});
|
|
|
|
// Trigger click callback too
|
|
if (node._raw && window.__onAttackNodeClick) {
|
|
window.__onAttackNodeClick(JSON.stringify(node._raw));
|
|
}
|
|
};
|
|
})();
|