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>
This commit is contained in:
Sharang Parnerkar
2026-03-11 20:07:22 +01:00
parent ad9036e5ad
commit 25da8c7268
6 changed files with 753 additions and 95 deletions

View File

@@ -0,0 +1,234 @@
// ═══════════════════════════════════════════════════════════════
// 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));
}
};
})();

View File

@@ -53,6 +53,7 @@ const MAIN_CSS: Asset = asset!("/assets/main.css");
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
const VIS_NETWORK_JS: Asset = asset!("/assets/vis-network.min.js");
const GRAPH_VIZ_JS: Asset = asset!("/assets/graph-viz.js");
const ATTACK_CHAIN_VIZ_JS: Asset = asset!("/assets/attack-chain-viz.js");
#[component]
pub fn App() -> Element {
@@ -62,6 +63,7 @@ pub fn App() -> Element {
document::Link { rel: "stylesheet", href: MAIN_CSS }
document::Script { src: VIS_NETWORK_JS }
document::Script { src: GRAPH_VIZ_JS }
document::Script { src: ATTACK_CHAIN_VIZ_JS }
Router::<Route> {}
}
}

View File

@@ -188,3 +188,24 @@ pub async fn fetch_pentest_findings(
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
#[server]
pub async fn export_pentest_report(
session_id: String,
format: String,
) -> Result<String, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/pentest/sessions/{session_id}/export?format={format}",
state.agent_api_url
);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body = resp
.text()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}

View File

