From fca0f93033614e17aba9cc4f6325968376963883 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 12 Mar 2026 15:21:20 +0100 Subject: [PATCH] feat: pure Dioxus attack chain visualization, PDF report redesign, and orchestrator data fixes - 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 --- Cargo.lock | 216 +++ Cargo.toml | 1 + compliance-agent/Cargo.toml | 1 + compliance-agent/src/api/handlers/pentest.rs | 279 ++- compliance-agent/src/api/routes.rs | 6 +- compliance-agent/src/llm/client.rs | 11 +- compliance-agent/src/pentest/mod.rs | 2 + compliance-agent/src/pentest/orchestrator.rs | 45 +- compliance-agent/src/pentest/report.rs | 1507 +++++++++++++++ .../assets/attack-chain-viz.js | 234 --- compliance-dashboard/assets/main.css | 464 +++++ compliance-dashboard/src/app.rs | 3 - .../src/infrastructure/pentest.rs | 50 +- .../src/pages/dast_findings.rs | 104 +- .../src/pages/pentest_dashboard.rs | 135 +- .../src/pages/pentest_session.rs | 1673 ++++++++++------- docs/.vitepress/config.mts | 1 + docs/features/dast.md | 15 +- docs/features/pentest.md | 110 ++ 19 files changed, 3693 insertions(+), 1164 deletions(-) create mode 100644 compliance-agent/src/pentest/report.rs delete mode 100644 compliance-dashboard/assets/attack-chain-viz.js create mode 100644 docs/features/pentest.md diff --git a/Cargo.lock b/Cargo.lock index 4dc02f9..7a703ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -45,6 +62,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.8.2" @@ -391,6 +417,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cc" version = "1.2.56" @@ -566,6 +611,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -609,6 +664,7 @@ dependencies = [ "urlencoding", "uuid", "walkdir", + "zip", ] [[package]] @@ -836,6 +892,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "content_disposition" version = "0.4.0" @@ -941,6 +1003,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1127,6 +1204,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deflate64" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" + [[package]] name = "deranged" version = "0.5.8" @@ -1159,6 +1242,17 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1978,6 +2072,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2804,6 +2908,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "inventory" version = "0.3.22" @@ -3084,6 +3197,27 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -3280,6 +3414,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -3788,6 +3932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", + "hmac", ] [[package]] @@ -4857,6 +5002,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -6866,6 +7017,15 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.1" @@ -6935,6 +7095,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" @@ -6969,12 +7143,54 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.13.0", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index bc72548..34096b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,4 @@ hex = "0.4" uuid = { version = "1", features = ["v4", "serde"] } secrecy = { version = "0.10", features = ["serde"] } regex = "1" +zip = { version = "2", features = ["aes-crypto", "deflate"] } diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index a1e6eaf..bc2eac7 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -36,3 +36,4 @@ base64 = "0.22" urlencoding = "2" futures-util = "0.3" jsonwebtoken = "9" +zip = { workspace = true } diff --git a/compliance-agent/src/api/handlers/pentest.rs b/compliance-agent/src/api/handlers/pentest.rs index 4675db7..9e1873a 100644 --- a/compliance-agent/src/api/handlers/pentest.rs +++ b/compliance-agent/src/api/handlers/pentest.rs @@ -361,6 +361,59 @@ pub async fn session_stream( Ok(Sse::new(stream::iter(events))) } +/// POST /api/v1/pentest/sessions/:id/stop — Stop a running pentest session +#[tracing::instrument(skip_all, fields(session_id = %id))] +pub async fn stop_session( + Extension(agent): AgentExt, + Path(id): Path, +) -> Result>, (StatusCode, String)> { + let oid = mongodb::bson::oid::ObjectId::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?; + + let session = agent + .db + .pentest_sessions() + .find_one(doc! { "_id": oid }) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {e}")))? + .ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found".to_string()))?; + + if session.status != PentestStatus::Running { + return Err(( + StatusCode::BAD_REQUEST, + format!("Session is {}, not running", session.status), + )); + } + + agent + .db + .pentest_sessions() + .update_one( + doc! { "_id": oid }, + doc! { "$set": { + "status": "failed", + "completed_at": mongodb::bson::DateTime::now(), + "error_message": "Stopped by user", + }}, + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {e}")))?; + + let updated = agent + .db + .pentest_sessions() + .find_one(doc! { "_id": oid }) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {e}")))? + .ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found after update".to_string()))?; + + Ok(Json(ApiResponse { + data: updated, + total: None, + page: None, + })) +} + /// GET /api/v1/pentest/sessions/:id/attack-chain — Get attack chain nodes for a session #[tracing::instrument(skip_all, fields(session_id = %id))] pub async fn get_attack_chain( @@ -556,50 +609,62 @@ pub async fn get_session_findings( } #[derive(Deserialize)] -pub struct ExportParams { - #[serde(default = "default_export_format")] - pub format: String, +pub struct ExportBody { + pub password: String, + /// Requester display name (from auth) + #[serde(default)] + pub requester_name: String, + /// Requester email (from auth) + #[serde(default)] + pub requester_email: String, } -fn default_export_format() -> String { - "json".to_string() -} - -/// GET /api/v1/pentest/sessions/:id/export?format=json|markdown — Export a session report +/// POST /api/v1/pentest/sessions/:id/export — Export an encrypted pentest report archive #[tracing::instrument(skip_all, fields(session_id = %id))] pub async fn export_session_report( Extension(agent): AgentExt, Path(id): Path, - Query(params): Query, + Json(body): Json, ) -> Result { let oid = mongodb::bson::oid::ObjectId::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?; + if body.password.len() < 8 { + return Err(( + StatusCode::BAD_REQUEST, + "Password must be at least 8 characters".to_string(), + )); + } + // Fetch session let session = agent .db .pentest_sessions() .find_one(doc! { "_id": oid }) .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Database error: {e}"), - ) - })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {e}")))? .ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found".to_string()))?; - // Fetch messages - let messages: Vec = match agent - .db - .pentest_messages() - .find(doc! { "session_id": &id }) - .sort(doc! { "created_at": 1 }) - .await - { - Ok(cursor) => collect_cursor_async(cursor).await, - Err(_) => Vec::new(), + // Resolve target name + let target = if let Ok(tid) = mongodb::bson::oid::ObjectId::parse_str(&session.target_id) { + agent + .db + .dast_targets() + .find_one(doc! { "_id": tid }) + .await + .ok() + .flatten() + } else { + None }; + let target_name = target + .as_ref() + .map(|t| t.name.clone()) + .unwrap_or_else(|| "Unknown Target".to_string()); + let target_url = target + .as_ref() + .map(|t| t.base_url.clone()) + .unwrap_or_default(); // Fetch attack chain nodes let nodes: Vec = match agent @@ -618,155 +683,35 @@ pub async fn export_session_report( .db .dast_findings() .find(doc! { "session_id": &id }) - .sort(doc! { "created_at": -1 }) + .sort(doc! { "severity": -1, "created_at": -1 }) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; - // Compute severity counts - let critical = findings.iter().filter(|f| f.severity.to_string() == "critical").count(); - let high = findings.iter().filter(|f| f.severity.to_string() == "high").count(); - let medium = findings.iter().filter(|f| f.severity.to_string() == "medium").count(); - let low = findings.iter().filter(|f| f.severity.to_string() == "low").count(); - let info = findings.iter().filter(|f| f.severity.to_string() == "info").count(); + let ctx = crate::pentest::report::ReportContext { + session, + target_name, + target_url, + findings, + attack_chain: nodes, + requester_name: if body.requester_name.is_empty() { + "Unknown".to_string() + } else { + body.requester_name + }, + requester_email: body.requester_email, + }; - match params.format.as_str() { - "markdown" => { - let mut md = String::new(); - md.push_str("# Penetration Test Report\n\n"); + let report = crate::pentest::generate_encrypted_report(&ctx, &body.password) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; - // Executive summary - md.push_str("## Executive Summary\n\n"); - md.push_str(&format!("| Field | Value |\n")); - md.push_str("| --- | --- |\n"); - md.push_str(&format!("| **Session ID** | {} |\n", id)); - md.push_str(&format!("| **Status** | {} |\n", session.status)); - md.push_str(&format!("| **Strategy** | {} |\n", session.strategy)); - md.push_str(&format!("| **Target ID** | {} |\n", session.target_id)); - md.push_str(&format!( - "| **Started** | {} |\n", - session.started_at.to_rfc3339() - )); - if let Some(ref completed) = session.completed_at { - md.push_str(&format!( - "| **Completed** | {} |\n", - completed.to_rfc3339() - )); - } - md.push_str(&format!( - "| **Tool Invocations** | {} |\n", - session.tool_invocations - )); - md.push_str(&format!( - "| **Success Rate** | {:.1}% |\n", - session.success_rate() - )); - md.push('\n'); + let response = serde_json::json!({ + "archive_base64": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &report.archive), + "sha256": report.sha256, + "filename": format!("pentest-report-{id}.zip"), + }); - // Findings by severity - md.push_str("## Findings Summary\n\n"); - md.push_str(&format!( - "| Severity | Count |\n| --- | --- |\n| Critical | {} |\n| High | {} |\n| Medium | {} |\n| Low | {} |\n| Info | {} |\n| **Total** | **{}** |\n\n", - critical, high, medium, low, info, findings.len() - )); - - // Findings table - if !findings.is_empty() { - md.push_str("## Findings Detail\n\n"); - md.push_str("| # | Severity | Title | Endpoint | Exploitable |\n"); - md.push_str("| --- | --- | --- | --- | --- |\n"); - for (i, f) in findings.iter().enumerate() { - md.push_str(&format!( - "| {} | {} | {} | {} {} | {} |\n", - i + 1, - f.severity, - f.title, - f.method, - f.endpoint, - if f.exploitable { "Yes" } else { "No" }, - )); - } - md.push('\n'); - } - - // Attack chain timeline - if !nodes.is_empty() { - md.push_str("## Attack Chain Timeline\n\n"); - md.push_str("| # | Tool | Status | Findings | Reasoning |\n"); - md.push_str("| --- | --- | --- | --- | --- |\n"); - for (i, node) in nodes.iter().enumerate() { - let reasoning_short = if node.llm_reasoning.len() > 80 { - format!("{}...", &node.llm_reasoning[..80]) - } else { - node.llm_reasoning.clone() - }; - md.push_str(&format!( - "| {} | {} | {} | {} | {} |\n", - i + 1, - node.tool_name, - format!("{:?}", node.status).to_lowercase(), - node.findings_produced.len(), - reasoning_short, - )); - } - md.push('\n'); - } - - // Statistics - md.push_str("## Statistics\n\n"); - md.push_str(&format!("- **Total Findings:** {}\n", findings.len())); - md.push_str(&format!("- **Exploitable Findings:** {}\n", session.exploitable_count)); - md.push_str(&format!("- **Attack Chain Steps:** {}\n", nodes.len())); - md.push_str(&format!("- **Messages Exchanged:** {}\n", messages.len())); - md.push_str(&format!("- **Tool Invocations:** {}\n", session.tool_invocations)); - md.push_str(&format!("- **Tool Success Rate:** {:.1}%\n", session.success_rate())); - - Ok(( - StatusCode::OK, - [ - (axum::http::header::CONTENT_TYPE, "text/markdown; charset=utf-8"), - ], - md, - ) - .into_response()) - } - _ => { - // JSON format - let report = serde_json::json!({ - "session": { - "id": id, - "target_id": session.target_id, - "repo_id": session.repo_id, - "status": session.status, - "strategy": session.strategy, - "started_at": session.started_at.to_rfc3339(), - "completed_at": session.completed_at.map(|d| d.to_rfc3339()), - "tool_invocations": session.tool_invocations, - "tool_successes": session.tool_successes, - "success_rate": session.success_rate(), - "findings_count": session.findings_count, - "exploitable_count": session.exploitable_count, - }, - "findings": findings, - "attack_chain": nodes, - "messages": messages, - "summary": { - "total_findings": findings.len(), - "severity_distribution": { - "critical": critical, - "high": high, - "medium": medium, - "low": low, - "info": info, - }, - "attack_chain_steps": nodes.len(), - "messages_exchanged": messages.len(), - }, - }); - - Ok(Json(report).into_response()) - } - } + Ok(Json(response).into_response()) } diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index b805b7e..f878d7e 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -112,6 +112,10 @@ pub fn build_router() -> Router { "/api/v1/pentest/sessions/{id}/chat", post(handlers::pentest::send_message), ) + .route( + "/api/v1/pentest/sessions/{id}/stop", + post(handlers::pentest::stop_session), + ) .route( "/api/v1/pentest/sessions/{id}/stream", get(handlers::pentest::session_stream), @@ -130,7 +134,7 @@ pub fn build_router() -> Router { ) .route( "/api/v1/pentest/sessions/{id}/export", - get(handlers::pentest::export_session_report), + post(handlers::pentest::export_session_report), ) .route("/api/v1/pentest/stats", get(handlers::pentest::pentest_stats)) // Webhook endpoints (proxied through dashboard) diff --git a/compliance-agent/src/llm/client.rs b/compliance-agent/src/llm/client.rs index 826bda5..b6bc657 100644 --- a/compliance-agent/src/llm/client.rs +++ b/compliance-agent/src/llm/client.rs @@ -117,7 +117,8 @@ pub struct ToolCallRequestFunction { #[derive(Debug, Clone)] pub enum LlmResponse { Content(String), - ToolCalls(Vec), + /// Tool calls with optional reasoning text from the LLM + ToolCalls { calls: Vec, reasoning: String }, } // ── Embedding types ──────────────────────────────────────────── @@ -210,7 +211,7 @@ impl LlmClient { self.send_chat_request(&request_body).await.map(|resp| { match resp { LlmResponse::Content(c) => c, - LlmResponse::ToolCalls(_) => String::new(), // shouldn't happen without tools + LlmResponse::ToolCalls { .. } => String::new(), // shouldn't happen without tools } }) } @@ -243,7 +244,7 @@ impl LlmClient { self.send_chat_request(&request_body).await.map(|resp| { match resp { LlmResponse::Content(c) => c, - LlmResponse::ToolCalls(_) => String::new(), + LlmResponse::ToolCalls { .. } => String::new(), } }) } @@ -337,7 +338,9 @@ impl LlmClient { } }) .collect(); - return Ok(LlmResponse::ToolCalls(calls)); + // Capture any reasoning text the LLM included alongside tool calls + let reasoning = choice.message.content.clone().unwrap_or_default(); + return Ok(LlmResponse::ToolCalls { calls, reasoning }); } } diff --git a/compliance-agent/src/pentest/mod.rs b/compliance-agent/src/pentest/mod.rs index ba0e0c8..934315a 100644 --- a/compliance-agent/src/pentest/mod.rs +++ b/compliance-agent/src/pentest/mod.rs @@ -1,3 +1,5 @@ pub mod orchestrator; +pub mod report; pub use orchestrator::PentestOrchestrator; +pub use report::generate_encrypted_report; diff --git a/compliance-agent/src/pentest/orchestrator.rs b/compliance-agent/src/pentest/orchestrator.rs index 482902f..184db47 100644 --- a/compliance-agent/src/pentest/orchestrator.rs +++ b/compliance-agent/src/pentest/orchestrator.rs @@ -213,7 +213,7 @@ impl PentestOrchestrator { } break; } - LlmResponse::ToolCalls(tool_calls) => { + LlmResponse::ToolCalls { calls: tool_calls, reasoning } => { let tc_requests: Vec = tool_calls .iter() .map(|tc| ToolCallRequest { @@ -229,7 +229,7 @@ impl PentestOrchestrator { messages.push(ChatMessage { role: "assistant".to_string(), - content: None, + content: if reasoning.is_empty() { None } else { Some(reasoning.clone()) }, tool_calls: Some(tc_requests), tool_call_id: None, }); @@ -245,7 +245,7 @@ impl PentestOrchestrator { node_id.clone(), tc.name.clone(), tc.arguments.clone(), - String::new(), + reasoning.clone(), ); // Link to previous iteration's nodes node.parent_node_ids = prev_node_ids.clone(); @@ -267,11 +267,15 @@ impl PentestOrchestrator { let findings_count = result.findings.len() as u32; total_findings += findings_count; + let mut finding_ids: Vec = Vec::new(); for mut finding in result.findings { finding.scan_run_id = session_id.clone(); finding.session_id = Some(session_id.clone()); - let _ = + let insert_result = self.db.dast_findings().insert_one(&finding).await; + if let Ok(res) = &insert_result { + finding_ids.push(res.inserted_id.as_object_id().map(|oid| oid.to_hex()).unwrap_or_default()); + } let _ = self.event_tx.send(PentestEvent::Finding { finding_id: finding @@ -283,12 +287,38 @@ impl PentestOrchestrator { }); } + // Compute risk score based on findings severity + let risk_score: Option = if findings_count > 0 { + Some(std::cmp::min( + 100, + (findings_count as u8).saturating_mul(15).saturating_add(20), + )) + } else { + None + }; + let _ = self.event_tx.send(PentestEvent::ToolComplete { node_id: node_id.clone(), summary: result.summary.clone(), findings_count, }); + let finding_ids_bson: Vec = finding_ids + .iter() + .map(|id| mongodb::bson::Bson::String(id.clone())) + .collect(); + + let mut update_doc = doc! { + "status": "completed", + "tool_output": mongodb::bson::to_bson(&result.data) + .unwrap_or(mongodb::bson::Bson::Null), + "completed_at": mongodb::bson::DateTime::now(), + "findings_produced": finding_ids_bson, + }; + if let Some(rs) = risk_score { + update_doc.insert("risk_score", rs as i32); + } + let _ = self .db .attack_chain_nodes() @@ -297,12 +327,7 @@ impl PentestOrchestrator { "session_id": &session_id, "node_id": &node_id, }, - doc! { "$set": { - "status": "completed", - "tool_output": mongodb::bson::to_bson(&result.data) - .unwrap_or(mongodb::bson::Bson::Null), - "completed_at": mongodb::bson::DateTime::now(), - }}, + doc! { "$set": update_doc }, ) .await; diff --git a/compliance-agent/src/pentest/report.rs b/compliance-agent/src/pentest/report.rs new file mode 100644 index 0000000..37ddec6 --- /dev/null +++ b/compliance-agent/src/pentest/report.rs @@ -0,0 +1,1507 @@ +use std::io::{Cursor, Write}; + +use compliance_core::models::dast::DastFinding; +use compliance_core::models::pentest::{AttackChainNode, PentestSession}; +use sha2::{Digest, Sha256}; +use zip::write::SimpleFileOptions; +use zip::AesMode; + +/// Report archive with metadata +pub struct ReportArchive { + /// The password-protected ZIP bytes + pub archive: Vec, + /// SHA-256 hex digest of the archive + pub sha256: String, +} + +/// Report context gathered from the database +pub struct ReportContext { + pub session: PentestSession, + pub target_name: String, + pub target_url: String, + pub findings: Vec, + pub attack_chain: Vec, + pub requester_name: String, + pub requester_email: String, +} + +/// Generate a password-protected ZIP archive containing the pentest report. +/// +/// The archive contains: +/// - `report.html` — Professional pentest report +/// - `findings.json` — Raw findings data +/// - `attack-chain.json` — Attack chain timeline +/// +/// Files are encrypted with AES-256 inside the ZIP (standard WinZip AES format, +/// supported by 7-Zip, WinRAR, macOS Archive Utility, etc.). +pub fn generate_encrypted_report( + ctx: &ReportContext, + password: &str, +) -> Result { + let zip_bytes = build_zip(ctx, password).map_err(|e| format!("Failed to create archive: {e}"))?; + + let mut hasher = Sha256::new(); + hasher.update(&zip_bytes); + let sha256 = hex::encode(hasher.finalize()); + + Ok(ReportArchive { archive: zip_bytes, sha256 }) +} + +fn build_zip(ctx: &ReportContext, password: &str) -> Result, zip::result::ZipError> { + let buf = Cursor::new(Vec::new()); + let mut zip = zip::ZipWriter::new(buf); + + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .with_aes_encryption(AesMode::Aes256, password); + + // report.html + let html = build_html_report(ctx); + zip.start_file("report.html", options.clone())?; + zip.write_all(html.as_bytes())?; + + // findings.json + let findings_json = + serde_json::to_string_pretty(&ctx.findings).unwrap_or_else(|_| "[]".to_string()); + zip.start_file("findings.json", options.clone())?; + zip.write_all(findings_json.as_bytes())?; + + // attack-chain.json + let chain_json = + serde_json::to_string_pretty(&ctx.attack_chain).unwrap_or_else(|_| "[]".to_string()); + zip.start_file("attack-chain.json", options)?; + zip.write_all(chain_json.as_bytes())?; + + let cursor = zip.finish()?; + Ok(cursor.into_inner()) +} + +fn build_html_report(ctx: &ReportContext) -> String { + let session = &ctx.session; + let session_id = session + .id + .map(|oid| oid.to_hex()) + .unwrap_or_else(|| "-".to_string()); + let date_str = session.started_at.format("%B %d, %Y at %H:%M UTC").to_string(); + let date_short = session.started_at.format("%B %d, %Y").to_string(); + let completed_str = session + .completed_at + .map(|d| d.format("%B %d, %Y at %H:%M UTC").to_string()) + .unwrap_or_else(|| "In Progress".to_string()); + + let critical = ctx.findings.iter().filter(|f| f.severity.to_string() == "critical").count(); + let high = ctx.findings.iter().filter(|f| f.severity.to_string() == "high").count(); + let medium = ctx.findings.iter().filter(|f| f.severity.to_string() == "medium").count(); + let low = ctx.findings.iter().filter(|f| f.severity.to_string() == "low").count(); + let info = ctx.findings.iter().filter(|f| f.severity.to_string() == "info").count(); + let exploitable = ctx.findings.iter().filter(|f| f.exploitable).count(); + let total = ctx.findings.len(); + + let overall_risk = if critical > 0 { + "CRITICAL" + } else if high > 0 { + "HIGH" + } else if medium > 0 { + "MEDIUM" + } else if low > 0 { + "LOW" + } else { + "INFORMATIONAL" + }; + + let risk_color = match overall_risk { + "CRITICAL" => "#991b1b", + "HIGH" => "#c2410c", + "MEDIUM" => "#a16207", + "LOW" => "#1d4ed8", + _ => "#4b5563", + }; + + // Risk score 0-100 + let risk_score: usize = std::cmp::min( + 100, + critical * 25 + high * 15 + medium * 8 + low * 3 + info * 1, + ); + + // Collect unique tool names used + let tool_names: Vec = { + let mut names: Vec = ctx + .attack_chain + .iter() + .map(|n| n.tool_name.clone()) + .collect(); + names.sort(); + names.dedup(); + names + }; + + // Severity distribution bar + let severity_bar = if total > 0 { + let crit_pct = (critical as f64 / total as f64 * 100.0) as usize; + let high_pct = (high as f64 / total as f64 * 100.0) as usize; + let med_pct = (medium as f64 / total as f64 * 100.0) as usize; + let low_pct = (low as f64 / total as f64 * 100.0) as usize; + let info_pct = 100_usize.saturating_sub(crit_pct + high_pct + med_pct + low_pct); + + let mut bar = String::from(r#"
"#); + if critical > 0 { + bar.push_str(&format!( + r#"
{}
"#, + std::cmp::max(crit_pct, 4), critical + )); + } + if high > 0 { + bar.push_str(&format!( + r#"
{}
"#, + std::cmp::max(high_pct, 4), high + )); + } + if medium > 0 { + bar.push_str(&format!( + r#"
{}
"#, + std::cmp::max(med_pct, 4), medium + )); + } + if low > 0 { + bar.push_str(&format!( + r#"
{}
"#, + std::cmp::max(low_pct, 4), low + )); + } + if info > 0 { + bar.push_str(&format!( + r#"
{}
"#, + std::cmp::max(info_pct, 4), info + )); + } + bar.push_str("
"); + bar.push_str(r#"
"#); + if critical > 0 { bar.push_str(r#" Critical"#); } + if high > 0 { bar.push_str(r#" High"#); } + if medium > 0 { bar.push_str(r#" Medium"#); } + if low > 0 { bar.push_str(r#" Low"#); } + if info > 0 { bar.push_str(r#" Info"#); } + bar.push_str("
"); + bar + } else { + String::new() + }; + + // Build findings grouped by severity + let severity_order = ["critical", "high", "medium", "low", "info"]; + let severity_labels = ["Critical", "High", "Medium", "Low", "Informational"]; + let severity_colors = ["#991b1b", "#c2410c", "#a16207", "#1d4ed8", "#4b5563"]; + + let mut findings_html = String::new(); + let mut finding_num = 0usize; + + for (si, &sev_key) in severity_order.iter().enumerate() { + let sev_findings: Vec<&DastFinding> = ctx + .findings + .iter() + .filter(|f| f.severity.to_string() == sev_key) + .collect(); + if sev_findings.is_empty() { + continue; + } + + findings_html.push_str(&format!( + r#"

