use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; use dioxus_free_icons::Icon; use crate::app::Route; use crate::components::attack_chain::AttackChainView; use crate::components::severity_badge::SeverityBadge; use crate::infrastructure::pentest::{ export_pentest_report, fetch_attack_chain, fetch_pentest_findings, fetch_pentest_session, pause_pentest_session, resume_pentest_session, }; #[component] pub fn PentestSessionPage(session_id: String) -> Element { let sid_for_session = session_id.clone(); let sid_for_findings = session_id.clone(); let sid_for_chain = session_id.clone(); let mut session = use_resource(move || { let id = sid_for_session.clone(); async move { fetch_pentest_session(id).await.ok() } }); let mut findings = use_resource(move || { let id = sid_for_findings.clone(); async move { fetch_pentest_findings(id).await.ok() } }); let mut attack_chain = use_resource(move || { let id = sid_for_chain.clone(); async move { fetch_attack_chain(id).await.ok() } }); let mut active_tab = use_signal(|| "findings".to_string()); let mut show_export_modal = use_signal(|| false); let mut export_password = use_signal(String::new); let mut exporting = use_signal(|| false); let mut export_sha256 = use_signal(|| Option::::None); let mut export_error = use_signal(|| Option::::None); let mut poll_gen = use_signal(|| 0u32); // Extract session data let session_data = session.read().clone(); let sess = session_data.as_ref().and_then(|s| s.as_ref()); let session_status = sess .and_then(|s| s.data.get("status")) .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); let target_name = sess .and_then(|s| s.data.get("target_name")) .and_then(|v| v.as_str()) .unwrap_or("Pentest Session") .to_string(); let strategy = sess .and_then(|s| s.data.get("strategy")) .and_then(|v| v.as_str()) .unwrap_or("-") .to_string(); let tool_invocations = sess .and_then(|s| s.data.get("tool_invocations")) .and_then(|v| v.as_u64()) .unwrap_or(0); let tool_successes = sess .and_then(|s| s.data.get("tool_successes")) .and_then(|v| v.as_u64()) .unwrap_or(0); let findings_count = { let f = findings.read(); match &*f { Some(Some(data)) => data.total.unwrap_or(0), _ => 0, } }; let started_at = sess .and_then(|s| s.data.get("started_at")) .and_then(|v| v.as_str()) .unwrap_or("-") .to_string(); let completed_at = sess .and_then(|s| s.data.get("completed_at")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let success_rate = if tool_invocations == 0 { 100.0 } else { (tool_successes as f64 / tool_invocations as f64) * 100.0 }; let is_running = session_status == "running"; let is_paused = session_status == "paused"; let is_active = is_running || is_paused; // Poll while running or paused use_effect(move || { let _gen = *poll_gen.read(); if is_active { spawn(async move { #[cfg(feature = "web")] gloo_timers::future::TimeoutFuture::new(3_000).await; #[cfg(not(feature = "web"))] tokio::time::sleep(std::time::Duration::from_secs(3)).await; findings.restart(); attack_chain.restart(); session.restart(); let next = poll_gen.peek().wrapping_add(1); poll_gen.set(next); }); } }); // Severity counts from findings data let (sev_critical, sev_high, sev_medium, sev_low, sev_info, exploitable_count) = { let f = findings.read(); match &*f { Some(Some(data)) => { let list = &data.data; let c = list .iter() .filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("critical")) .count(); let h = list .iter() .filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("high")) .count(); let m = list .iter() .filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("medium")) .count(); let l = list .iter() .filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("low")) .count(); let i = list .iter() .filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("info")) .count(); let e = list .iter() .filter(|f| { f.get("exploitable") .and_then(|v| v.as_bool()) .unwrap_or(false) }) .count(); (c, h, m, l, i, e) } _ => (0, 0, 0, 0, 0, 0), } }; let status_style = match session_status.as_str() { "running" => "background: #16a34a; color: #fff;", "completed" => "background: #2563eb; color: #fff;", "failed" => "background: #dc2626; color: #fff;", "paused" => "background: #d97706; color: #fff;", _ => "background: var(--bg-tertiary); color: var(--text-secondary);", }; // Export handler let sid_for_export = session_id.clone(); let do_export = move |_| { let pw = export_password.read().clone(); if pw.len() < 8 { export_error.set(Some("Password must be at least 8 characters".to_string())); return; } export_error.set(None); export_sha256.set(None); exporting.set(true); let sid = sid_for_export.clone(); spawn(async move { // TODO: get real user info from auth context match export_pentest_report(sid.clone(), pw, String::new(), String::new()).await { Ok(resp) => { export_sha256.set(Some(resp.sha256.clone())); // Trigger download via JS let js = format!( r#" try {{ var raw = atob("{}"); var bytes = new Uint8Array(raw.length); for (var i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); var blob = new Blob([bytes], {{ type: "application/octet-stream" }}); var url = URL.createObjectURL(blob); var a = document.createElement("a"); a.href = url; a.download = "{}"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }} catch(e) {{ console.error("Download failed:", e); }} "#, resp.archive_base64, resp.filename, ); document::eval(&js); } Err(e) => { export_error.set(Some(format!("{e}"))); } } exporting.set(false); }); }; rsx! { div { class: "back-nav", Link { to: Route::PentestDashboardPage {}, class: "btn btn-ghost btn-back", Icon { icon: BsArrowLeft, width: 16, height: 16 } "Back to Pentest Dashboard" } } // Session header div { style: "display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 8px;", div { h2 { style: "margin: 0 0 4px 0;", "{target_name}" } div { style: "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;", span { class: "badge", style: "{status_style}", "{session_status}" } span { class: "badge", style: "background: var(--bg-tertiary); color: var(--text-secondary);", "{strategy}" } if is_running { span { style: "font-size: 0.8rem; color: var(--text-secondary);", Icon { icon: BsPlayCircle, width: 12, height: 12 } " Running..." } } if is_paused { span { style: "font-size: 0.8rem; color: #d97706;", Icon { icon: BsPauseCircle, width: 12, height: 12 } " Paused" } } } } div { style: "display: flex; gap: 8px;", if is_running { { let sid_pause = session_id.clone(); rsx! { button { class: "btn btn-ghost", style: "font-size: 0.85rem; color: #d97706; border-color: #d97706;", onclick: move |_| { let sid = sid_pause.clone(); spawn(async move { let _ = pause_pentest_session(sid).await; session.restart(); }); }, Icon { icon: BsPauseCircle, width: 14, height: 14 } " Pause" } } } } if is_paused { { let sid_resume = session_id.clone(); rsx! { button { class: "btn btn-ghost", style: "font-size: 0.85rem; color: #16a34a; border-color: #16a34a;", onclick: move |_| { let sid = sid_resume.clone(); spawn(async move { let _ = resume_pentest_session(sid).await; session.restart(); }); }, Icon { icon: BsPlayCircle, width: 14, height: 14 } " Resume" } } } } button { class: "btn btn-primary", style: "font-size: 0.85rem;", onclick: move |_| { export_password.set(String::new()); export_sha256.set(None); export_error.set(None); show_export_modal.set(true); }, Icon { icon: BsDownload, width: 14, height: 14 } " Export Report" } } } // Summary cards div { class: "stat-cards", style: "margin-bottom: 20px;", div { class: "stat-card-item", div { class: "stat-card-value", "{findings_count}" } div { class: "stat-card-label", Icon { icon: BsShieldExclamation, width: 14, height: 14 } " Findings" } } div { class: "stat-card-item", div { class: "stat-card-value", style: "color: #dc2626;", "{exploitable_count}" } div { class: "stat-card-label", Icon { icon: BsExclamationTriangle, width: 14, height: 14 } " Exploitable" } } div { class: "stat-card-item", div { class: "stat-card-value", "{tool_invocations}" } div { class: "stat-card-label", Icon { icon: BsWrench, width: 14, height: 14 } " Tool Invocations" } } div { class: "stat-card-item", div { class: "stat-card-value", "{success_rate:.0}%" } div { class: "stat-card-label", Icon { icon: BsCheckCircle, width: 14, height: 14 } " Success Rate" } } } // Severity distribution bar div { class: "card", style: "margin-bottom: 20px; padding: 14px;", div { style: "display: flex; align-items: center; gap: 14px; flex-wrap: wrap;", span { style: "font-weight: 600; color: var(--text-secondary); font-size: 0.85rem;", "Severity Distribution" } span { class: "badge", style: "background: #dc2626; color: #fff;", "Critical: {sev_critical}" } span { class: "badge", style: "background: #ea580c; color: #fff;", "High: {sev_high}" } span { class: "badge", style: "background: #d97706; color: #fff;", "Medium: {sev_medium}" } span { class: "badge", style: "background: #2563eb; color: #fff;", "Low: {sev_low}" } span { class: "badge", style: "background: #6b7280; color: #fff;", "Info: {sev_info}" } } } // Session details row div { class: "card", style: "margin-bottom: 20px; padding: 14px;", div { style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; font-size: 0.85rem;", div { span { style: "color: var(--text-secondary);", "Started: " } span { "{started_at}" } } if !completed_at.is_empty() { div { span { style: "color: var(--text-secondary);", "Completed: " } span { "{completed_at}" } } } div { span { style: "color: var(--text-secondary);", "Tools: " } span { "{tool_successes}/{tool_invocations} successful" } } } } // Tabs: Findings / Attack Chain div { class: "card", style: "overflow: hidden;", div { style: "display: flex; border-bottom: 1px solid var(--border-color);", button { style: if *active_tab.read() == "findings" { "flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid #2563eb; color: var(--text-primary); cursor: pointer; font-weight: 600; font-size: 0.9rem;" } else { "flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer; font-size: 0.9rem;" }, onclick: move |_| active_tab.set("findings".to_string()), Icon { icon: BsShieldExclamation, width: 14, height: 14 } " Findings ({findings_count})" } button { style: if *active_tab.read() == "chain" { "flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid #2563eb; color: var(--text-primary); cursor: pointer; font-weight: 600; font-size: 0.9rem;" } else { "flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer; font-size: 0.9rem;" }, onclick: move |_| { active_tab.set("chain".to_string()); }, Icon { icon: BsDiagram3, width: 14, height: 14 } " Attack Chain" } } // Tab content div { style: "padding: 16px;", if *active_tab.read() == "findings" { // Findings list 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;", if is_running { p { "Scan in progress — findings will appear here." } } else { p { "No findings discovered." } } } } } else { rsx! { div { style: "display: flex; flex-direction: column; gap: 10px;", 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 method = finding.get("method").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 description = finding.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(); let remediation = finding.get("remediation").and_then(|v| v.as_str()).unwrap_or("").to_string(); let cwe = finding.get("cwe").and_then(|v| v.as_str()).unwrap_or("").to_string(); let linked_sast = finding.get("linked_sast_finding_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); rsx! { div { style: "background: var(--bg-tertiary); border-radius: 8px; padding: 14px;", // Header div { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;", div { style: "display: flex; align-items: center; gap: 8px;", SeverityBadge { severity: severity } span { style: "font-weight: 600; font-size: 0.95rem;", "{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: "font-size: 0.7rem;", "{vuln_type}" } } } // Endpoint if !endpoint.is_empty() { div { style: "font-family: monospace; font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 6px;", "{method} {endpoint}" } } // CWE if !cwe.is_empty() { div { style: "font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 4px;", "CWE: {cwe}" } } // Description if !description.is_empty() { div { style: "font-size: 0.85rem; margin-bottom: 8px; line-height: 1.5;", "{description}" } } // Remediation if !remediation.is_empty() { div { style: "font-size: 0.8rem; padding: 8px 10px; background: rgba(56, 189, 248, 0.08); border-left: 3px solid #38bdf8; border-radius: 0 4px 4px 0; margin-top: 6px;", span { style: "font-weight: 600;", "Recommendation: " } "{remediation}" } } // Linked SAST if !linked_sast.is_empty() { div { style: "font-size: 0.75rem; color: var(--text-secondary); margin-top: 4px;", "Correlated SAST finding: " code { "{linked_sast}" } } } } } } } } } } }, 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 visualization 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;", if is_running { p { "Scan in progress — attack chain will appear here." } } else { p { "No attack chain steps recorded." } } } } } else { rsx! { AttackChainView { steps: steps.clone(), is_running: is_running, session_findings: findings_count as usize, session_tool_invocations: tool_invocations as usize, session_success_rate: success_rate, } } } }, Some(None) => rsx! { p { style: "color: var(--text-secondary);", "Failed to load attack chain." } }, None => rsx! { p { style: "color: var(--text-secondary);", "Loading..." } }, } } } } // Export modal if *show_export_modal.read() { div { style: "position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000;", onclick: move |_| show_export_modal.set(false), div { style: "background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 24px; width: 480px; max-width: 90vw;", onclick: move |e| e.stop_propagation(), h3 { style: "margin: 0 0 4px 0;", "Export Pentest Report" } p { style: "font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 16px;", "The report will be exported as a password-protected ZIP archive (AES-256) containing a professional HTML report and raw findings data. Open with any standard archive tool." } div { style: "margin-bottom: 14px;", label { style: "display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px;", "Encryption Password" } input { class: "chat-input", style: "width: 100%; padding: 8px;", r#type: "password", placeholder: "Minimum 8 characters", value: "{export_password}", oninput: move |e| { export_password.set(e.value()); export_error.set(None); }, } } if let Some(err) = &*export_error.read() { div { style: "padding: 8px 12px; background: rgba(220, 38, 38, 0.1); border: 1px solid #dc2626; border-radius: 6px; color: #dc2626; font-size: 0.85rem; margin-bottom: 14px;", "{err}" } } if let Some(sha) = &*export_sha256.read() { { let sha_copy = sha.clone(); rsx! { div { style: "padding: 10px 12px; background: rgba(22, 163, 74, 0.08); border: 1px solid #16a34a; border-radius: 6px; margin-bottom: 14px;", div { style: "font-size: 0.8rem; font-weight: 600; color: #16a34a; margin-bottom: 4px;", Icon { icon: BsCheckCircle, width: 12, height: 12 } " Archive downloaded successfully" } div { style: "font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 2px;", "SHA-256 Checksum:" } div { style: "display: flex; align-items: center; gap: 6px;", div { style: "flex: 1; font-family: monospace; font-size: 0.7rem; word-break: break-all; color: var(--text-primary); background: var(--bg-primary); padding: 6px 8px; border-radius: 4px;", "{sha_copy}" } button { class: "btn btn-ghost", style: "padding: 4px 8px; font-size: 0.75rem; flex-shrink: 0;", onclick: move |_| { let js = format!( "navigator.clipboard.writeText('{}');", sha_copy ); document::eval(&js); }, Icon { icon: BsClipboard, width: 12, height: 12 } } } } } } } div { style: "display: flex; justify-content: flex-end; gap: 8px;", button { class: "btn btn-ghost", onclick: move |_| show_export_modal.set(false), "Close" } button { class: "btn btn-primary", disabled: *exporting.read() || export_password.read().len() < 8, onclick: do_export, if *exporting.read() { "Encrypting..." } else { "Export" } } } } } } } }