Files
compliance-scanner-agent/compliance-dashboard/assets/attack-chain-viz.js
Sharang Parnerkar af98e3e070 feat: attack chain DAG visualization, report export, and UI polish
- 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>
2026-03-11 20:07:22 +01:00

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));
}
};
})();