{label} ({count})

"#, + color = severity_colors[si], + label = severity_labels[si], + count = sev_findings.len(), + )); + + for f in sev_findings { + finding_num += 1; + let sev_color = severity_colors[si]; + let exploitable_badge = if f.exploitable { + r#"EXPLOITABLE"# + } else { + "" + }; + let cwe_cell = f + .cwe + .as_deref() + .map(|c| format!("CWE{}", html_escape(c))) + .unwrap_or_default(); + let param_row = f + .parameter + .as_deref() + .map(|p| format!("Parameter{}", html_escape(p))) + .unwrap_or_default(); + let remediation = f + .remediation + .as_deref() + .unwrap_or("Refer to industry best practices for this vulnerability class."); + + let evidence_html = if f.evidence.is_empty() { + String::new() + } else { + let mut eh = String::from(r#"
Evidence
"#); + for ev in &f.evidence { + let payload_info = ev + .payload + .as_deref() + .map(|p| format!("
Payload: {}", html_escape(p))) + .unwrap_or_default(); + eh.push_str(&format!( + "", + html_escape(&ev.request_method), + html_escape(&ev.request_url), + ev.response_status, + ev.response_snippet + .as_deref() + .map(|s| html_escape(s)) + .unwrap_or_default(), + payload_info, + )); + } + eh.push_str("
RequestStatusDetails
{} {}{}{}{}
"); + eh + }; + + let linked_sast = f + .linked_sast_finding_id + .as_deref() + .map(|id| { + format!( + r#"
Correlated SAST Finding: {id}
"# + ) + }) + .unwrap_or_default(); + + findings_html.push_str(&format!( + r#" +
+
+ F-{num:03} + {title} + {exploitable_badge} +
+ + + + {param_row} + {cwe_cell} +
Type{vuln_type}
Endpoint{method} {endpoint}
+
{description}
+ {evidence_html} + {linked_sast} +
+
Recommendation
+ {remediation} +
+
+ "#, + num = finding_num, + title = html_escape(&f.title), + vuln_type = f.vuln_type, + method = f.method, + endpoint = html_escape(&f.endpoint), + description = html_escape(&f.description), + )); + } + } + + // Build attack chain — group by phase using BFS + let mut chain_html = String::new(); + if !ctx.attack_chain.is_empty() { + // Compute phases via BFS from root nodes + let mut phase_map: std::collections::HashMap = std::collections::HashMap::new(); + let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); + + for node in &ctx.attack_chain { + if node.parent_node_ids.is_empty() { + let nid = node.node_id.clone(); + if !nid.is_empty() { + phase_map.insert(nid.clone(), 0); + queue.push_back(nid); + } + } + } + + while let Some(nid) = queue.pop_front() { + let parent_phase = phase_map.get(&nid).copied().unwrap_or(0); + for node in &ctx.attack_chain { + if node.parent_node_ids.contains(&nid) { + let child_id = node.node_id.clone(); + if !child_id.is_empty() && !phase_map.contains_key(&child_id) { + phase_map.insert(child_id.clone(), parent_phase + 1); + queue.push_back(child_id); + } + } + } + } + + // Assign phase 0 to any unassigned nodes + for node in &ctx.attack_chain { + let nid = node.node_id.clone(); + if !nid.is_empty() && !phase_map.contains_key(&nid) { + phase_map.insert(nid, 0); + } + } + + // Group nodes by phase + let max_phase = phase_map.values().copied().max().unwrap_or(0); + let phase_labels = ["Reconnaissance", "Enumeration", "Exploitation", "Validation", "Post-Exploitation"]; + + for phase_idx in 0..=max_phase { + let phase_nodes: Vec<&AttackChainNode> = ctx + .attack_chain + .iter() + .filter(|n| { + let nid = n.node_id.clone(); + phase_map.get(&nid).copied().unwrap_or(0) == phase_idx + }) + .collect(); + + if phase_nodes.is_empty() { + continue; + } + + let label = if phase_idx < phase_labels.len() { + phase_labels[phase_idx] + } else { + "Additional Testing" + }; + + chain_html.push_str(&format!( + r#"
+
+ Phase {} + {} + {} step{} +
+
"#, + phase_idx + 1, + label, + phase_nodes.len(), + if phase_nodes.len() == 1 { "" } else { "s" }, + )); + + for (i, node) in phase_nodes.iter().enumerate() { + let status_label = format!("{:?}", node.status); + let status_class = match status_label.to_lowercase().as_str() { + "completed" => "step-completed", + "failed" => "step-failed", + _ => "step-running", + }; + let findings_badge = if !node.findings_produced.is_empty() { + format!( + r#"{} finding{}"#, + node.findings_produced.len(), + if node.findings_produced.len() == 1 { "" } else { "s" }, + ) + } else { + String::new() + }; + let risk_badge = node.risk_score.map(|r| { + let risk_class = if r >= 70 { "risk-high" } else if r >= 40 { "risk-med" } else { "risk-low" }; + format!(r#"Risk: {r}"#) + }).unwrap_or_default(); + + let reasoning_html = if node.llm_reasoning.is_empty() { + String::new() + } else { + format!( + r#"
{}
"#, + html_escape(&node.llm_reasoning) + ) + }; + + chain_html.push_str(&format!( + r#"
+
{num}
+
+
+
+ {tool_name} + {status_label} + {findings_badge} + {risk_badge} +
+ {reasoning_html} +
+
"#, + num = i + 1, + tool_name = html_escape(&node.tool_name), + )); + } + + chain_html.push_str("
"); + } + } + + // Tools methodology table + let tools_table: String = tool_names + .iter() + .enumerate() + .map(|(i, t)| { + let category = tool_category(t); + format!( + "{}{}{}", + i + 1, + html_escape(t), + category, + ) + }) + .collect::>() + .join("\n"); + + // Table of contents + let toc_findings_sub = if !ctx.findings.is_empty() { + let mut sub = String::new(); + let mut fnum = 0usize; + for (si, &sev_key) in severity_order.iter().enumerate() { + let count = ctx.findings.iter().filter(|f| f.severity.to_string() == sev_key).count(); + if count == 0 { continue; } + for f in ctx.findings.iter().filter(|f| f.severity.to_string() == sev_key) { + fnum += 1; + sub.push_str(&format!( + r#"
F-{:03} — {}
"#, + fnum, + html_escape(&f.title), + )); + } + } + sub + } else { + String::new() + }; + + format!( + r##" + + + + +Penetration Test Report — {target_name} + + + + + + + + +
+ + + + + + + + + + + + + + +
CONFIDENTIAL
+ +
Penetration Test Report
+
{target_name}
+ +
+ +
+ Report ID: {session_id}
+ Date: {date_short}
+ Target: {target_url}
+ Prepared for: {requester_name} ({requester_email}) +
+ + +
+ + +
+ +
+

