feat: pure Dioxus attack chain visualization, PDF report redesign, and orchestrator data fixes
Some checks failed
CI / Format (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Security Audit (pull_request) Has been cancelled
CI / Tests (pull_request) Has been cancelled
CI / Detect Changes (pull_request) Has been cancelled
CI / Deploy Agent (pull_request) Has been cancelled
CI / Deploy Dashboard (pull_request) Has been cancelled
CI / Deploy Docs (pull_request) Has been cancelled
CI / Deploy MCP (pull_request) Has been cancelled

- Replace vis-network JS graph with pure RSX attack chain component
  featuring KPI header, phase rail, expandable accordion with tool
  category chips, risk scores, and findings pills
- Redesign pentest report as professional PDF-first document with
  cover page, table of contents, severity bar chart, phased attack
  chain timeline, and print-friendly light theme
- Fix orchestrator to populate findings_produced, risk_score, and
  llm_reasoning on attack chain nodes
- Capture LLM reasoning text alongside tool calls in LlmResponse enum
- Add session-level KPI fallback for older pentest data
- Remove attack-chain-viz.js and prototype files
- Add encrypted ZIP report export endpoint with password protection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-12 15:21:20 +01:00
parent 1e91277040
commit 9f495e5215
19 changed files with 3693 additions and 1164 deletions

View File

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

@@ -2767,3 +2767,467 @@ tbody tr:last-child td {
.sbom-diff-row-changed {
border-left: 3px solid var(--warning);
}
/* ═══════════════════════════════════
ATTACK CHAIN VISUALIZATION
═══════════════════════════════════ */
/* KPI bar */
.ac-kpi-bar {
display: flex;
gap: 2px;
margin-bottom: 16px;
}
.ac-kpi-card {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
padding: 12px 14px;
position: relative;
overflow: hidden;
}
.ac-kpi-card:first-child { border-radius: 10px 0 0 10px; }
.ac-kpi-card:last-child { border-radius: 0 10px 10px 0; }
.ac-kpi-card::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
}
.ac-kpi-card:nth-child(1)::before { background: var(--accent, #3b82f6); opacity: 0.4; }
.ac-kpi-card:nth-child(2)::before { background: var(--danger, #dc2626); opacity: 0.5; }
.ac-kpi-card:nth-child(3)::before { background: var(--success, #16a34a); opacity: 0.4; }
.ac-kpi-card:nth-child(4)::before { background: var(--warning, #d97706); opacity: 0.4; }
.ac-kpi-value {
font-family: var(--font-display);
font-size: 24px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.03em;
}
.ac-kpi-label {
font-family: var(--font-mono, monospace);
font-size: 9px;
color: var(--text-tertiary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-top: 4px;
}
/* Phase progress rail */
.ac-phase-rail {
display: flex;
align-items: flex-start;
margin-bottom: 14px;
position: relative;
padding: 0 8px;
}
.ac-phase-rail::before {
content: '';
position: absolute;
top: 7px;
left: 8px;
right: 8px;
height: 2px;
background: var(--border-color);
z-index: 0;
}
.ac-rail-node {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
cursor: pointer;
min-width: 56px;
flex: 1;
transition: all 0.15s;
}
.ac-rail-node:hover .ac-rail-dot { transform: scale(1.25); }
.ac-rail-node.active .ac-rail-label { color: var(--accent, #3b82f6); }
.ac-rail-node.active .ac-rail-dot { box-shadow: 0 0 0 3px rgba(59,130,246,0.2), 0 0 12px rgba(59,130,246,0.15); }
.ac-rail-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2.5px solid var(--bg-primary, #0f172a);
transition: transform 0.2s cubic-bezier(0.16,1,0.3,1);
flex-shrink: 0;
}
.ac-rail-dot.done { background: var(--success, #16a34a); box-shadow: 0 0 8px rgba(22,163,74,0.25); }
.ac-rail-dot.running { background: var(--warning, #d97706); box-shadow: 0 0 10px rgba(217,119,6,0.35); animation: ac-dot-pulse 2s ease-in-out infinite; }
.ac-rail-dot.pending { background: var(--text-tertiary, #6b7280); opacity: 0.5; }
.ac-rail-dot.mixed { background: conic-gradient(var(--success, #16a34a) 0deg 270deg, var(--danger, #dc2626) 270deg 360deg); box-shadow: 0 0 8px rgba(22,163,74,0.2); }
@keyframes ac-dot-pulse {
0%, 100% { box-shadow: 0 0 8px rgba(217,119,6,0.35); }
50% { box-shadow: 0 0 18px rgba(217,119,6,0.55); }
}
.ac-rail-label {
font-family: var(--font-mono, monospace);
font-size: 9px;
color: var(--text-tertiary, #6b7280);
margin-top: 5px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
transition: color 0.15s;
}
.ac-rail-findings {
font-family: var(--font-mono, monospace);
font-size: 9px;
font-weight: 600;
margin-top: 1px;
}
.ac-rail-findings.has { color: var(--danger, #dc2626); }
.ac-rail-findings.none { color: var(--text-tertiary, #6b7280); opacity: 0.4; }
.ac-rail-heatmap {
display: flex;
gap: 2px;
margin-top: 3px;
}
.ac-hm-cell {
width: 7px;
height: 7px;
border-radius: 1.5px;
}
.ac-hm-cell.ok { background: var(--success, #16a34a); opacity: 0.5; }
.ac-hm-cell.fail { background: var(--danger, #dc2626); opacity: 0.65; }
.ac-hm-cell.run { background: var(--warning, #d97706); opacity: 0.5; animation: ac-pulse 1.5s ease-in-out infinite; }
.ac-hm-cell.wait { background: var(--text-tertiary, #6b7280); opacity: 0.15; }
.ac-rail-bar {
flex: 1;
height: 2px;
margin-top: 7px;
position: relative;
z-index: 1;
}
.ac-rail-bar-inner {
height: 100%;
border-radius: 1px;
}
.ac-rail-bar-inner.done { background: var(--success, #16a34a); opacity: 0.35; }
.ac-rail-bar-inner.running { background: linear-gradient(to right, var(--success, #16a34a), var(--warning, #d97706)); opacity: 0.35; }
/* Progress track */
.ac-progress-track {
height: 3px;
background: var(--border-color);
border-radius: 2px;
overflow: hidden;
margin-bottom: 10px;
}
.ac-progress-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--success, #16a34a) 0%, var(--accent, #3b82f6) 100%);
transition: width 0.6s cubic-bezier(0.16,1,0.3,1);
}
/* Expand all controls */
.ac-controls {
display: flex;
justify-content: flex-end;
margin-bottom: 6px;
}
.ac-btn-toggle {
font-family: var(--font-body);
font-size: 11px;
color: var(--accent, #3b82f6);
background: none;
border: 1px solid transparent;
cursor: pointer;
padding: 3px 10px;
border-radius: 4px;
transition: all 0.15s;
}
.ac-btn-toggle:hover {
background: rgba(59,130,246,0.08);
border-color: rgba(59,130,246,0.12);
}
/* Phase accordion */
.ac-phases {
display: flex;
flex-direction: column;
gap: 2px;
}
.ac-phase {
animation: ac-phase-in 0.35s cubic-bezier(0.16,1,0.3,1) both;
}
@keyframes ac-phase-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.ac-phase-header {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.ac-phase.open .ac-phase-header {
border-radius: 10px 10px 0 0;
}
.ac-phase-header:hover {
background: var(--bg-tertiary);
}
.ac-phase-num {
font-family: var(--font-mono, monospace);
font-size: 10px;
font-weight: 600;
color: var(--accent, #3b82f6);
background: rgba(59,130,246,0.08);
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 0.04em;
white-space: nowrap;
border: 1px solid rgba(59,130,246,0.1);
}
.ac-phase-title {
font-family: var(--font-display);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
flex: 1;
}
.ac-phase-dots {
display: flex;
gap: 3px;
align-items: center;
}
.ac-phase-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.ac-phase-dot.completed { background: var(--success, #16a34a); }
.ac-phase-dot.failed { background: var(--danger, #dc2626); }
.ac-phase-dot.running { background: var(--warning, #d97706); animation: ac-pulse 1.5s ease-in-out infinite; }
.ac-phase-dot.pending { background: var(--text-tertiary, #6b7280); opacity: 0.4; }
@keyframes ac-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.ac-phase-meta {
display: flex;
align-items: center;
gap: 12px;
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--text-secondary);
}
.ac-phase-meta .findings-ct { color: var(--danger, #dc2626); font-weight: 600; }
.ac-phase-meta .running-ct { color: var(--warning, #d97706); font-weight: 500; }
.ac-phase-chevron {
color: var(--text-tertiary, #6b7280);
font-size: 11px;
transition: transform 0.25s cubic-bezier(0.16,1,0.3,1);
width: 14px;
text-align: center;
}
.ac-phase.open .ac-phase-chevron {
transform: rotate(90deg);
}
.ac-phase-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.35s cubic-bezier(0.16,1,0.3,1);
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
border-right: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
border-radius: 0 0 10px 10px;
}
.ac-phase.open .ac-phase-body {
max-height: 2000px;
}
.ac-phase-body-inner {
padding: 4px 6px;
display: flex;
flex-direction: column;
gap: 1px;
}
/* Tool rows */
.ac-tool-row {
display: grid;
grid-template-columns: 5px 26px 1fr auto auto auto;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.12s;
}
.ac-tool-row:hover {
background: rgba(255,255,255,0.02);
}
.ac-tool-row.expanded {
background: rgba(59,130,246,0.03);
}
.ac-tool-row.is-pending {
opacity: 0.45;
cursor: default;
}
.ac-status-bar {
width: 4px;
height: 26px;
border-radius: 2px;
flex-shrink: 0;
}
.ac-status-bar.completed { background: var(--success, #16a34a); }
.ac-status-bar.failed { background: var(--danger, #dc2626); }
.ac-status-bar.running { background: var(--warning, #d97706); animation: ac-pulse 1.5s ease-in-out infinite; }
.ac-status-bar.pending { background: var(--text-tertiary, #6b7280); opacity: 0.25; }
.ac-tool-icon {
font-size: 17px;
text-align: center;
line-height: 1;
}
.ac-tool-info { min-width: 0; }
.ac-tool-name {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Category chips */
.ac-cat-chip {
font-family: var(--font-mono, monospace);
font-size: 9px;
font-weight: 500;
padding: 1px 6px;
border-radius: 3px;
display: inline-block;
letter-spacing: 0.02em;
}
.ac-cat-chip.recon { color: #38bdf8; background: rgba(56,189,248,0.1); }
.ac-cat-chip.api { color: #818cf8; background: rgba(129,140,248,0.1); }
.ac-cat-chip.headers { color: #06b6d4; background: rgba(6,182,212,0.1); }
.ac-cat-chip.csp { color: #d946ef; background: rgba(217,70,239,0.1); }
.ac-cat-chip.cookies { color: #f59e0b; background: rgba(245,158,11,0.1); }
.ac-cat-chip.logs { color: #78716c; background: rgba(120,113,108,0.1); }
.ac-cat-chip.ratelimit { color: #64748b; background: rgba(100,116,139,0.1); }
.ac-cat-chip.cors { color: #8b5cf6; background: rgba(139,92,246,0.1); }
.ac-cat-chip.tls { color: #14b8a6; background: rgba(20,184,166,0.1); }
.ac-cat-chip.redirect { color: #fb923c; background: rgba(251,146,60,0.1); }
.ac-cat-chip.email { color: #0ea5e9; background: rgba(14,165,233,0.1); }
.ac-cat-chip.auth { color: #f43f5e; background: rgba(244,63,94,0.1); }
.ac-cat-chip.xss { color: #f97316; background: rgba(249,115,22,0.1); }
.ac-cat-chip.sqli { color: #ef4444; background: rgba(239,68,68,0.1); }
.ac-cat-chip.ssrf { color: #a855f7; background: rgba(168,85,247,0.1); }
.ac-cat-chip.idor { color: #ec4899; background: rgba(236,72,153,0.1); }
.ac-cat-chip.fuzzer { color: #a78bfa; background: rgba(167,139,250,0.1); }
.ac-cat-chip.cve { color: #dc2626; background: rgba(220,38,38,0.1); }
.ac-cat-chip.default { color: #94a3b8; background: rgba(148,163,184,0.1); }
.ac-tool-duration {
font-family: var(--font-mono, monospace);
font-size: 10px;
color: var(--text-tertiary, #6b7280);
white-space: nowrap;
min-width: 48px;
text-align: right;
}
.ac-tool-duration.running-text {
color: var(--warning, #d97706);
font-weight: 500;
}
.ac-findings-pill {
font-family: var(--font-mono, monospace);
font-size: 10px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
padding: 1px 7px;
border-radius: 9px;
line-height: 1.4;
text-align: center;
}
.ac-findings-pill.has { background: rgba(220,38,38,0.12); color: var(--danger, #dc2626); }
.ac-findings-pill.zero { background: transparent; color: var(--text-tertiary, #6b7280); font-weight: 400; opacity: 0.5; }
.ac-risk-val {
font-family: var(--font-mono, monospace);
font-size: 10px;
font-weight: 700;
min-width: 32px;
text-align: right;
}
.ac-risk-val.high { color: var(--danger, #dc2626); }
.ac-risk-val.medium { color: var(--warning, #d97706); }
.ac-risk-val.low { color: var(--text-secondary); }
.ac-risk-val.none { color: transparent; }
/* Tool detail (expanded) */
.ac-tool-detail {
max-height: 0;
overflow: hidden;
transition: max-height 0.28s cubic-bezier(0.16,1,0.3,1);
}
.ac-tool-detail.open {
max-height: 300px;
}
.ac-tool-detail-inner {
padding: 6px 10px 10px 49px;
font-size: 12px;
line-height: 1.55;
color: var(--text-secondary);
}
.ac-reasoning-block {
background: rgba(59,130,246,0.03);
border-left: 2px solid var(--accent, #3b82f6);
padding: 7px 12px;
border-radius: 0 6px 6px 0;
font-style: italic;
margin-bottom: 8px;
color: var(--text-secondary);
}
.ac-detail-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 14px;
font-family: var(--font-mono, monospace);
font-size: 10px;
}
.ac-detail-label {
color: var(--text-tertiary, #6b7280);
text-transform: uppercase;
font-size: 9px;
letter-spacing: 0.04em;
}
.ac-detail-value {
color: var(--text-secondary);
}

View File

@@ -53,8 +53,6 @@ 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 {
rsx! {
@@ -63,7 +61,6 @@ 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

@@ -228,6 +228,23 @@ pub async fn send_pentest_message(
Ok(body)
}
#[server]
pub async fn stop_pentest_session(session_id: String) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/pentest/sessions/{session_id}/stop",
state.agent_api_url
);
let client = reqwest::Client::new();
client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(())
}
#[server]
pub async fn fetch_pentest_findings(
session_id: String,
@@ -248,22 +265,43 @@ pub async fn fetch_pentest_findings(
Ok(body)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExportReportResponse {
pub archive_base64: String,
pub sha256: String,
pub filename: String,
}
#[server]
pub async fn export_pentest_report(
session_id: String,
format: String,
) -> Result<String, ServerFnError> {
password: String,
requester_name: String,
requester_email: String,
) -> Result<ExportReportResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/pentest/sessions/{session_id}/export?format={format}",
"{}/api/v1/pentest/sessions/{session_id}/export",
state.agent_api_url
);
let resp = reqwest::get(&url)
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({
"password": password,
"requester_name": requester_name,
"requester_email": requester_email,
}))
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body = resp
.text()
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!("Export failed: {text}")));
}
let body: ExportReportResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)

View File

@@ -11,6 +11,11 @@ use crate::infrastructure::dast::fetch_dast_findings;
pub fn DastFindingsPage() -> Element {
let findings = use_resource(|| async { fetch_dast_findings().await.ok() });
let mut filter_severity = use_signal(|| "all".to_string());
let mut filter_vuln_type = use_signal(|| "all".to_string());
let mut filter_exploitable = use_signal(|| "all".to_string());
let mut search_text = use_signal(String::new);
rsx! {
div { class: "back-nav",
button {
@@ -26,14 +31,105 @@ pub fn DastFindingsPage() -> Element {
description: "Vulnerabilities discovered through dynamic application security testing",
}
// Filter bar
div { style: "display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; align-items: center;",
// Search
div { style: "flex: 1; min-width: 180px;",
input {
class: "chat-input",
style: "width: 100%; padding: 6px 10px; font-size: 0.85rem;",
placeholder: "Search title or endpoint...",
value: "{search_text}",
oninput: move |e| search_text.set(e.value()),
}
}
// Severity
select {
style: "padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem;",
value: "{filter_severity}",
onchange: move |e| filter_severity.set(e.value()),
option { value: "all", "All Severities" }
option { value: "critical", "Critical" }
option { value: "high", "High" }
option { value: "medium", "Medium" }
option { value: "low", "Low" }
option { value: "info", "Info" }
}
// Vuln type
select {
style: "padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem;",
value: "{filter_vuln_type}",
onchange: move |e| filter_vuln_type.set(e.value()),
option { value: "all", "All Types" }
option { value: "sql_injection", "SQL Injection" }
option { value: "xss", "XSS" }
option { value: "auth_bypass", "Auth Bypass" }
option { value: "ssrf", "SSRF" }
option { value: "api_misconfiguration", "API Misconfiguration" }
option { value: "open_redirect", "Open Redirect" }
option { value: "idor", "IDOR" }
option { value: "information_disclosure", "Information Disclosure" }
option { value: "security_misconfiguration", "Security Misconfiguration" }
option { value: "broken_auth", "Broken Auth" }
option { value: "dns_misconfiguration", "DNS Misconfiguration" }
option { value: "email_security", "Email Security" }
option { value: "tls_misconfiguration", "TLS Misconfiguration" }
option { value: "cookie_security", "Cookie Security" }
option { value: "csp_issue", "CSP Issue" }
option { value: "cors_misconfiguration", "CORS Misconfiguration" }
option { value: "rate_limit_absent", "Rate Limit Absent" }
option { value: "console_log_leakage", "Console Log Leakage" }
option { value: "security_header_missing", "Security Header Missing" }
option { value: "known_cve_exploit", "Known CVE Exploit" }
option { value: "other", "Other" }
}
// Exploitable
select {
style: "padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem;",
value: "{filter_exploitable}",
onchange: move |e| filter_exploitable.set(e.value()),
option { value: "all", "All" }
option { value: "yes", "Exploitable" }
option { value: "no", "Unconfirmed" }
}
}
div { class: "card",
match &*findings.read() {
Some(Some(data)) => {
let finding_list = &data.data;
if finding_list.is_empty() {
rsx! { p { "No DAST findings yet. Run a scan to discover vulnerabilities." } }
let sev_filter = filter_severity.read().clone();
let vt_filter = filter_vuln_type.read().clone();
let exp_filter = filter_exploitable.read().clone();
let search = search_text.read().to_lowercase();
let filtered: Vec<_> = data.data.iter().filter(|f| {
let severity = f.get("severity").and_then(|v| v.as_str()).unwrap_or("info");
let vuln_type = f.get("vuln_type").and_then(|v| v.as_str()).unwrap_or("");
let exploitable = f.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false);
let title = f.get("title").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
let endpoint = f.get("endpoint").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
(sev_filter == "all" || severity == sev_filter)
&& (vt_filter == "all" || vuln_type == vt_filter)
&& match exp_filter.as_str() {
"yes" => exploitable,
"no" => !exploitable,
_ => true,
}
&& (search.is_empty() || title.contains(&search) || endpoint.contains(&search))
}).collect();
if filtered.is_empty() {
if data.data.is_empty() {
rsx! { p { style: "padding: 16px;", "No DAST findings yet. Run a scan to discover vulnerabilities." } }
} else {
rsx! { p { style: "padding: 16px; color: var(--text-secondary);", "No findings match the current filters." } }
}
} else {
rsx! {
div { style: "padding: 8px 16px; font-size: 0.8rem; color: var(--text-secondary);",
"Showing {filtered.len()} of {data.data.len()} findings"
}
table { class: "table",
thead {
tr {
@@ -46,7 +142,7 @@ pub fn DastFindingsPage() -> Element {
}
}
tbody {
for finding in finding_list {
for finding in filtered {
{
let id = finding.get("_id").and_then(|v| v.get("$oid")).and_then(|v| v.as_str()).unwrap_or("").to_string();
let severity = finding.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();

View File

@@ -6,7 +6,7 @@ use crate::app::Route;
use crate::components::page_header::PageHeader;
use crate::infrastructure::dast::fetch_dast_targets;
use crate::infrastructure::pentest::{
create_pentest_session, fetch_pentest_sessions, fetch_pentest_stats,
create_pentest_session, fetch_pentest_sessions, fetch_pentest_stats, stop_pentest_session,
};
#[component]
@@ -86,7 +86,7 @@ pub fn PentestDashboardPage() -> Element {
match &*s {
Some(Some(data)) => data
.data
.get("tool_invocations")
.get("total_tool_invocations")
.and_then(|v| v.as_u64())
.unwrap_or(0),
_ => 0,
@@ -97,58 +97,29 @@ pub fn PentestDashboardPage() -> Element {
match &*s {
Some(Some(data)) => data
.data
.get("success_rate")
.get("tool_success_rate")
.and_then(|v| v.as_f64())
.unwrap_or(0.0),
_ => 0.0,
}
};
// Severity counts from stats
let severity_critical = {
// Severity counts from stats (nested under severity_distribution)
let sev_dist = {
let s = stats.read();
match &*s {
Some(Some(data)) => data
.data
.get("severity_critical")
.and_then(|v| v.as_u64())
.unwrap_or(0),
_ => 0,
}
};
let severity_high = {
let s = stats.read();
match &*s {
Some(Some(data)) => data
.data
.get("severity_high")
.and_then(|v| v.as_u64())
.unwrap_or(0),
_ => 0,
}
};
let severity_medium = {
let s = stats.read();
match &*s {
Some(Some(data)) => data
.data
.get("severity_medium")
.and_then(|v| v.as_u64())
.unwrap_or(0),
_ => 0,
}
};
let severity_low = {
let s = stats.read();
match &*s {
Some(Some(data)) => data
.data
.get("severity_low")
.and_then(|v| v.as_u64())
.unwrap_or(0),
_ => 0,
.get("severity_distribution")
.cloned()
.unwrap_or(serde_json::Value::Null),
_ => serde_json::Value::Null,
}
};
let severity_critical = sev_dist.get("critical").and_then(|v| v.as_u64()).unwrap_or(0);
let severity_high = sev_dist.get("high").and_then(|v| v.as_u64()).unwrap_or(0);
let severity_medium = sev_dist.get("medium").and_then(|v| v.as_u64()).unwrap_or(0);
let severity_low = sev_dist.get("low").and_then(|v| v.as_u64()).unwrap_or(0);
rsx! {
PageHeader {
@@ -259,39 +230,63 @@ pub fn PentestDashboardPage() -> Element {
"paused" => "background: #d97706; color: #fff;",
_ => "background: var(--bg-tertiary); color: var(--text-secondary);",
};
rsx! {
Link {
to: Route::PentestSessionPage { session_id: id.clone() },
class: "card",
style: "padding: 16px; text-decoration: none; cursor: pointer; transition: border-color 0.15s;",
div { style: "display: flex; justify-content: space-between; align-items: flex-start;",
div {
div { style: "font-weight: 600; font-size: 1rem; margin-bottom: 4px; color: var(--text-primary);",
"{target_name}"
}
div { style: "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;",
span {
class: "badge",
style: "{status_style}",
"{status}"
{
let is_session_running = status == "running";
let stop_id = id.clone();
rsx! {
div { class: "card", style: "padding: 16px; transition: border-color 0.15s;",
Link {
to: Route::PentestSessionPage { session_id: id.clone() },
style: "text-decoration: none; cursor: pointer; display: block;",
div { style: "display: flex; justify-content: space-between; align-items: flex-start;",
div {
div { style: "font-weight: 600; font-size: 1rem; margin-bottom: 4px; color: var(--text-primary);",
"{target_name}"
}
div { style: "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;",
span {
class: "badge",
style: "{status_style}",
"{status}"
}
span {
class: "badge",
style: "background: var(--bg-tertiary); color: var(--text-secondary);",
"{strategy}"
}
}
}
span {
class: "badge",
style: "background: var(--bg-tertiary); color: var(--text-secondary);",
"{strategy}"
div { style: "text-align: right; font-size: 0.85rem; color: var(--text-secondary);",
div { style: "margin-bottom: 4px;",
Icon { icon: BsShieldExclamation, width: 12, height: 12 }
" {findings_count} findings"
}
div { style: "margin-bottom: 4px;",
Icon { icon: BsWrench, width: 12, height: 12 }
" {tool_count} tools"
}
div { "{created_at}" }
}
}
}
div { style: "text-align: right; font-size: 0.85rem; color: var(--text-secondary);",
div { style: "margin-bottom: 4px;",
Icon { icon: BsShieldExclamation, width: 12, height: 12 }
" {findings_count} findings"
if is_session_running {
div { style: "margin-top: 8px; display: flex; justify-content: flex-end;",
button {
class: "btn btn-ghost",
style: "font-size: 0.8rem; padding: 4px 12px; color: #dc2626; border-color: #dc2626;",
onclick: move |e| {
e.stop_propagation();
e.prevent_default();
let sid = stop_id.clone();
spawn(async move {
let _ = stop_pentest_session(sid).await;
sessions.restart();
});
},
Icon { icon: BsStopCircle, width: 12, height: 12 }
" Stop"
}
}
div { style: "margin-bottom: 4px;",
Icon { icon: BsWrench, width: 12, height: 12 }
" {tool_count} tools"
}
div { "{created_at}" }
}
}
}

File diff suppressed because it is too large Load Diff