@@ -4,8 +4,8 @@ use dioxus_free_icons::Icon;
use crate::app::Route;
use crate::infrastructure::pentest::{
fetch_attack_chain, fetch_pentest_findings, fetch_pentest_messages, fetch_pentest_session,
send_pentest_message,
export_pentest_report, fetch_attack_chain, fetch_pentest_findings, fetch_pentest_messages,
fetch_pentest_session, send_pentest_message,
};
#[component]
@@ -35,6 +35,8 @@ pub fn PentestSessionPage(session_id: String) -> Element {
let mut input_text = use_signal(String::new);
let mut sending = use_signal(|| false);
let mut right_tab = use_signal(|| "findings".to_string());
let mut chain_view = use_signal(|| "graph".to_string()); // "graph" or "list"
let mut exporting = use_signal(|| false);
// Auto-poll messages every 3s when session is running
let session_status = {
@@ -69,6 +71,25 @@ pub fn PentestSessionPage(session_id: String) -> Element {
}
});
// Load attack chain into vis-network when data changes and graph tab is active
let chain_data_for_viz = attack_chain.read().clone();
let current_tab = right_tab.read().clone();
let current_chain_view = chain_view.read().clone();
use_effect(move || {
if current_tab == "chain" && current_chain_view == "graph" {
if let Some(Some(data)) = &chain_data_for_viz {
if !data.data.is_empty() {
let nodes_json =
serde_json::to_string(&data.data).unwrap_or_else(|_| "[]".to_string());
let js = format!(
r#"if (window.__loadAttackChain) {{ window.__loadAttackChain({nodes_json}); }}"#
);
document::eval(&js);
}
}
}
});
// Send message handler
let sid_for_send = session_id.clone();
let mut do_send = move || {
@@ -88,6 +109,76 @@ pub fn PentestSessionPage(session_id: String) -> Element {
let mut do_send_click = do_send.clone();
// Export handler
let sid_for_export = session_id.clone();
let do_export_md = move |_| {
let sid = sid_for_export.clone();
exporting.set(true);
spawn(async move {
match export_pentest_report(sid.clone(), "markdown".to_string()).await {
Ok(content) => {
// Trigger download via JS
let escaped = content
.replace('\\', "\\\\")
.replace('`', "\\`")
.replace("${", "\\${");
let js = format!(
r#"
var blob = new Blob([`{escaped}`], {{ type: 'text/markdown' }});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'pentest-report-{sid}.md';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
"#
);
document::eval(&js);
}
Err(e) => {
tracing::warn!("Export failed: {e}");
}
}
exporting.set(false);
});
};
let sid_for_export_json = session_id.clone();
let do_export_json = move |_| {
let sid = sid_for_export_json.clone();
exporting.set(true);
spawn(async move {
match export_pentest_report(sid.clone(), "json".to_string()).await {
Ok(content) => {
let escaped = content
.replace('\\', "\\\\")
.replace('`', "\\`")
.replace("${", "\\${");
let js = format!(
r#"
var blob = new Blob([`{escaped}`], {{ type: 'application/json' }});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'pentest-report-{sid}.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
"#
);
document::eval(&js);
}
Err(e) => {
tracing::warn!("Export failed: {e}");
}
}
exporting.set(false);
});
};
// Session header info
let target_name = {
let s = session.read();
@@ -164,20 +255,41 @@ pub fn PentestSessionPage(session_id: String) -> Element {
}
}
}
div { style: "display: flex; gap: 16px; font-size: 0.85rem; color: var(--text-secondary);",
span {
Icon { icon: BsWrench, width: 14, height: 14 }
" {header_tool_count} tools"
div { style: "display: flex; gap: 8px; align-items: center;",
// Export buttons
div { style: "display: flex; gap: 4px;",
button {
class: "btn btn-ghost",
style: "font-size: 0.8rem; padding: 4px 10px;",
disabled: *exporting.read(),
onclick: do_export_md,
Icon { icon: BsFileEarmarkText, width: 12, height: 12 }
" Export MD"
}
button {
class: "btn btn-ghost",
style: "font-size: 0.8rem; padding: 4px 10px;",
disabled: *exporting.read(),
onclick: do_export_json,
Icon { icon: BsFiletypeJson, width: 12, height: 12 }
" Export JSON"
}
}
span {
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
" {header_findings_count} findings"
div { style: "display: flex; gap: 16px; font-size: 0.85rem; color: var(--text-secondary);",
span {
Icon { icon: BsWrench, width: 14, height: 14 }
" {header_tool_count} tools"
}
span {
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
" {header_findings_count} findings"
}
}
}
}
// Split layout: chat left, findings/chain right
div { style: "display: grid; grid-template-columns: 1fr 380px; gap: 16px; height: calc(100vh - 220px); min-height: 400px;",
div { style: "display: grid; grid-template-columns: 1fr 420px; gap: 16px; height: calc(100vh - 220px); min-height: 400px;",
// Left: Chat area
div { class: "card", style: "display: flex; flex-direction: column; overflow: hidden;",
@@ -207,7 +319,6 @@ pub fn PentestSessionPage(session_id: String) -> Element {
let tool_status = msg.get("tool_status").and_then(|v| v.as_str()).unwrap_or("").to_string();
if msg_type == "tool_call" || msg_type == "tool_result" {
// Tool invocation indicator
let tool_icon_style = match tool_status.as_str() {
"success" => "color: #16a34a;",
"error" => "color: #dc2626;",
@@ -236,7 +347,6 @@ pub fn PentestSessionPage(session_id: String) -> Element {
}
}
} else if role == "user" {
// User message - right aligned
rsx! {
div {
key: "{i}",
@@ -248,7 +358,6 @@ pub fn PentestSessionPage(session_id: String) -> Element {
}
}
} else {
// Assistant message - left aligned
rsx! {
div {
key: "{i}",
@@ -339,90 +448,51 @@ pub fn PentestSessionPage(session_id: String) -> Element {
}
// Tab content
div { style: "flex: 1; overflow-y: auto; padding: 12px;",
div { style: "flex: 1; overflow-y: auto; display: flex; flex-direction: column;",
if *right_tab.read() == "findings" {
// Findings tab
match &*findings.read() {
Some(Some(data)) => {
let finding_list = &data.data;
if finding_list.is_empty() {
rsx! {
div { style: "text-align: center; color: var(--text-secondary); padding: 24px;",
p { "No findings yet." }
}
}
} else {
rsx! {
div { style: "display: flex; flex-direction: column; gap: 8px;",
for finding in finding_list {
{
let title = finding.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled").to_string();
let severity = finding.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
let vuln_type = finding.get("vulnerability_type").and_then(|v| v.as_str()).unwrap_or("-").to_string();
let sev_style = match severity.as_str() {
"critical" => "background: #dc2626; color: #fff;",
"high" => "background: #ea580c; color: #fff;",
"medium" => "background: #d97706; color: #fff;",
"low" => "background: #2563eb; color: #fff;",
_ => "background: var(--bg-tertiary); color: var(--text-secondary);",
};
rsx! {
div { style: "padding: 10px; background: var(--bg-tertiary); border-radius: 8px;",
div { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;",
span { style: "font-weight: 600; font-size: 0.85rem;", "{title}" }
span { class: "badge", style: "{sev_style}", "{severity}" }
}
div { style: "font-size: 0.8rem; color: var(--text-secondary);", "{vuln_type}" }
}
}
}
div { style: "padding: 12px; flex: 1; overflow-y: auto;",
match &*findings.read() {
Some(Some(data)) => {
let finding_list = &data.data;
if finding_list.is_empty() {
rsx! {
div { style: "text-align: center; color: var(--text-secondary); padding: 24px;",
p { "No findings yet." }
}
}
}
}
},
Some(None) => rsx! { p { style: "color: var(--text-secondary);", "Failed to load findings." } },
None => rsx! { p { style: "color: var(--text-secondary);", "Loading..." } },
}
} else {
// Attack chain tab
match &*attack_chain.read() {
Some(Some(data)) => {
let steps = &data.data;
if steps.is_empty() {
rsx! {
div { style: "text-align: center; color: var(--text-secondary); padding: 24px;",
p { "No attack chain steps yet." }
}
}
} else {
rsx! {
div { style: "display: flex; flex-direction: column; gap: 4px;",
for (i, step) in steps.iter().enumerate() {
{
let step_name = step.get("name").and_then(|v| v.as_str()).unwrap_or("Step").to_string();
let step_status = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending").to_string();
let description = step.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
let step_num = i + 1;
let dot_color = match step_status.as_str() {
"completed" => "#16a34a",
"running" => "#d97706",
"failed" => "#dc2626",
_ => "var(--text-secondary)",
};
rsx! {
div { style: "display: flex; gap: 10px; padding: 8px 0;",
div { style: "display: flex; flex-direction: column; align-items: center;",
div { style: "width: 10px; height: 10px; border-radius: 50%; background: {dot_color}; flex-shrink: 0;" }
if i < steps.len() - 1 {
div { style: "width: 2px; flex: 1; background: var(--border-color); margin-top: 4px;" }
} else {
rsx! {
div { style: "display: flex; flex-direction: column; gap: 8px;",
for finding in finding_list {
{
let title = finding.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled").to_string();
let severity = finding.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
let vuln_type = finding.get("vuln_type").and_then(|v| v.as_str()).unwrap_or("-").to_string();
let endpoint = finding.get("endpoint").and_then(|v| v.as_str()).unwrap_or("").to_string();
let exploitable = finding.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false);
let sev_style = match severity.as_str() {
"critical" => "background: #dc2626; color: #fff;",
"high" => "background: #ea580c; color: #fff;",
"medium" => "background: #d97706; color: #fff;",
"low" => "background: #2563eb; color: #fff;",
_ => "background: var(--bg-tertiary); color: var(--text-secondary);",
};
rsx! {
div { style: "padding: 10px; background: var(--bg-tertiary); border-radius: 8px;",
div { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;",
span { style: "font-weight: 600; font-size: 0.85rem;", "{title}" }
div { style: "display: flex; gap: 4px;",
if exploitable {
span { class: "badge", style: "background: #dc2626; color: #fff; font-size: 0.7rem;", "Exploitable" }
}
span { class: "badge", style: "{sev_style}", "{severity}" }
}
}
}
div {
div { style: "font-size: 0.85rem; font-weight: 600;", "{step_num}. {step_name}" }
if !description.is_empty() {
div { style: "font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px;",
"{description}"
div { style: "font-size: 0.8rem; color: var(--text-secondary);", "{vuln_type}" }
if !endpoint.is_empty() {
div { style: "font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; margin-top: 2px;",
"{endpoint}"
}
}
}
@@ -432,10 +502,120 @@ pub fn PentestSessionPage(session_id: String) -> Element {
}
}
}
},
Some(None) => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Failed to load findings." } },
None => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Loading..." } },
}
}
} else {
// Attack chain tab — graph/list toggle
div { style: "display: flex; gap: 4px; padding: 8px 12px; flex-shrink: 0;",
button {
class: if *chain_view.read() == "graph" { "btn btn-primary" } else { "btn btn-ghost" },
style: "font-size: 0.75rem; padding: 3px 8px;",
onclick: move |_| chain_view.set("graph".to_string()),
Icon { icon: BsDiagram3, width: 12, height: 12 }
" Graph"
}
button {
class: if *chain_view.read() == "list" { "btn btn-primary" } else { "btn btn-ghost" },
style: "font-size: 0.75rem; padding: 3px 8px;",
onclick: move |_| chain_view.set("list".to_string()),
Icon { icon: BsListOl, width: 12, height: 12 }
" List"
}
}
if *chain_view.read() == "graph" {
// Interactive DAG visualization
div { style: "flex: 1; position: relative; min-height: 300px;",
div {
id: "attack-chain-canvas",
style: "width: 100%; height: 100%; position: absolute; inset: 0;",
}
},
Some(None) => rsx! { p { style: "color: var(--text-secondary);", "Failed to load attack chain." } },
None => rsx! { p { style: "color: var(--text-secondary);", "Loading..." } },
match &*attack_chain.read() {
Some(Some(data)) if data.data.is_empty() => rsx! {
div { style: "position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--text-secondary);",
p { "No attack chain steps yet." }
}
},
_ => rsx! {},
}
}
} else {
// List view
div { style: "flex: 1; overflow-y: auto; padding: 0 12px 12px;",
match &*attack_chain.read() {
Some(Some(data)) => {
let steps = &data.data;
if steps.is_empty() {
rsx! {
div { style: "text-align: center; color: var(--text-secondary); padding: 24px;",
p { "No attack chain steps yet." }
}
}
} else {
rsx! {
div { style: "display: flex; flex-direction: column; gap: 4px;",
for (i, step) in steps.iter().enumerate() {
{
let tool_name = step.get("tool_name").and_then(|v| v.as_str()).unwrap_or("Step").to_string();
let step_status = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending").to_string();
let reasoning = step.get("llm_reasoning").and_then(|v| v.as_str()).unwrap_or("").to_string();
let findings_count = step.get("findings_produced").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
let risk_score = step.get("risk_score").and_then(|v| v.as_u64());
let step_num = i + 1;
let dot_color = match step_status.as_str() {
"completed" => "#16a34a",
"running" => "#d97706",
"failed" => "#dc2626",
"skipped" => "#374151",
_ => "var(--text-secondary)",
};
rsx! {
div { style: "display: flex; gap: 10px; padding: 8px 0;",
div { style: "display: flex; flex-direction: column; align-items: center;",
div { style: "width: 10px; height: 10px; border-radius: 50%; background: {dot_color}; flex-shrink: 0;" }
if i < steps.len() - 1 {
div { style: "width: 2px; flex: 1; background: var(--border-color); margin-top: 4px;" }
}
}
div { style: "flex: 1; min-width: 0;",
div { style: "display: flex; justify-content: space-between; align-items: center;",
span { style: "font-size: 0.85rem; font-weight: 600;",
"{step_num}. {tool_name}"
}
div { style: "display: flex; gap: 4px;",
if findings_count > 0 {
span { class: "badge", style: "font-size: 0.65rem; background: #dc2626; color: #fff;",
"{findings_count} findings"
}
}
if let Some(score) = risk_score {
span { class: "badge", style: "font-size: 0.65rem;",
"risk: {score}"
}
}
}
}
if !reasoning.is_empty() {
div { style: "font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;",
"{reasoning}"
}
}
}
}
}
}
}
}
}
}
},
Some(None) => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Failed to load attack chain." } },
None => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Loading..." } },
}
}
}
}
}