Table of Contents

+
1Executive Summary
+
2Scope & Methodology
+
3Findings ({total_findings})
+ {toc_findings_sub} +
4Attack Chain Timeline
+
5Appendix
+
+ + +

1. Executive Summary

+ +
+
+
+
+
+
{risk_score} / 100
+
+
+
Overall Risk: {overall_risk}
+
+ Based on {total_findings} finding{findings_plural} identified across the target application. +
+
+
+ +
+
+
{total_findings}
+
Total Findings
+
+
+
{critical_high}
+
Critical / High
+
+
+
{exploitable_count}
+
Exploitable
+
+
+
{tool_count}
+
Tools Used
+
+
+ +

Severity Distribution

+{severity_bar} + +

+ This report presents the results of an automated penetration test conducted against + {target_name} ({target_url}) using the Compliance Scanner + AI-powered testing engine. A total of {total_findings} vulnerabilities were + identified, of which {exploitable_count} were confirmed exploitable with + working proof-of-concept payloads. The assessment employed {tool_count} security tools + across {tool_invocations} invocations ({success_rate:.0}% success rate). +

+ + +
+

2. Scope & Methodology

+ +

+ The assessment was performed using an AI-driven orchestrator that autonomously selects and + executes security testing tools based on the target's attack surface, technology stack, and + any available static analysis (SAST) findings and SBOM data. +

