From af98e3e0700706ad2b60f6c805fd06a3e648d8bb Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 11 Mar 2026 20:07:22 +0100 Subject: [PATCH] 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 --- compliance-agent/src/api/handlers/pentest.rs | 217 ++++++++++ compliance-agent/src/api/routes.rs | 4 + .../assets/attack-chain-viz.js | 234 +++++++++++ compliance-dashboard/src/app.rs | 2 + .../src/infrastructure/pentest.rs | 21 + .../src/pages/pentest_session.rs | 370 +++++++++++++----- 6 files changed, 753 insertions(+), 95 deletions(-) create mode 100644 compliance-dashboard/assets/attack-chain-viz.js diff --git a/compliance-agent/src/api/handlers/pentest.rs b/compliance-agent/src/api/handlers/pentest.rs index 020c2e6..ea25c09 100644 --- a/compliance-agent/src/api/handlers/pentest.rs +++ b/compliance-agent/src/api/handlers/pentest.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use axum::extract::{Extension, Path, Query}; use axum::http::StatusCode; use axum::response::sse::{Event, Sse}; +use axum::response::IntoResponse; use axum::Json; use futures_util::stream; use mongodb::bson::doc; @@ -550,3 +551,219 @@ pub async fn get_session_findings( page: Some(params.page), })) } + +#[derive(Deserialize)] +pub struct ExportParams { + #[serde(default = "default_export_format")] + pub format: String, +} + +fn default_export_format() -> String { + "json".to_string() +} + +/// GET /api/v1/pentest/sessions/:id/export?format=json|markdown — Export a session report +#[tracing::instrument(skip_all, fields(session_id = %id))] +pub async fn export_session_report( + Extension(agent): AgentExt, + Path(id): Path, + Query(params): Query, +) -> Result { + let oid = mongodb::bson::oid::ObjectId::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?; + + // Fetch session + let session = agent + .db + .pentest_sessions() + .find_one(doc! { "_id": oid }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {e}"), + ) + })? + .ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found".to_string()))?; + + // Fetch messages + let messages: Vec = match agent + .db + .pentest_messages() + .find(doc! { "session_id": &id }) + .sort(doc! { "created_at": 1 }) + .await + { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + // Fetch attack chain nodes + let nodes: Vec = match agent + .db + .attack_chain_nodes() + .find(doc! { "session_id": &id }) + .sort(doc! { "started_at": 1 }) + .await + { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + // Fetch DAST findings for this session + let findings: Vec = match agent + .db + .dast_findings() + .find(doc! { "session_id": &id }) + .sort(doc! { "created_at": -1 }) + .await + { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + // Compute severity counts + let critical = findings.iter().filter(|f| f.severity.to_string() == "critical").count(); + let high = findings.iter().filter(|f| f.severity.to_string() == "high").count(); + let medium = findings.iter().filter(|f| f.severity.to_string() == "medium").count(); + let low = findings.iter().filter(|f| f.severity.to_string() == "low").count(); + let info = findings.iter().filter(|f| f.severity.to_string() == "info").count(); + + match params.format.as_str() { + "markdown" => { + let mut md = String::new(); + md.push_str("# Penetration Test Report\n\n"); + + // Executive summary + md.push_str("## Executive Summary\n\n"); + md.push_str(&format!("| Field | Value |\n")); + md.push_str("| --- | --- |\n"); + md.push_str(&format!("| **Session ID** | {} |\n", id)); + md.push_str(&format!("| **Status** | {} |\n", session.status)); + md.push_str(&format!("| **Strategy** | {} |\n", session.strategy)); + md.push_str(&format!("| **Target ID** | {} |\n", session.target_id)); + md.push_str(&format!( + "| **Started** | {} |\n", + session.started_at.to_rfc3339() + )); + if let Some(ref completed) = session.completed_at { + md.push_str(&format!( + "| **Completed** | {} |\n", + completed.to_rfc3339() + )); + } + md.push_str(&format!( + "| **Tool Invocations** | {} |\n", + session.tool_invocations + )); + md.push_str(&format!( + "| **Success Rate** | {:.1}% |\n", + session.success_rate() + )); + md.push('\n'); + + // Findings by severity + md.push_str("## Findings Summary\n\n"); + md.push_str(&format!( + "| Severity | Count |\n| --- | --- |\n| Critical | {} |\n| High | {} |\n| Medium | {} |\n| Low | {} |\n| Info | {} |\n| **Total** | **{}** |\n\n", + critical, high, medium, low, info, findings.len() + )); + + // Findings table + if !findings.is_empty() { + md.push_str("## Findings Detail\n\n"); + md.push_str("| # | Severity | Title | Endpoint | Exploitable |\n"); + md.push_str("| --- | --- | --- | --- | --- |\n"); + for (i, f) in findings.iter().enumerate() { + md.push_str(&format!( + "| {} | {} | {} | {} {} | {} |\n", + i + 1, + f.severity, + f.title, + f.method, + f.endpoint, + if f.exploitable { "Yes" } else { "No" }, + )); + } + md.push('\n'); + } + + // Attack chain timeline + if !nodes.is_empty() { + md.push_str("## Attack Chain Timeline\n\n"); + md.push_str("| # | Tool | Status | Findings | Reasoning |\n"); + md.push_str("| --- | --- | --- | --- | --- |\n"); + for (i, node) in nodes.iter().enumerate() { + let reasoning_short = if node.llm_reasoning.len() > 80 { + format!("{}...", &node.llm_reasoning[..80]) + } else { + node.llm_reasoning.clone() + }; + md.push_str(&format!( + "| {} | {} | {} | {} | {} |\n", + i + 1, + node.tool_name, + format!("{:?}", node.status).to_lowercase(), + node.findings_produced.len(), + reasoning_short, + )); + } + md.push('\n'); + } + + // Statistics + md.push_str("## Statistics\n\n"); + md.push_str(&format!("- **Total Findings:** {}\n", findings.len())); + md.push_str(&format!("- **Exploitable Findings:** {}\n", session.exploitable_count)); + md.push_str(&format!("- **Attack Chain Steps:** {}\n", nodes.len())); + md.push_str(&format!("- **Messages Exchanged:** {}\n", messages.len())); + md.push_str(&format!("- **Tool Invocations:** {}\n", session.tool_invocations)); + md.push_str(&format!("- **Tool Success Rate:** {:.1}%\n", session.success_rate())); + + Ok(( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, "text/markdown; charset=utf-8"), + ], + md, + ) + .into_response()) + } + _ => { + // JSON format + let report = serde_json::json!({ + "session": { + "id": id, + "target_id": session.target_id, + "repo_id": session.repo_id, + "status": session.status, + "strategy": session.strategy, + "started_at": session.started_at.to_rfc3339(), + "completed_at": session.completed_at.map(|d| d.to_rfc3339()), + "tool_invocations": session.tool_invocations, + "tool_successes": session.tool_successes, + "success_rate": session.success_rate(), + "findings_count": session.findings_count, + "exploitable_count": session.exploitable_count, + }, + "findings": findings, + "attack_chain": nodes, + "messages": messages, + "summary": { + "total_findings": findings.len(), + "severity_distribution": { + "critical": critical, + "high": high, + "medium": medium, + "low": low, + "info": info, + }, + "attack_chain_steps": nodes.len(), + "messages_exchanged": messages.len(), + }, + }); + + Ok(Json(report).into_response()) + } + } +} diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index 4c755e1..b805b7e 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -128,6 +128,10 @@ pub fn build_router() -> Router { "/api/v1/pentest/sessions/{id}/findings", get(handlers::pentest::get_session_findings), ) + .route( + "/api/v1/pentest/sessions/{id}/export", + get(handlers::pentest::export_session_report), + ) .route("/api/v1/pentest/stats", get(handlers::pentest::pentest_stats)) // Webhook endpoints (proxied through dashboard) .route( diff --git a/compliance-dashboard/assets/attack-chain-viz.js b/compliance-dashboard/assets/attack-chain-viz.js new file mode 100644 index 0000000..f2d9edf --- /dev/null +++ b/compliance-dashboard/assets/attack-chain-viz.js @@ -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)); + } + }; +})(); diff --git a/compliance-dashboard/src/app.rs b/compliance-dashboard/src/app.rs index e64bd68..b87fc35 100644 --- a/compliance-dashboard/src/app.rs +++ b/compliance-dashboard/src/app.rs @@ -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:: {} } } diff --git a/compliance-dashboard/src/infrastructure/pentest.rs b/compliance-dashboard/src/infrastructure/pentest.rs index 6546eb3..3dd5436 100644 --- a/compliance-dashboard/src/infrastructure/pentest.rs +++ b/compliance-dashboard/src/infrastructure/pentest.rs @@ -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 { + 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) +} diff --git a/compliance-dashboard/src/pages/pentest_session.rs b/compliance-dashboard/src/pages/pentest_session.rs index 3cf99d1..f2bd7b5 100644 --- a/compliance-dashboard/src/pages/pentest_session.rs +++ b/compliance-dashboard/src/pages/pentest_session.rs @@ -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..." } }, + } + } } } }