Files
compliance-scanner-agent/compliance-dashboard/src/pages/pentest_session.rs
T
sharang c461faa2fb
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 7s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Successful in 2s
CI / Deploy MCP (push) Successful in 2s
feat: pentest onboarding — streaming, browser automation, reports, user cleanup (#16)
Complete pentest feature overhaul: SSE streaming, session-persistent browser tool (CDP), AES-256 credential encryption, auto-screenshots in reports, code-level remediation correlation, SAST triage chunking, context window optimization, test user cleanup (Keycloak/Auth0/Okta), wizard dropdowns, attack chain improvements, architecture docs with Mermaid diagrams.

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #16
2026-03-17 20:32:20 +00:00

598 lines
30 KiB
Rust

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::<String>::None);
let mut export_error = use_signal(|| Option::<String>::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" }
}
}
}
}
}
}
}