+ +

Engagement Details

+ + + + + + + + +
Target{target_name}
URL{target_url}
Strategy{strategy}
Status{status}
Started{date_str}
Completed{completed_str}
Tool Invocations{tool_invocations} ({tool_successes} successful, {success_rate:.1}% success rate)
+ +

Tools Employed

+ + + {tools_table} +
#ToolCategory
+ + +
+

3. Findings

+ +{findings_section} + + +
+

4. Attack Chain Timeline

+ +

+ The following sequence shows each tool invocation made by the AI orchestrator during the assessment, + grouped by phase. Each step includes the tool's name, execution status, and the AI's reasoning + for choosing that action. +

+ +
+ {chain_section} +
+ + +
+

5. Appendix

+ +

Severity Definitions

+ + + + + + +
CriticalVulnerabilities that can be exploited remotely without authentication to execute arbitrary code, exfiltrate sensitive data, or fully compromise the system.
HighVulnerabilities that allow significant unauthorized access or data exposure, typically requiring minimal user interaction or privileges.
MediumVulnerabilities that may lead to limited data exposure or require specific conditions to exploit, but still represent meaningful risk.
LowMinor issues with limited direct impact. May contribute to broader attack chains or indicate defense-in-depth weaknesses.
InfoObservations and best-practice recommendations that do not represent direct security vulnerabilities.
+ +

