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>
1142 lines
54 KiB
Rust
1142 lines
54 KiB
Rust
use std::collections::{HashMap, VecDeque};
|
|
|
|
use dioxus::prelude::*;
|
|
use dioxus_free_icons::icons::bs_icons::*;
|
|
use dioxus_free_icons::Icon;
|
|
|
|
use crate::app::Route;
|
|
use crate::components::severity_badge::SeverityBadge;
|
|
use crate::infrastructure::pentest::{
|
|
export_pentest_report, fetch_attack_chain, fetch_pentest_findings, fetch_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";
|
|
|
|
// Poll while running
|
|
use_effect(move || {
|
|
let _gen = *poll_gen.read();
|
|
if is_running {
|
|
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..."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
div { style: "display: flex; gap: 8px;",
|
|
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" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════
|
|
// Attack Chain Visualization Component
|
|
// ═══════════════════════════════════════
|
|
|
|
/// Get category CSS class from tool name
|
|
fn tool_category(name: &str) -> &'static str {
|
|
let lower = name.to_lowercase();
|
|
if lower.contains("recon") { return "recon"; }
|
|
if lower.contains("openapi") || lower.contains("api") || lower.contains("swagger") { return "api"; }
|
|
if lower.contains("header") { return "headers"; }
|
|
if lower.contains("csp") { return "csp"; }
|
|
if lower.contains("cookie") { return "cookies"; }
|
|
if lower.contains("log") || lower.contains("console") { return "logs"; }
|
|
if lower.contains("rate") || lower.contains("limit") { return "ratelimit"; }
|
|
if lower.contains("cors") { return "cors"; }
|
|
if lower.contains("tls") || lower.contains("ssl") { return "tls"; }
|
|
if lower.contains("redirect") { return "redirect"; }
|
|
if lower.contains("dns") || lower.contains("dmarc") || lower.contains("email") || lower.contains("spf") { return "email"; }
|
|
if lower.contains("auth") || lower.contains("jwt") || lower.contains("token") || lower.contains("session") { return "auth"; }
|
|
if lower.contains("xss") { return "xss"; }
|
|
if lower.contains("sql") || lower.contains("sqli") { return "sqli"; }
|
|
if lower.contains("ssrf") { return "ssrf"; }
|
|
if lower.contains("idor") { return "idor"; }
|
|
if lower.contains("fuzz") { return "fuzzer"; }
|
|
if lower.contains("cve") || lower.contains("exploit") { return "cve"; }
|
|
"default"
|
|
}
|
|
|
|
/// Get emoji icon from tool category
|
|
fn tool_emoji(cat: &str) -> &'static str {
|
|
match cat {
|
|
"recon" => "\u{1F50D}",
|
|
"api" => "\u{1F517}",
|
|
"headers" => "\u{1F6E1}",
|
|
"csp" => "\u{1F6A7}",
|
|
"cookies" => "\u{1F36A}",
|
|
"logs" => "\u{1F4DD}",
|
|
"ratelimit" => "\u{23F1}",
|
|
"cors" => "\u{1F30D}",
|
|
"tls" => "\u{1F510}",
|
|
"redirect" => "\u{21AA}",
|
|
"email" => "\u{1F4E7}",
|
|
"auth" => "\u{1F512}",
|
|
"xss" => "\u{26A1}",
|
|
"sqli" => "\u{1F489}",
|
|
"ssrf" => "\u{1F310}",
|
|
"idor" => "\u{1F511}",
|
|
"fuzzer" => "\u{1F9EA}",
|
|
"cve" => "\u{1F4A3}",
|
|
_ => "\u{1F527}",
|
|
}
|
|
}
|
|
|
|
/// Compute display label for category
|
|
fn cat_label(cat: &str) -> &'static str {
|
|
match cat {
|
|
"recon" => "Recon",
|
|
"api" => "API",
|
|
"headers" => "Headers",
|
|
"csp" => "CSP",
|
|
"cookies" => "Cookies",
|
|
"logs" => "Logs",
|
|
"ratelimit" => "Rate Limit",
|
|
"cors" => "CORS",
|
|
"tls" => "TLS",
|
|
"redirect" => "Redirect",
|
|
"email" => "Email/DNS",
|
|
"auth" => "Auth",
|
|
"xss" => "XSS",
|
|
"sqli" => "SQLi",
|
|
"ssrf" => "SSRF",
|
|
"idor" => "IDOR",
|
|
"fuzzer" => "Fuzzer",
|
|
"cve" => "CVE",
|
|
_ => "Other",
|
|
}
|
|
}
|
|
|
|
/// Phase name heuristic based on depth
|
|
fn phase_name(depth: usize) -> &'static str {
|
|
match depth {
|
|
0 => "Reconnaissance",
|
|
1 => "Analysis",
|
|
2 => "Boundary Testing",
|
|
3 => "Injection & Exploitation",
|
|
4 => "Authentication Testing",
|
|
5 => "Validation",
|
|
6 => "Deep Scan",
|
|
_ => "Final",
|
|
}
|
|
}
|
|
|
|
/// Short label for phase rail
|
|
fn phase_short_name(depth: usize) -> &'static str {
|
|
match depth {
|
|
0 => "Recon",
|
|
1 => "Analysis",
|
|
2 => "Boundary",
|
|
3 => "Exploit",
|
|
4 => "Auth",
|
|
5 => "Validate",
|
|
6 => "Deep",
|
|
_ => "Final",
|
|
}
|
|
}
|
|
|
|
/// Compute BFS phases from attack chain nodes
|
|
fn compute_phases(steps: &[serde_json::Value]) -> Vec<Vec<usize>> {
|
|
let node_ids: Vec<String> = steps
|
|
.iter()
|
|
.map(|s| s.get("node_id").and_then(|v| v.as_str()).unwrap_or("").to_string())
|
|
.collect();
|
|
|
|
let id_to_idx: HashMap<String, usize> = node_ids
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, id)| (id.clone(), i))
|
|
.collect();
|
|
|
|
// Compute depth via BFS
|
|
let mut depths = vec![usize::MAX; steps.len()];
|
|
let mut queue = VecDeque::new();
|
|
|
|
// Root nodes: those with no parents or parents not in the set
|
|
for (i, step) in steps.iter().enumerate() {
|
|
let parents = step
|
|
.get("parent_node_ids")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|p| p.as_str())
|
|
.filter(|p| id_to_idx.contains_key(*p))
|
|
.count()
|
|
})
|
|
.unwrap_or(0);
|
|
if parents == 0 {
|
|
depths[i] = 0;
|
|
queue.push_back(i);
|
|
}
|
|
}
|
|
|
|
// BFS to compute min depth
|
|
while let Some(idx) = queue.pop_front() {
|
|
let current_depth = depths[idx];
|
|
let node_id = &node_ids[idx];
|
|
// Find children: nodes that list this node as a parent
|
|
for (j, step) in steps.iter().enumerate() {
|
|
if depths[j] <= current_depth + 1 {
|
|
continue;
|
|
}
|
|
let is_child = step
|
|
.get("parent_node_ids")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| arr.iter().any(|p| p.as_str() == Some(node_id.as_str())))
|
|
.unwrap_or(false);
|
|
if is_child {
|
|
depths[j] = current_depth + 1;
|
|
queue.push_back(j);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unreachable nodes
|
|
for d in depths.iter_mut() {
|
|
if *d == usize::MAX {
|
|
*d = 0;
|
|
}
|
|
}
|
|
|
|
// Group by depth
|
|
let max_depth = depths.iter().copied().max().unwrap_or(0);
|
|
let mut phases: Vec<Vec<usize>> = Vec::new();
|
|
for d in 0..=max_depth {
|
|
let indices: Vec<usize> = depths
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, &dep)| dep == d)
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
if !indices.is_empty() {
|
|
phases.push(indices);
|
|
}
|
|
}
|
|
phases
|
|
}
|
|
|
|
/// Format BSON datetime to readable string
|
|
fn format_bson_time(val: &serde_json::Value) -> String {
|
|
// Handle BSON {"$date":{"$numberLong":"..."}}
|
|
if let Some(date_obj) = val.get("$date") {
|
|
if let Some(ms_str) = date_obj.get("$numberLong").and_then(|v| v.as_str()) {
|
|
if let Ok(ms) = ms_str.parse::<i64>() {
|
|
let secs = ms / 1000;
|
|
let h = (secs / 3600) % 24;
|
|
let m = (secs / 60) % 60;
|
|
let s = secs % 60;
|
|
return format!("{h:02}:{m:02}:{s:02}");
|
|
}
|
|
}
|
|
// Handle {"$date": "2025-..."}
|
|
if let Some(s) = date_obj.as_str() {
|
|
return s.to_string();
|
|
}
|
|
}
|
|
// Handle plain string
|
|
if let Some(s) = val.as_str() {
|
|
return s.to_string();
|
|
}
|
|
String::new()
|
|
}
|
|
|
|
/// Compute duration string from started_at and completed_at
|
|
fn compute_duration(step: &serde_json::Value) -> String {
|
|
let extract_ms = |val: &serde_json::Value| -> Option<i64> {
|
|
val.get("$date")?
|
|
.get("$numberLong")?
|
|
.as_str()?
|
|
.parse::<i64>()
|
|
.ok()
|
|
};
|
|
|
|
let started = step.get("started_at").and_then(extract_ms);
|
|
let completed = step.get("completed_at").and_then(extract_ms);
|
|
|
|
match (started, completed) {
|
|
(Some(s), Some(c)) => {
|
|
let diff_ms = c - s;
|
|
if diff_ms < 1000 {
|
|
format!("{}ms", diff_ms)
|
|
} else {
|
|
format!("{:.1}s", diff_ms as f64 / 1000.0)
|
|
}
|
|
}
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
fn AttackChainView(
|
|
steps: Vec<serde_json::Value>,
|
|
is_running: bool,
|
|
session_findings: usize,
|
|
session_tool_invocations: usize,
|
|
session_success_rate: f64,
|
|
) -> Element {
|
|
let phases = compute_phases(&steps);
|
|
|
|
// Compute KPIs — prefer session-level stats, fall back to node-level
|
|
let total_tools = steps.len();
|
|
let node_findings: usize = steps
|
|
.iter()
|
|
.map(|s| {
|
|
s.get("findings_produced")
|
|
.and_then(|v| v.as_array())
|
|
.map(|a| a.len())
|
|
.unwrap_or(0)
|
|
})
|
|
.sum();
|
|
// Use session-level findings count if nodes don't have findings linked
|
|
let total_findings = if node_findings > 0 { node_findings } else { session_findings };
|
|
|
|
let completed_count = steps
|
|
.iter()
|
|
.filter(|s| s.get("status").and_then(|v| v.as_str()) == Some("completed"))
|
|
.count();
|
|
let failed_count = steps
|
|
.iter()
|
|
.filter(|s| s.get("status").and_then(|v| v.as_str()) == Some("failed"))
|
|
.count();
|
|
let finished = completed_count + failed_count;
|
|
let success_pct = if finished == 0 {
|
|
100
|
|
} else {
|
|
(completed_count * 100) / finished
|
|
};
|
|
let max_risk: u8 = steps
|
|
.iter()
|
|
.filter_map(|s| s.get("risk_score").and_then(|v| v.as_u64()))
|
|
.map(|v| v as u8)
|
|
.max()
|
|
.unwrap_or(0);
|
|
|
|
let progress_pct = if total_tools == 0 {
|
|
0
|
|
} else {
|
|
((completed_count + failed_count) * 100) / total_tools
|
|
};
|
|
|
|
// Build phase data for rail and accordion
|
|
let phase_data: Vec<(usize, Vec<&serde_json::Value>, usize, bool, bool, bool)> = phases
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(pi, indices)| {
|
|
let phase_steps: Vec<&serde_json::Value> = indices.iter().map(|&i| &steps[i]).collect();
|
|
let phase_findings: usize = phase_steps
|
|
.iter()
|
|
.map(|s| {
|
|
s.get("findings_produced")
|
|
.and_then(|v| v.as_array())
|
|
.map(|a| a.len())
|
|
.unwrap_or(0)
|
|
})
|
|
.sum();
|
|
let has_failed = phase_steps
|
|
.iter()
|
|
.any(|s| s.get("status").and_then(|v| v.as_str()) == Some("failed"));
|
|
let has_running = phase_steps
|
|
.iter()
|
|
.any(|s| s.get("status").and_then(|v| v.as_str()) == Some("running"));
|
|
let all_done = phase_steps.iter().all(|s| {
|
|
let st = s.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
|
st == "completed" || st == "failed" || st == "skipped"
|
|
});
|
|
(pi, phase_steps, phase_findings, has_failed, has_running, all_done)
|
|
})
|
|
.collect();
|
|
|
|
let mut active_rail = use_signal(|| 0usize);
|
|
|
|
rsx! {
|
|
// KPI bar
|
|
div { class: "ac-kpi-bar",
|
|
div { class: "ac-kpi-card",
|
|
div { class: "ac-kpi-value", style: "color: var(--text-primary);", "{total_tools}" }
|
|
div { class: "ac-kpi-label", "Tools Run" }
|
|
}
|
|
div { class: "ac-kpi-card",
|
|
div { class: "ac-kpi-value", style: "color: var(--danger, #dc2626);", "{total_findings}" }
|
|
div { class: "ac-kpi-label", "Findings" }
|
|
}
|
|
div { class: "ac-kpi-card",
|
|
div { class: "ac-kpi-value", style: "color: var(--success, #16a34a);", "{success_pct}%" }
|
|
div { class: "ac-kpi-label", "Success Rate" }
|
|
}
|
|
div { class: "ac-kpi-card",
|
|
div { class: "ac-kpi-value", style: "color: var(--warning, #d97706);", "{max_risk}" }
|
|
div { class: "ac-kpi-label", "Max Risk" }
|
|
}
|
|
}
|
|
|
|
// Phase rail
|
|
div { class: "ac-phase-rail",
|
|
for (pi, (_phase_idx, phase_steps, phase_findings, has_failed, has_running, all_done)) in phase_data.iter().enumerate() {
|
|
{
|
|
if pi > 0 {
|
|
let prev_done = phase_data.get(pi - 1).map(|p| p.5).unwrap_or(false);
|
|
let bar_class = if prev_done && *all_done {
|
|
"done"
|
|
} else if prev_done {
|
|
"running"
|
|
} else {
|
|
""
|
|
};
|
|
rsx! {
|
|
div { class: "ac-rail-bar",
|
|
div { class: "ac-rail-bar-inner {bar_class}" }
|
|
}
|
|
}
|
|
} else {
|
|
rsx! {}
|
|
}
|
|
}
|
|
{
|
|
let dot_class = if *has_running {
|
|
"running"
|
|
} else if *has_failed && *all_done {
|
|
"mixed"
|
|
} else if *all_done {
|
|
"done"
|
|
} else {
|
|
"pending"
|
|
};
|
|
let is_active = *active_rail.read() == pi;
|
|
let active_cls = if is_active { " active" } else { "" };
|
|
let findings_cls = if *phase_findings > 0 { "has" } else { "none" };
|
|
let findings_text = if *phase_findings > 0 {
|
|
format!("{phase_findings}")
|
|
} else {
|
|
"\u{2014}".to_string()
|
|
};
|
|
let short = phase_short_name(pi);
|
|
|
|
rsx! {
|
|
div {
|
|
class: "ac-rail-node{active_cls}",
|
|
onclick: move |_| {
|
|
active_rail.set(pi);
|
|
let js = format!(
|
|
"document.getElementById('ac-phase-{pi}')?.scrollIntoView({{behavior:'smooth',block:'nearest'}});document.getElementById('ac-phase-{pi}')?.classList.add('open');"
|
|
);
|
|
document::eval(&js);
|
|
},
|
|
div { class: "ac-rail-dot {dot_class}" }
|
|
div { class: "ac-rail-label", "{short}" }
|
|
div { class: "ac-rail-findings {findings_cls}", "{findings_text}" }
|
|
div { class: "ac-rail-heatmap",
|
|
for step in phase_steps.iter() {
|
|
{
|
|
let st = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending");
|
|
let hm_cls = match st {
|
|
"completed" => "ok",
|
|
"failed" => "fail",
|
|
"running" => "run",
|
|
_ => "wait",
|
|
};
|
|
rsx! { div { class: "ac-hm-cell {hm_cls}" } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Progress bar
|
|
div { class: "ac-progress-track",
|
|
div { class: "ac-progress-fill", style: "width: {progress_pct}%;" }
|
|
}
|
|
|
|
// Expand all
|
|
div { class: "ac-controls",
|
|
button {
|
|
class: "ac-btn-toggle",
|
|
onclick: move |_| {
|
|
document::eval(
|
|
"document.querySelectorAll('.ac-phase').forEach(p => p.classList.toggle('open', !document.querySelector('.ac-phase.open') || !document.querySelectorAll('.ac-phase:not(.open)').length === 0));(function(){var ps=document.querySelectorAll('.ac-phase');var allOpen=Array.from(ps).every(p=>p.classList.contains('open'));ps.forEach(p=>{if(allOpen)p.classList.remove('open');else p.classList.add('open');});})();"
|
|
);
|
|
},
|
|
"Expand all"
|
|
}
|
|
}
|
|
|
|
// Phase accordion
|
|
div { class: "ac-phases",
|
|
for (pi, (_, phase_steps, phase_findings, has_failed, has_running, all_done)) in phase_data.iter().enumerate() {
|
|
{
|
|
let open_cls = if pi == 0 { " open" } else { "" };
|
|
let phase_label = phase_name(pi);
|
|
let tool_count = phase_steps.len();
|
|
let meta_text = if *has_running {
|
|
"in progress".to_string()
|
|
} else {
|
|
format!("{phase_findings} findings")
|
|
};
|
|
let meta_cls = if *has_running { "running-ct" } else { "findings-ct" };
|
|
let phase_num_label = format!("PHASE {}", pi + 1);
|
|
let phase_el_id = format!("ac-phase-{pi}");
|
|
let phase_el_id2 = phase_el_id.clone();
|
|
|
|
rsx! {
|
|
div {
|
|
class: "ac-phase{open_cls}",
|
|
id: "{phase_el_id}",
|
|
div {
|
|
class: "ac-phase-header",
|
|
onclick: move |_| {
|
|
let js = format!("document.getElementById('{phase_el_id2}').classList.toggle('open');");
|
|
document::eval(&js);
|
|
},
|
|
span { class: "ac-phase-num", "{phase_num_label}" }
|
|
span { class: "ac-phase-title", "{phase_label}" }
|
|
div { class: "ac-phase-dots",
|
|
for step in phase_steps.iter() {
|
|
{
|
|
let st = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending");
|
|
rsx! { div { class: "ac-phase-dot {st}" } }
|
|
}
|
|
}
|
|
}
|
|
div { class: "ac-phase-meta",
|
|
span { "{tool_count} tools" }
|
|
span { class: "{meta_cls}", "{meta_text}" }
|
|
}
|
|
span { class: "ac-phase-chevron", "\u{25B8}" }
|
|
}
|
|
div { class: "ac-phase-body",
|
|
div { class: "ac-phase-body-inner",
|
|
for step in phase_steps.iter() {
|
|
{
|
|
let tool_name_val = step.get("tool_name").and_then(|v| v.as_str()).unwrap_or("Unknown").to_string();
|
|
let status = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending").to_string();
|
|
let cat = tool_category(&tool_name_val);
|
|
let emoji = tool_emoji(cat);
|
|
let label = cat_label(cat);
|
|
let findings_n = step.get("findings_produced").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
|
|
let risk = step.get("risk_score").and_then(|v| v.as_u64()).map(|v| v as u8);
|
|
let reasoning = step.get("llm_reasoning").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let duration = compute_duration(step);
|
|
let started = step.get("started_at").map(format_bson_time).unwrap_or_default();
|
|
|
|
let is_pending = status == "pending";
|
|
let pending_cls = if is_pending { " is-pending" } else { "" };
|
|
|
|
let duration_cls = if status == "running" { "ac-tool-duration running-text" } else { "ac-tool-duration" };
|
|
let duration_text = if status == "running" {
|
|
"running\u{2026}".to_string()
|
|
} else if duration.is_empty() {
|
|
"\u{2014}".to_string()
|
|
} else {
|
|
duration
|
|
};
|
|
|
|
let pill_cls = if findings_n > 0 { "ac-findings-pill has" } else { "ac-findings-pill zero" };
|
|
let pill_text = if findings_n > 0 { format!("{findings_n}") } else { "\u{2014}".to_string() };
|
|
|
|
let (risk_cls, risk_text) = match risk {
|
|
Some(r) if r >= 75 => ("ac-risk-val high", format!("{r}")),
|
|
Some(r) if r >= 40 => ("ac-risk-val medium", format!("{r}")),
|
|
Some(r) => ("ac-risk-val low", format!("{r}")),
|
|
None => ("ac-risk-val none", "\u{2014}".to_string()),
|
|
};
|
|
|
|
let node_id = step.get("node_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let detail_id = format!("ac-detail-{node_id}");
|
|
let row_id = format!("ac-row-{node_id}");
|
|
let detail_id_clone = detail_id.clone();
|
|
|
|
rsx! {
|
|
div {
|
|
class: "ac-tool-row{pending_cls}",
|
|
id: "{row_id}",
|
|
onclick: move |_| {
|
|
if is_pending { return; }
|
|
let js = format!(
|
|
"(function(){{var r=document.getElementById('{row_id}');var d=document.getElementById('{detail_id}');if(r.classList.contains('expanded')){{r.classList.remove('expanded');d.classList.remove('open');}}else{{r.classList.add('expanded');d.classList.add('open');}}}})()"
|
|
);
|
|
document::eval(&js);
|
|
},
|
|
div { class: "ac-status-bar {status}" }
|
|
div { class: "ac-tool-icon", "{emoji}" }
|
|
div { class: "ac-tool-info",
|
|
div { class: "ac-tool-name", "{tool_name_val}" }
|
|
span { class: "ac-cat-chip {cat}", "{label}" }
|
|
}
|
|
div { class: "{duration_cls}", "{duration_text}" }
|
|
div { span { class: "{pill_cls}", "{pill_text}" } }
|
|
div { class: "{risk_cls}", "{risk_text}" }
|
|
}
|
|
div {
|
|
class: "ac-tool-detail",
|
|
id: "{detail_id_clone}",
|
|
if !reasoning.is_empty() || !started.is_empty() {
|
|
div { class: "ac-tool-detail-inner",
|
|
if !reasoning.is_empty() {
|
|
div { class: "ac-reasoning-block", "{reasoning}" }
|
|
}
|
|
if !started.is_empty() {
|
|
div { class: "ac-detail-grid",
|
|
span { class: "ac-detail-label", "Started" }
|
|
span { class: "ac-detail-value", "{started}" }
|
|
if !duration_text.is_empty() && status != "running" && duration_text != "\u{2014}" {
|
|
span { class: "ac-detail-label", "Duration" }
|
|
span { class: "ac-detail-value", "{duration_text}" }
|
|
}
|
|
span { class: "ac-detail-label", "Status" }
|
|
if status == "completed" {
|
|
span { class: "ac-detail-value", style: "color: var(--success, #16a34a);", "Completed" }
|
|
} else if status == "failed" {
|
|
span { class: "ac-detail-value", style: "color: var(--danger, #dc2626);", "Failed" }
|
|
} else if status == "running" {
|
|
span { class: "ac-detail-value", style: "color: var(--warning, #d97706);", "Running" }
|
|
} else {
|
|
span { class: "ac-detail-value", "{status}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|