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:
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||||||
use axum::extract::{Extension, Path, Query};
|
use axum::extract::{Extension, Path, Query};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::sse::{Event, Sse};
|
use axum::response::sse::{Event, Sse};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
use mongodb::bson::doc;
|
use mongodb::bson::doc;
|
||||||
@@ -550,3 +551,219 @@ pub async fn get_session_findings(
|
|||||||
page: Some(params.page),
|
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<String>,
|
||||||
|
Query(params): Query<ExportParams>,
|
||||||
|
) -> Result<axum::response::Response, (StatusCode, String)> {
|
||||||
|
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<PentestMessage> = 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<AttackChainNode> = 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<DastFinding> = 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ pub fn build_router() -> Router {
|
|||||||
"/api/v1/pentest/sessions/{id}/findings",
|
"/api/v1/pentest/sessions/{id}/findings",
|
||||||
get(handlers::pentest::get_session_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))
|
.route("/api/v1/pentest/stats", get(handlers::pentest::pentest_stats))
|
||||||
// Webhook endpoints (proxied through dashboard)
|
// Webhook endpoints (proxied through dashboard)
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
234
compliance-dashboard/assets/attack-chain-viz.js
Normal file
234
compliance-dashboard/assets/attack-chain-viz.js
Normal 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));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -53,6 +53,7 @@ const MAIN_CSS: Asset = asset!("/assets/main.css");
|
|||||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||||
const VIS_NETWORK_JS: Asset = asset!("/assets/vis-network.min.js");
|
const VIS_NETWORK_JS: Asset = asset!("/assets/vis-network.min.js");
|
||||||
const GRAPH_VIZ_JS: Asset = asset!("/assets/graph-viz.js");
|
const GRAPH_VIZ_JS: Asset = asset!("/assets/graph-viz.js");
|
||||||
|
const ATTACK_CHAIN_VIZ_JS: Asset = asset!("/assets/attack-chain-viz.js");
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> Element {
|
pub fn App() -> Element {
|
||||||
@@ -62,6 +63,7 @@ pub fn App() -> Element {
|
|||||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||||
document::Script { src: VIS_NETWORK_JS }
|
document::Script { src: VIS_NETWORK_JS }
|
||||||
document::Script { src: GRAPH_VIZ_JS }
|
document::Script { src: GRAPH_VIZ_JS }
|
||||||
|
document::Script { src: ATTACK_CHAIN_VIZ_JS }
|
||||||
Router::<Route> {}
|
Router::<Route> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,3 +188,24 @@ pub async fn fetch_pentest_findings(
|
|||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
Ok(body)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use dioxus_free_icons::Icon;
|
|||||||
|
|
||||||
use crate::app::Route;
|
use crate::app::Route;
|
||||||
use crate::infrastructure::pentest::{
|
use crate::infrastructure::pentest::{
|
||||||
fetch_attack_chain, fetch_pentest_findings, fetch_pentest_messages, fetch_pentest_session,
|
export_pentest_report, fetch_attack_chain, fetch_pentest_findings, fetch_pentest_messages,
|
||||||
send_pentest_message,
|
fetch_pentest_session, send_pentest_message,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
@@ -35,6 +35,8 @@ pub fn PentestSessionPage(session_id: String) -> Element {
|
|||||||
let mut input_text = use_signal(String::new);
|
let mut input_text = use_signal(String::new);
|
||||||
let mut sending = use_signal(|| false);
|
let mut sending = use_signal(|| false);
|
||||||
let mut right_tab = use_signal(|| "findings".to_string());
|
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
|
// Auto-poll messages every 3s when session is running
|
||||||
let session_status = {
|
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
|
// Send message handler
|
||||||
let sid_for_send = session_id.clone();
|
let sid_for_send = session_id.clone();
|
||||||
let mut do_send = move || {
|
let mut do_send = move || {
|
||||||
@@ -88,6 +109,76 @@ pub fn PentestSessionPage(session_id: String) -> Element {
|
|||||||
|
|
||||||
let mut do_send_click = do_send.clone();
|
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
|
// Session header info
|
||||||
let target_name = {
|
let target_name = {
|
||||||
let s = session.read();
|
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);",
|
div { style: "display: flex; gap: 8px; align-items: center;",
|
||||||
span {
|
// Export buttons
|
||||||
Icon { icon: BsWrench, width: 14, height: 14 }
|
div { style: "display: flex; gap: 4px;",
|
||||||
" {header_tool_count} tools"
|
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 {
|
div { style: "display: flex; gap: 16px; font-size: 0.85rem; color: var(--text-secondary);",
|
||||||
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
|
span {
|
||||||
" {header_findings_count} findings"
|
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
|
// 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
|
// Left: Chat area
|
||||||
div { class: "card", style: "display: flex; flex-direction: column; overflow: hidden;",
|
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();
|
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" {
|
if msg_type == "tool_call" || msg_type == "tool_result" {
|
||||||
// Tool invocation indicator
|
|
||||||
let tool_icon_style = match tool_status.as_str() {
|
let tool_icon_style = match tool_status.as_str() {
|
||||||
"success" => "color: #16a34a;",
|
"success" => "color: #16a34a;",
|
||||||
"error" => "color: #dc2626;",
|
"error" => "color: #dc2626;",
|
||||||
@@ -236,7 +347,6 @@ pub fn PentestSessionPage(session_id: String) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if role == "user" {
|
} else if role == "user" {
|
||||||
// User message - right aligned
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div {
|
div {
|
||||||
key: "{i}",
|
key: "{i}",
|
||||||
@@ -248,7 +358,6 @@ pub fn PentestSessionPage(session_id: String) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Assistant message - left aligned
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div {
|
div {
|
||||||
key: "{i}",
|
key: "{i}",
|
||||||
@@ -339,90 +448,51 @@ pub fn PentestSessionPage(session_id: String) -> Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tab content
|
// 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" {
|
if *right_tab.read() == "findings" {
|
||||||
// Findings tab
|
// Findings tab
|
||||||
match &*findings.read() {
|
div { style: "padding: 12px; flex: 1; overflow-y: auto;",
|
||||||
Some(Some(data)) => {
|
match &*findings.read() {
|
||||||
let finding_list = &data.data;
|
Some(Some(data)) => {
|
||||||
if finding_list.is_empty() {
|
let finding_list = &data.data;
|
||||||
rsx! {
|
if finding_list.is_empty() {
|
||||||
div { style: "text-align: center; color: var(--text-secondary); padding: 24px;",
|
rsx! {
|
||||||
p { "No findings yet." }
|
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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
}
|
rsx! {
|
||||||
},
|
div { style: "display: flex; flex-direction: column; gap: 8px;",
|
||||||
Some(None) => rsx! { p { style: "color: var(--text-secondary);", "Failed to load findings." } },
|
for finding in finding_list {
|
||||||
None => rsx! { p { style: "color: var(--text-secondary);", "Loading..." } },
|
{
|
||||||
}
|
let title = finding.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled").to_string();
|
||||||
} else {
|
let severity = finding.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
|
||||||
// Attack chain tab
|
let vuln_type = finding.get("vuln_type").and_then(|v| v.as_str()).unwrap_or("-").to_string();
|
||||||
match &*attack_chain.read() {
|
let endpoint = finding.get("endpoint").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
Some(Some(data)) => {
|
let exploitable = finding.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
let steps = &data.data;
|
let sev_style = match severity.as_str() {
|
||||||
if steps.is_empty() {
|
"critical" => "background: #dc2626; color: #fff;",
|
||||||
rsx! {
|
"high" => "background: #ea580c; color: #fff;",
|
||||||
div { style: "text-align: center; color: var(--text-secondary); padding: 24px;",
|
"medium" => "background: #d97706; color: #fff;",
|
||||||
p { "No attack chain steps yet." }
|
"low" => "background: #2563eb; color: #fff;",
|
||||||
}
|
_ => "background: var(--bg-tertiary); color: var(--text-secondary);",
|
||||||
}
|
};
|
||||||
} else {
|
rsx! {
|
||||||
rsx! {
|
div { style: "padding: 10px; background: var(--bg-tertiary); border-radius: 8px;",
|
||||||
div { style: "display: flex; flex-direction: column; gap: 4px;",
|
div { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;",
|
||||||
for (i, step) in steps.iter().enumerate() {
|
span { style: "font-weight: 600; font-size: 0.85rem;", "{title}" }
|
||||||
{
|
div { style: "display: flex; gap: 4px;",
|
||||||
let step_name = step.get("name").and_then(|v| v.as_str()).unwrap_or("Step").to_string();
|
if exploitable {
|
||||||
let step_status = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending").to_string();
|
span { class: "badge", style: "background: #dc2626; color: #fff; font-size: 0.7rem;", "Exploitable" }
|
||||||
let description = step.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
}
|
||||||
let step_num = i + 1;
|
span { class: "badge", style: "{sev_style}", "{severity}" }
|
||||||
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;" }
|
|
||||||
}
|
}
|
||||||
}
|
div { style: "font-size: 0.8rem; color: var(--text-secondary);", "{vuln_type}" }
|
||||||
div {
|
if !endpoint.is_empty() {
|
||||||
div { style: "font-size: 0.85rem; font-weight: 600;", "{step_num}. {step_name}" }
|
div { style: "font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; margin-top: 2px;",
|
||||||
if !description.is_empty() {
|
"{endpoint}"
|
||||||
div { style: "font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px;",
|
|
||||||
"{description}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;",
|
||||||
}
|
}
|
||||||
},
|
match &*attack_chain.read() {
|
||||||
Some(None) => rsx! { p { style: "color: var(--text-secondary);", "Failed to load attack chain." } },
|
Some(Some(data)) if data.data.is_empty() => rsx! {
|
||||||
None => rsx! { p { style: "color: var(--text-secondary);", "Loading..." } },
|
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..." } },
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user