Disclaimer

+

+ This report was generated by an automated AI-powered penetration testing engine. While the system + employs advanced techniques to identify vulnerabilities, no automated assessment can guarantee + complete coverage. The results should be reviewed by qualified security professionals and validated + in the context of the target application's threat model. Findings are point-in-time observations + and may change as the application evolves. +

+ + + + +
+ + +"##, + target_name = html_escape(&ctx.target_name), + target_url = html_escape(&ctx.target_url), + session_id = html_escape(&session_id), + date_str = date_str, + date_short = date_short, + completed_str = completed_str, + requester_name = html_escape(&ctx.requester_name), + requester_email = html_escape(&ctx.requester_email), + risk_color = risk_color, + risk_score = risk_score, + overall_risk = overall_risk, + total_findings = total, + findings_plural = if total == 1 { "" } else { "s" }, + critical_high = format!("{} / {}", critical, high), + exploitable_count = exploitable, + tool_count = tool_names.len(), + strategy = session.strategy, + status = session.status, + tool_invocations = session.tool_invocations, + tool_successes = session.tool_successes, + success_rate = session.success_rate(), + severity_bar = severity_bar, + tools_table = tools_table, + toc_findings_sub = toc_findings_sub, + findings_section = if ctx.findings.is_empty() { + "

No vulnerabilities were identified during this assessment.

".to_string() + } else { + findings_html + }, + chain_section = if ctx.attack_chain.is_empty() { + "

No attack chain steps recorded.

".to_string() + } else { + chain_html + }, + ) +} + +fn tool_category(tool_name: &str) -> &'static str { + let name = tool_name.to_lowercase(); + if name.contains("nmap") || name.contains("port") { return "Network Reconnaissance"; } + if name.contains("nikto") || name.contains("header") { return "Web Server Analysis"; } + if name.contains("zap") || name.contains("spider") || name.contains("crawl") { return "Web Application Scanning"; } + if name.contains("sqlmap") || name.contains("sqli") || name.contains("sql") { return "SQL Injection Testing"; } + if name.contains("xss") || name.contains("cross-site") { return "Cross-Site Scripting Testing"; } + if name.contains("dir") || name.contains("brute") || name.contains("fuzz") || name.contains("gobuster") { return "Directory Enumeration"; } + if name.contains("ssl") || name.contains("tls") || name.contains("cert") { return "SSL/TLS Analysis"; } + if name.contains("api") || name.contains("endpoint") { return "API Security Testing"; } + if name.contains("auth") || name.contains("login") || name.contains("credential") { return "Authentication Testing"; } + if name.contains("cors") { return "CORS Testing"; } + if name.contains("csrf") { return "CSRF Testing"; } + if name.contains("nuclei") || name.contains("template") { return "Vulnerability Scanning"; } + if name.contains("whatweb") || name.contains("tech") || name.contains("wappalyzer") { return "Technology Fingerprinting"; } + "Security Testing" +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} diff --git a/compliance-dashboard/assets/attack-chain-viz.js b/compliance-dashboard/assets/attack-chain-viz.js deleted file mode 100644 index f2d9edf..0000000 --- a/compliance-dashboard/assets/attack-chain-viz.js +++ /dev/null @@ -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)); - } - }; -})(); diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index 8a8cf52..2470dbc 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -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); +} diff --git a/compliance-dashboard/src/app.rs b/compliance-dashboard/src/app.rs index b87fc35..eb850ea 100644 --- a/compliance-dashboard/src/app.rs +++ b/compliance-dashboard/src/app.rs @@ -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:: {} } } diff --git a/compliance-dashboard/src/infrastructure/pentest.rs b/compliance-dashboard/src/infrastructure/pentest.rs index 4fd8a72..a9605f2 100644 --- a/compliance-dashboard/src/infrastructure/pentest.rs +++ b/compliance-dashboard/src/infrastructure/pentest.rs @@ -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 { + password: String, + requester_name: String, + requester_email: String, +) -> Result { 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) diff --git a/compliance-dashboard/src/pages/dast_findings.rs b/compliance-dashboard/src/pages/dast_findings.rs index 4fa4cac..422f183 100644 --- a/compliance-dashboard/src/pages/dast_findings.rs +++ b/compliance-dashboard/src/pages/dast_findings.rs @@ -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(); diff --git a/compliance-dashboard/src/pages/pentest_dashboard.rs b/compliance-dashboard/src/pages/pentest_dashboard.rs index 0159f4c..a8643b4 100644 --- a/compliance-dashboard/src/pages/pentest_dashboard.rs +++ b/compliance-dashboard/src/pages/pentest_dashboard.rs @@ -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}" } } } } diff --git a/compliance-dashboard/src/pages/pentest_session.rs b/compliance-dashboard/src/pages/pentest_session.rs index f8a152c..bc92112 100644 --- a/compliance-dashboard/src/pages/pentest_session.rs +++ b/compliance-dashboard/src/pages/pentest_session.rs @@ -1,167 +1,17 @@ +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_messages, - fetch_pentest_session, send_pentest_message, + export_pentest_report, fetch_attack_chain, fetch_pentest_findings, fetch_pentest_session, }; -/// Simple markdown-to-HTML converter for assistant messages. -/// Handles headers, bold, italic, code blocks, inline code, and lists. -fn markdown_to_html(input: &str) -> String { - let mut html = String::new(); - let mut in_code_block = false; - let mut in_list = false; - - for line in input.lines() { - if line.starts_with("```") { - if in_code_block { - html.push_str(""); - in_code_block = false; - } else { - if in_list { - html.push_str(""); - in_list = false; - } - html.push_str("
");
-                in_code_block = true;
-            }
-            continue;
-        }
-
-        if in_code_block {
-            // Escape HTML inside code blocks
-            let escaped = line
-                .replace('&', "&")
-                .replace('<', "<")
-                .replace('>', ">");
-            html.push_str(&escaped);
-            html.push('\n');
-            continue;
-        }
-
-        let trimmed = line.trim();
-
-        // Blank line — close list if open
-        if trimmed.is_empty() {
-            if in_list {
-                html.push_str("");
-                in_list = false;
-            }
-            html.push_str("
"); - continue; - } - - // Lists - if trimmed.starts_with("- ") || trimmed.starts_with("* ") { - if !in_list { - html.push_str("
    "); - in_list = true; - } - let content = inline_format(&trimmed[2..]); - html.push_str(&format!("
  • {content}
  • ")); - continue; - } - - // Numbered lists - if trimmed.len() > 2 { - let mut chars = trimmed.chars(); - let first = chars.next(); - let second = chars.next(); - if first.map(|c| c.is_ascii_digit()).unwrap_or(false) - && (second == Some('.') || second == Some(')')) - { - let rest = &trimmed[2..].trim_start(); - if !in_list { - html.push_str("
      "); - in_list = true; - } - let content = inline_format(rest); - html.push_str(&format!("
    • {content}
    • ")); - continue; - } - } - - // Close list if we're no longer in one - if in_list { - html.push_str("
    "); - in_list = false; - } - - // Headers - if trimmed.starts_with("### ") { - let content = inline_format(&trimmed[4..]); - html.push_str(&format!( - "

    {content}

    " - )); - } else if trimmed.starts_with("## ") { - let content = inline_format(&trimmed[3..]); - html.push_str(&format!( - "

    {content}

    " - )); - } else if trimmed.starts_with("# ") { - let content = inline_format(&trimmed[2..]); - html.push_str(&format!( - "

    {content}

    " - )); - } else { - let content = inline_format(trimmed); - html.push_str(&format!("

    {content}

    ")); - } - } - - if in_list { - html.push_str("
"); - } - if in_code_block { - html.push_str("
"); - } - - html -} - -/// Handle inline formatting: bold, italic, inline code -fn inline_format(text: &str) -> String { - let mut result = text - .replace('&', "&") - .replace('<', "<") - .replace('>', ">"); - - // Inline code (backticks) - while let Some(start) = result.find('`') { - if let Some(end) = result[start + 1..].find('`') { - let code_content = &result[start + 1..start + 1 + end].to_string(); - let replacement = format!( - "{code_content}" - ); - result = format!("{}{}{}", &result[..start], replacement, &result[start + 2 + end..]); - } else { - break; - } - } - - // Bold (**text**) - while let Some(start) = result.find("**") { - if let Some(end) = result[start + 2..].find("**") { - let bold_content = &result[start + 2..start + 2 + end].to_string(); - result = format!( - "{}{bold_content}{}", - &result[..start], - &result[start + 4 + end..] - ); - } else { - break; - } - } - - result -} - #[component] pub fn PentestSessionPage(session_id: String) -> Element { - let sid = session_id.clone(); let sid_for_session = session_id.clone(); let sid_for_findings = session_id.clone(); let sid_for_chain = session_id.clone(); @@ -170,10 +20,6 @@ pub fn PentestSessionPage(session_id: String) -> Element { let id = sid_for_session.clone(); async move { fetch_pentest_session(id).await.ok() } }); - let mut messages_res = use_resource(move || { - let id = sid.clone(); - async move { fetch_pentest_messages(id).await.ok() } - }); let mut findings = use_resource(move || { let id = sid_for_findings.clone(); async move { fetch_pentest_findings(id).await.ok() } @@ -183,209 +29,123 @@ pub fn PentestSessionPage(session_id: String) -> Element { async move { fetch_attack_chain(id).await.ok() } }); - let mut input_text = use_signal(String::new); - let mut sending = use_signal(|| false); - let mut right_tab = use_signal(|| "findings".to_string()); - let mut chain_view = use_signal(|| "list".to_string()); + 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 poll_gen = use_signal(|| 0u32); // incremented to trigger re-poll + 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 status - let session_status = { - let s = session.read(); - match &*s { - Some(Some(resp)) => resp - .data - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(), - _ => "unknown".to_string(), + // 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"; - // Continuous polling: re-fetch all data every 3s while running + // Poll while running use_effect(move || { - let _gen = *poll_gen.read(); // subscribe to changes + 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; - messages_res.restart(); findings.restart(); attack_chain.restart(); session.restart(); - // Bump generation to trigger the next poll cycle let next = poll_gen.peek().wrapping_add(1); poll_gen.set(next); }); } }); - // Load attack chain into vis-network when graph tab is active and data is available - // Use a separate effect that reads the reactive resources directly - use_effect(move || { - let tab = right_tab.read().clone(); - let view = chain_view.read().clone(); - let chain = attack_chain.read().clone(); - - if tab == "chain" && view == "graph" { - if let Some(Some(data)) = &chain { - if !data.data.is_empty() { - let nodes_json = - serde_json::to_string(&data.data).unwrap_or_else(|_| "[]".to_string()); - // Small delay to ensure the DOM container exists - spawn(async move { - #[cfg(feature = "web")] - gloo_timers::future::TimeoutFuture::new(100).await; - #[cfg(not(feature = "web"))] - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let js = format!( - r#"if (window.__loadAttackChain) {{ window.__loadAttackChain({nodes_json}); }}"# - ); - document::eval(&js); - }); - } - } - } - }); - - // Send message handler - let sid_for_send = session_id.clone(); - let mut do_send = move || { - let text = input_text.read().trim().to_string(); - if text.is_empty() || *sending.read() { - return; - } - let sid = sid_for_send.clone(); - input_text.set(String::new()); - sending.set(true); - spawn(async move { - let _ = send_pentest_message(sid, text).await; - sending.set(false); - messages_res.restart(); - }); - }; - - let mut do_send_click = do_send.clone(); - - // Export handlers - let sid_for_export = session_id.clone(); - let do_export_md = move |_| { - let sid = sid_for_export.clone(); - exporting.set(true); - spawn(async move { - match export_pentest_report(sid.clone(), "markdown".to_string()).await { - Ok(content) => { - let escaped = content - .replace('\\', "\\\\") - .replace('`', "\\`") - .replace("${", "\\${"); - let js = format!( - r#" - var blob = new Blob([`{escaped}`], {{ type: 'text/markdown' }}); - var url = URL.createObjectURL(blob); - var a = document.createElement('a'); - a.href = url; - a.download = 'pentest-report-{sid}.md'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - "# - ); - document::eval(&js); - } - Err(e) => { - tracing::warn!("Export failed: {e}"); - } - } - exporting.set(false); - }); - }; - - let sid_for_export_json = session_id.clone(); - let do_export_json = move |_| { - let sid = sid_for_export_json.clone(); - exporting.set(true); - spawn(async move { - match export_pentest_report(sid.clone(), "json".to_string()).await { - Ok(content) => { - let escaped = content - .replace('\\', "\\\\") - .replace('`', "\\`") - .replace("${", "\\${"); - let js = format!( - r#" - var blob = new Blob([`{escaped}`], {{ type: 'application/json' }}); - var url = URL.createObjectURL(blob); - var a = document.createElement('a'); - a.href = url; - a.download = 'pentest-report-{sid}.json'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - "# - ); - document::eval(&js); - } - Err(e) => { - tracing::warn!("Export failed: {e}"); - } - } - exporting.set(false); - }); - }; - - // Session header info - let target_name = { - let s = session.read(); - match &*s { - Some(Some(resp)) => resp - .data - .get("target_name") - .and_then(|v| v.as_str()) - .unwrap_or("Pentest Session") - .to_string(), - _ => "Pentest Session".to_string(), - } - }; - - let strategy = { - let s = session.read(); - match &*s { - Some(Some(resp)) => resp - .data - .get("strategy") - .and_then(|v| v.as_str()) - .unwrap_or("-") - .to_string(), - _ => "-".to_string(), - } - }; - - let header_tool_count = { - let s = session.read(); - match &*s { - Some(Some(resp)) => resp - .data - .get("tool_invocations") - .and_then(|v| v.as_u64()) - .unwrap_or(0), - _ => 0, - } - }; - - let header_findings_count = { + // 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)) => data.total.unwrap_or(0), - _ => 0, + 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), } }; @@ -397,6 +157,60 @@ pub fn PentestSessionPage(session_id: String) -> Element { _ => "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 { @@ -408,7 +222,7 @@ pub fn PentestSessionPage(session_id: String) -> Element { } // Session header - div { style: "display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 8px;", + 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;", @@ -416,254 +230,897 @@ pub fn PentestSessionPage(session_id: String) -> Element { 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; align-items: center;", - div { style: "display: flex; gap: 4px;", - button { - class: "btn btn-ghost", - style: "font-size: 0.8rem; padding: 4px 10px;", - disabled: *exporting.read(), - onclick: do_export_md, - Icon { icon: BsFileEarmarkText, width: 12, height: 12 } - " Export MD" - } - button { - class: "btn btn-ghost", - style: "font-size: 0.8rem; padding: 4px 10px;", - disabled: *exporting.read(), - onclick: do_export_json, - Icon { icon: BsFiletypeJson, width: 12, height: 12 } - " Export JSON" + 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 { style: "display: flex; gap: 16px; font-size: 0.85rem; color: var(--text-secondary);", - span { - Icon { icon: BsWrench, width: 14, height: 14 } - " {header_tool_count} tools" + 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..." } }, } - span { - Icon { icon: BsShieldExclamation, width: 14, height: 14 } - " {header_findings_count} findings" + } 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..." } }, } } } } - // Split layout: chat left, findings/chain right - div { style: "display: grid; grid-template-columns: 1fr 420px; gap: 16px; height: calc(100vh - 220px); min-height: 400px;", - - // Left: Chat area - div { class: "card", style: "display: flex; flex-direction: column; overflow: hidden;", - div { class: "card-header", style: "flex-shrink: 0;", "Chat" } - - // Messages + // 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: "flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px;", - match &*messages_res.read() { - Some(Some(data)) => { - let msgs = &data.data; - if msgs.is_empty() { - rsx! { - div { style: "text-align: center; color: var(--text-secondary); padding: 32px;", - h3 { style: "margin-bottom: 8px;", "Start the conversation" } - p { "Send a message to guide the pentest agent." } - } - } - } else { - rsx! { - for (i, msg) in msgs.iter().enumerate() { - { - let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("assistant").to_string(); - let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("text").to_string(); - let tool_name = msg.get("tool_name").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let tool_status = msg.get("tool_status").and_then(|v| v.as_str()).unwrap_or("").to_string(); + 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." + } - if msg_type == "tool_call" || msg_type == "tool_result" || role == "tool_result" { - let tool_icon_style = match tool_status.as_str() { - "success" => "color: #16a34a;", - "error" => "color: #dc2626;", - "running" => "color: #d97706;", - _ => "color: var(--text-secondary);", - }; - rsx! { - div { - key: "{i}", - style: "display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-tertiary); border-radius: 6px; font-size: 0.8rem; color: var(--text-secondary);", - span { style: "{tool_icon_style}", - Icon { icon: BsWrench, width: 12, height: 12 } - } - span { style: "font-family: monospace;", "{tool_name}" } - if !tool_status.is_empty() { - span { class: "badge", style: "font-size: 0.7rem;", "{tool_status}" } - } - if !content.is_empty() { - details { style: "margin-left: auto; cursor: pointer;", - summary { style: "font-size: 0.75rem;", "details" } - pre { style: "margin-top: 4px; padding: 8px; background: var(--bg-primary); border-radius: 4px; font-size: 0.75rem; overflow-x: auto; max-height: 200px; white-space: pre-wrap;", - "{content}" - } - } - } - } - } - } else if role == "user" { - rsx! { - div { - key: "{i}", - style: "display: flex; justify-content: flex-end;", - div { - style: "max-width: 80%; padding: 10px 14px; background: #2563eb; color: #fff; border-radius: 12px 12px 2px 12px; font-size: 0.9rem; line-height: 1.5; white-space: pre-wrap;", - "{content}" - } - } - } - } else { - // Assistant message — render markdown - let rendered_html = markdown_to_html(&content); - rsx! { - div { - key: "{i}", - style: "display: flex; gap: 8px; align-items: flex-start;", - div { - style: "flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center;", - Icon { icon: BsCpu, width: 14, height: 14 } - } - div { - style: "max-width: 80%; padding: 10px 14px; background: var(--bg-tertiary); border-radius: 12px 12px 12px 2px; font-size: 0.9rem; line-height: 1.5;", - dangerous_inner_html: "{rendered_html}", - } - } - } - } + 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 } } } } } - }, - Some(None) => rsx! { p { style: "padding: 16px; color: var(--text-secondary);", "Failed to load messages." } }, - None => rsx! { p { style: "padding: 16px; color: var(--text-secondary);", "Loading messages..." } }, - } - - if *sending.read() { - div { style: "display: flex; gap: 8px; align-items: flex-start;", - div { - style: "flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center;", - Icon { icon: BsCpu, width: 14, height: 14 } - } - div { - style: "padding: 10px 14px; background: var(--bg-tertiary); border-radius: 12px 12px 12px 2px; font-size: 0.9rem; color: var(--text-secondary);", - "Thinking..." - } } } - } - // Input area — disabled while pentest is running (messages have no effect) - if is_running { - div { style: "flex-shrink: 0; padding: 12px; border-top: 1px solid var(--border-color); text-align: center; color: var(--text-secondary); font-size: 0.85rem;", - Icon { icon: BsPlayCircle, width: 14, height: 14 } - " Pentest is running — input disabled until complete" - } - } else { - div { style: "flex-shrink: 0; padding: 12px; border-top: 1px solid var(--border-color); display: flex; gap: 8px;", - textarea { - class: "chat-input", - style: "flex: 1;", - placeholder: "Guide the pentest agent...", - value: "{input_text}", - oninput: move |e| input_text.set(e.value()), - onkeydown: move |e: Event| { - if e.key() == Key::Enter && !e.modifiers().shift() { - e.prevent_default(); - do_send(); - } - }, + 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", - style: "align-self: flex-end;", - disabled: *sending.read(), - onclick: move |_| do_send_click(), - "Send" + disabled: *exporting.read() || export_password.read().len() < 8, + onclick: do_export, + if *exporting.read() { "Encrypting..." } else { "Export" } } } } } + } + } +} - // Right: Findings / Attack Chain tabs - div { class: "card", style: "display: flex; flex-direction: column; overflow: hidden;", - // Tab bar - div { style: "display: flex; border-bottom: 1px solid var(--border-color); flex-shrink: 0;", - button { - style: if *right_tab.read() == "findings" { - "flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid #2563eb; color: var(--text-primary); cursor: pointer; font-weight: 600;" +// ═══════════════════════════════════════ +// 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> { + let node_ids: Vec = steps + .iter() + .map(|s| s.get("node_id").and_then(|v| v.as_str()).unwrap_or("").to_string()) + .collect(); + + let id_to_idx: HashMap = 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::new(); + for d in 0..=max_depth { + let indices: Vec = 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::() { + 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 { + val.get("$date")? + .get("$numberLong")? + .as_str()? + .parse::() + .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, + 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 { - "flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer;" - }, - onclick: move |_| right_tab.set("findings".to_string()), - Icon { icon: BsShieldExclamation, width: 14, height: 14 } - " Findings ({header_findings_count})" - } - button { - style: if *right_tab.read() == "chain" { - "flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid #2563eb; color: var(--text-primary); cursor: pointer; font-weight: 600;" - } else { - "flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer;" - }, - onclick: move |_| right_tab.set("chain".to_string()), - Icon { icon: BsDiagram3, width: 14, height: 14 } - " Attack Chain" + "" + }; + 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); - // Tab content - div { style: "flex: 1; overflow-y: auto; display: flex; flex-direction: column;", - if *right_tab.read() == "findings" { - // Findings tab - div { style: "padding: 12px; flex: 1; overflow-y: auto;", - 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;", - p { "No findings yet." } - } + 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}" } } } - } else { - rsx! { - div { style: "display: flex; flex-direction: column; gap: 8px;", - for finding in finding_list { - { - let title = finding.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled").to_string(); - let severity = finding.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string(); - let vuln_type = finding.get("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 exploitable = finding.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false); - let sev_style = match severity.as_str() { - "critical" => "background: #dc2626; color: #fff;", - "high" => "background: #ea580c; color: #fff;", - "medium" => "background: #d97706; color: #fff;", - "low" => "background: #2563eb; color: #fff;", - _ => "background: var(--bg-tertiary); color: var(--text-secondary);", - }; - rsx! { - div { style: "padding: 10px; background: var(--bg-tertiary); border-radius: 8px;", - div { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;", - span { style: "font-weight: 600; font-size: 0.85rem;", "{title}" } - 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: "{sev_style}", "{severity}" } + } + } + 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}" } } - } - div { style: "font-size: 0.8rem; color: var(--text-secondary);", "{vuln_type}" } - if !endpoint.is_empty() { - div { style: "font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; margin-top: 2px;", - "{endpoint}" + 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}" } } } } @@ -673,118 +1130,6 @@ pub fn PentestSessionPage(session_id: String) -> Element { } } } - }, - Some(None) => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Failed to load findings." } }, - None => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Loading..." } }, - } - } - } else { - // Attack chain tab — graph/list toggle - div { style: "display: flex; gap: 4px; padding: 8px 12px; flex-shrink: 0;", - button { - class: if *chain_view.read() == "graph" { "btn btn-primary" } else { "btn btn-ghost" }, - style: "font-size: 0.75rem; padding: 3px 8px;", - onclick: move |_| chain_view.set("graph".to_string()), - Icon { icon: BsDiagram3, width: 12, height: 12 } - " Graph" - } - button { - class: if *chain_view.read() == "list" { "btn btn-primary" } else { "btn btn-ghost" }, - style: "font-size: 0.75rem; padding: 3px 8px;", - onclick: move |_| chain_view.set("list".to_string()), - Icon { icon: BsListOl, width: 12, height: 12 } - " List" - } - } - - if *chain_view.read() == "graph" { - // Interactive DAG visualization - div { style: "flex: 1; position: relative; min-height: 300px;", - div { - id: "attack-chain-canvas", - style: "width: 100%; height: 100%; position: absolute; inset: 0;", - } - match &*attack_chain.read() { - Some(Some(data)) if data.data.is_empty() => rsx! { - div { style: "position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--text-secondary);", - p { "No attack chain steps yet." } - } - }, - _ => rsx! {}, - } - } - } else { - // List view - div { style: "flex: 1; overflow-y: auto; padding: 0 12px 12px;", - match &*attack_chain.read() { - Some(Some(data)) => { - let steps = &data.data; - if steps.is_empty() { - rsx! { - div { style: "text-align: center; color: var(--text-secondary); padding: 24px;", - p { "No attack chain steps yet." } - } - } - } else { - rsx! { - div { style: "display: flex; flex-direction: column; gap: 4px;", - for (i, step) in steps.iter().enumerate() { - { - let tool_name = step.get("tool_name").and_then(|v| v.as_str()).unwrap_or("Step").to_string(); - let step_status = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending").to_string(); - let reasoning = step.get("llm_reasoning").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let findings_count = step.get("findings_produced").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); - let risk_score = step.get("risk_score").and_then(|v| v.as_u64()); - let step_num = i + 1; - let dot_color = match step_status.as_str() { - "completed" => "#16a34a", - "running" => "#d97706", - "failed" => "#dc2626", - "skipped" => "#374151", - _ => "var(--text-secondary)", - }; - rsx! { - div { style: "display: flex; gap: 10px; padding: 8px 0;", - div { style: "display: flex; flex-direction: column; align-items: center;", - div { style: "width: 10px; height: 10px; border-radius: 50%; background: {dot_color}; flex-shrink: 0;" } - if i < steps.len() - 1 { - div { style: "width: 2px; flex: 1; background: var(--border-color); margin-top: 4px;" } - } - } - div { style: "flex: 1; min-width: 0;", - div { style: "display: flex; justify-content: space-between; align-items: center;", - span { style: "font-size: 0.85rem; font-weight: 600;", - "{step_num}. {tool_name}" - } - div { style: "display: flex; gap: 4px;", - if findings_count > 0 { - span { class: "badge", style: "font-size: 0.65rem; background: #dc2626; color: #fff;", - "{findings_count} findings" - } - } - if let Some(score) = risk_score { - span { class: "badge", style: "font-size: 0.65rem;", - "risk: {score}" - } - } - } - } - if !reasoning.is_empty() { - div { style: "font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;", - "{reasoning}" - } - } - } - } - } - } - } - } - } - } - }, - Some(None) => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Failed to load attack chain." } }, - None => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Loading..." } }, } } } diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index b77e69e..926680a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -30,6 +30,7 @@ export default defineConfig({ items: [ { text: 'Dashboard Overview', link: '/features/overview' }, { text: 'DAST Scanning', link: '/features/dast' }, + { text: 'AI Pentest', link: '/features/pentest' }, { text: 'AI Chat', link: '/features/ai-chat' }, { text: 'Code Knowledge Graph', link: '/features/graph' }, { text: 'MCP Integration', link: '/features/mcp-server' }, diff --git a/docs/features/dast.md b/docs/features/dast.md index 7e5eeb3..2d5de87 100644 --- a/docs/features/dast.md +++ b/docs/features/dast.md @@ -67,7 +67,7 @@ Navigate to **DAST > Findings** to see all discovered vulnerabilities. Each find | Column | Description | |--------|-------------| -| Severity | Critical, High, Medium, or Low | +| Severity | Critical, High, Medium, Low, or Info | | Type | Vulnerability category (SQL Injection, XSS, SSRF, etc.) | | Title | Description of the vulnerability | | Endpoint | The HTTP path that is vulnerable | @@ -76,6 +76,19 @@ Navigate to **DAST > Findings** to see all discovered vulnerabilities. Each find Click a finding to see full details including the CWE identifier, vulnerable parameter, remediation guidance, and evidence showing the exact request/response pairs that triggered the finding. +### Filtering Findings + +The findings page provides several filters to help you focus on what matters: + +| Filter | Description | +|--------|-------------| +| **Search** | Free-text search across finding titles and descriptions | +| **Severity** | Filter by severity level (Critical, High, Medium, Low, Info) | +| **Vulnerability Type** | Filter by vulnerability category -- supports all 21 DAST vulnerability types including SQL Injection, XSS, SSRF, CORS Misconfiguration, CSP Bypass, and more | +| **Exploitable** | Show only confirmed-exploitable findings, or only unconfirmed | + +Filters can be combined. A count indicator shows how many findings match the current filters out of the total (e.g. "Showing 12 of 76 findings"). When no findings match the active filters, a message distinguishes between "no findings exist" and "no findings match your current filters." + ::: tip Findings marked as **Confirmed** exploitable were verified with a successful attack payload. **Unconfirmed** findings show suspicious behavior that may indicate a vulnerability but could not be fully exploited. ::: diff --git a/docs/features/pentest.md b/docs/features/pentest.md new file mode 100644 index 0000000..0346df4 --- /dev/null +++ b/docs/features/pentest.md @@ -0,0 +1,110 @@ +# AI Pentest + +The AI Pentest module provides autonomous, LLM-driven penetration testing against your DAST targets. It orchestrates a chain of security tools guided by AI reasoning to discover vulnerabilities that traditional scanning may miss. + +## Overview + +Navigate to **Pentest** in the sidebar to see the pentest dashboard. + +The dashboard shows: + +- Total pentest sessions run +- Aggregate finding counts with severity breakdown +- Tool invocation statistics and success rates +- Session cards with status, target, strategy, and finding count + +## Starting a Pentest Session + +1. Click **New Pentest** on the dashboard +2. Select a **DAST target** (must be configured under DAST > Targets first) +3. Choose a **strategy**: + +| Strategy | Description | +|----------|-------------| +| **Comprehensive** | Full-spectrum test covering recon, API analysis, injection testing, auth checks, and more | +| **Focused** | Targets specific vulnerability categories based on initial reconnaissance | + +4. Optionally provide an initial **message** to guide the AI's focus +5. Click **Start** to begin the session + +The AI orchestrator will autonomously select and execute security tools in phases, using the output of each phase to inform the next. + +## Session View + +Click any session card to open the detailed session view. It shows: + +### Summary Cards + +- **Findings** — total vulnerabilities discovered +- **Exploitable** — confirmed-exploitable findings +- **Tool Invocations** — total tools executed +- **Success Rate** — percentage of tools that completed successfully + +### Severity Distribution + +A bar showing the breakdown of findings by severity level (Critical, High, Medium, Low, Info). + +### Findings Tab + +Lists all discovered vulnerabilities with: + +- Severity badge and title +- Vulnerability type and exploitability status +- HTTP method and endpoint +- CWE identifier +- Description and remediation recommendation +- Correlated SAST finding references (when available) + +### Attack Chain Tab + +A visual DAG (directed acyclic graph) showing the sequence of tools executed during the pentest. Nodes are grouped into phases: + +- **Phase-based layout** — tools are organized top-down by execution phase (reconnaissance, analysis, testing, exploitation, etc.) +- **Category icons** — each tool displays an icon indicating its category (recon, XSS, SQLi, SSRF, auth, headers, cookies, TLS, CORS, etc.) +- **Status indicators** — color-coded status dots (green = completed, yellow = running, red = failed) +- **Finding badges** — red badge showing the number of findings produced by each tool +- **Interactive** — hover for details, click to select, scroll to zoom, drag to pan + +### Stopping a Session + +Running sessions can be stopped from the dashboard by clicking the **Stop** button on the session card. This immediately halts all tool execution. + +## Exporting Reports + +Click **Export Report** on any session to generate a professional pentest report. + +### Export Process + +1. Enter an **encryption password** (minimum 8 characters) +2. Click **Export** to generate and download the report + +The export produces a **password-protected ZIP archive** (AES-256 encryption) that can be opened with any standard archive tool (7-Zip, WinRAR, macOS Archive Utility, etc.). + +### Archive Contents + +| File | Description | +|------|-------------| +| `report.html` | Professional HTML report with executive summary, methodology, tools, findings with recommendations, and attack chain timeline | +| `findings.json` | Raw findings data in JSON format for programmatic processing | +| `attack-chain.json` | Raw attack chain data showing tool execution sequence and relationships | + +### Report Features + +The HTML report includes: + +- Company logo and CONFIDENTIAL banner +- Requester information +- Executive summary with overall risk rating +- Severity distribution chart +- Methodology and tools section +- Detailed findings with severity, CWE, endpoint, evidence, remediation guidance, and linked SAST references +- Attack chain timeline +- Print-friendly layout (dark theme on screen, light theme for print) + +### Integrity Verification + +After export, the dashboard displays the **SHA-256 checksum** of the archive with a copy-to-clipboard button. Use this to verify the archive has not been tampered with after distribution. + +::: warning +Only run pentests against applications you own or have explicit written authorization to test. AI-driven pentesting sends real attack payloads that may trigger alerts or cause unintended side effects. +:::