feat: pure Dioxus attack chain visualization, PDF report redesign, and orchestrator data fixes
Some checks failed
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Security Audit (pull_request) Has been cancelled
CI / Tests (pull_request) Has been cancelled
CI / Detect Changes (pull_request) Has been cancelled
CI / Deploy Agent (pull_request) Has been cancelled
CI / Deploy Dashboard (pull_request) Has been cancelled
CI / Deploy Docs (pull_request) Has been cancelled
CI / Deploy MCP (pull_request) Has been cancelled
Some checks failed
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Security Audit (pull_request) Has been cancelled
CI / Tests (pull_request) Has been cancelled
CI / Detect Changes (pull_request) Has been cancelled
CI / Deploy Agent (pull_request) Has been cancelled
CI / Deploy Dashboard (pull_request) Has been cancelled
CI / Deploy Docs (pull_request) Has been cancelled
CI / Deploy MCP (pull_request) Has been cancelled
- Replace vis-network JS graph with pure RSX attack chain component featuring KPI header, phase rail, expandable accordion with tool category chips, risk scores, and findings pills - Redesign pentest report as professional PDF-first document with cover page, table of contents, severity bar chart, phased attack chain timeline, and print-friendly light theme - Fix orchestrator to populate findings_produced, risk_score, and llm_reasoning on attack chain nodes - Capture LLM reasoning text alongside tool calls in LlmResponse enum - Add session-level KPI fallback for older pentest data - Remove attack-chain-viz.js and prototype files - Add encrypted ZIP report export endpoint with password protection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
216
Cargo.lock
generated
216
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -36,3 +36,4 @@ base64 = "0.22"
|
||||
urlencoding = "2"
|
||||
futures-util = "0.3"
|
||||
jsonwebtoken = "9"
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -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<String>,
|
||||
) -> Result<Json<ApiResponse<PentestSession>>, (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<String>,
|
||||
Query(params): Query<ExportParams>,
|
||||
Json(body): Json<ExportBody>,
|
||||
) -> Result<axum::response::Response, (StatusCode, String)> {
|
||||
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<PentestMessage> = 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<AttackChainNode> = 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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -117,7 +117,8 @@ pub struct ToolCallRequestFunction {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LlmResponse {
|
||||
Content(String),
|
||||
ToolCalls(Vec<LlmToolCall>),
|
||||
/// Tool calls with optional reasoning text from the LLM
|
||||
ToolCalls { calls: Vec<LlmToolCall>, 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod orchestrator;
|
||||
pub mod report;
|
||||
|
||||
pub use orchestrator::PentestOrchestrator;
|
||||
pub use report::generate_encrypted_report;
|
||||
|
||||
@@ -213,7 +213,7 @@ impl PentestOrchestrator {
|
||||
}
|
||||
break;
|
||||
}
|
||||
LlmResponse::ToolCalls(tool_calls) => {
|
||||
LlmResponse::ToolCalls { calls: tool_calls, reasoning } => {
|
||||
let tc_requests: Vec<ToolCallRequest> = 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<String> = 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<u8> = 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<mongodb::bson::Bson> = 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;
|
||||
|
||||
|
||||
1507
compliance-agent/src/pentest/report.rs
Normal file
1507
compliance-agent/src/pentest/report.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,6 @@ const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
const VIS_NETWORK_JS: Asset = asset!("/assets/vis-network.min.js");
|
||||
const GRAPH_VIZ_JS: Asset = asset!("/assets/graph-viz.js");
|
||||
const ATTACK_CHAIN_VIZ_JS: Asset = asset!("/assets/attack-chain-viz.js");
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
rsx! {
|
||||
@@ -63,7 +61,6 @@ pub fn App() -> Element {
|
||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||
document::Script { src: VIS_NETWORK_JS }
|
||||
document::Script { src: GRAPH_VIZ_JS }
|
||||
document::Script { src: ATTACK_CHAIN_VIZ_JS }
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,23 @@ pub async fn send_pentest_message(
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn stop_pentest_session(session_id: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/pentest/sessions/{session_id}/stop",
|
||||
state.agent_api_url
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_pentest_findings(
|
||||
session_id: String,
|
||||
@@ -248,22 +265,43 @@ pub async fn fetch_pentest_findings(
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ExportReportResponse {
|
||||
pub archive_base64: String,
|
||||
pub sha256: String,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn export_pentest_report(
|
||||
session_id: String,
|
||||
format: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
password: String,
|
||||
requester_name: String,
|
||||
requester_email: String,
|
||||
) -> Result<ExportReportResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/pentest/sessions/{session_id}/export?format={format}",
|
||||
"{}/api/v1/pentest/sessions/{session_id}/export",
|
||||
state.agent_api_url
|
||||
);
|
||||
let resp = reqwest::get(&url)
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"password": password,
|
||||
"requester_name": requester_name,
|
||||
"requester_email": requester_email,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body = resp
|
||||
.text()
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!("Export failed: {text}")));
|
||||
}
|
||||
let body: ExportReportResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::app::Route;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::dast::fetch_dast_targets;
|
||||
use crate::infrastructure::pentest::{
|
||||
create_pentest_session, fetch_pentest_sessions, fetch_pentest_stats,
|
||||
create_pentest_session, fetch_pentest_sessions, fetch_pentest_stats, stop_pentest_session,
|
||||
};
|
||||
|
||||
#[component]
|
||||
@@ -86,7 +86,7 @@ pub fn PentestDashboardPage() -> Element {
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("tool_invocations")
|
||||
.get("total_tool_invocations")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
_ => 0,
|
||||
@@ -97,58 +97,29 @@ pub fn PentestDashboardPage() -> Element {
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("success_rate")
|
||||
.get("tool_success_rate")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
|
||||
// Severity counts from stats
|
||||
let severity_critical = {
|
||||
// Severity counts from stats (nested under severity_distribution)
|
||||
let sev_dist = {
|
||||
let s = stats.read();
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("severity_critical")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
let severity_high = {
|
||||
let s = stats.read();
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("severity_high")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
let severity_medium = {
|
||||
let s = stats.read();
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("severity_medium")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
let severity_low = {
|
||||
let s = stats.read();
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("severity_low")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
_ => 0,
|
||||
.get("severity_distribution")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
_ => serde_json::Value::Null,
|
||||
}
|
||||
};
|
||||
let severity_critical = sev_dist.get("critical").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let severity_high = sev_dist.get("high").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let severity_medium = sev_dist.get("medium").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let severity_low = sev_dist.get("low").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
@@ -259,39 +230,63 @@ pub fn PentestDashboardPage() -> Element {
|
||||
"paused" => "background: #d97706; color: #fff;",
|
||||
_ => "background: var(--bg-tertiary); color: var(--text-secondary);",
|
||||
};
|
||||
rsx! {
|
||||
Link {
|
||||
to: Route::PentestSessionPage { session_id: id.clone() },
|
||||
class: "card",
|
||||
style: "padding: 16px; text-decoration: none; cursor: pointer; transition: border-color 0.15s;",
|
||||
div { style: "display: flex; justify-content: space-between; align-items: flex-start;",
|
||||
div {
|
||||
div { style: "font-weight: 600; font-size: 1rem; margin-bottom: 4px; color: var(--text-primary);",
|
||||
"{target_name}"
|
||||
}
|
||||
div { style: "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;",
|
||||
span {
|
||||
class: "badge",
|
||||
style: "{status_style}",
|
||||
"{status}"
|
||||
{
|
||||
let is_session_running = status == "running";
|
||||
let stop_id = id.clone();
|
||||
rsx! {
|
||||
div { class: "card", style: "padding: 16px; transition: border-color 0.15s;",
|
||||
Link {
|
||||
to: Route::PentestSessionPage { session_id: id.clone() },
|
||||
style: "text-decoration: none; cursor: pointer; display: block;",
|
||||
div { style: "display: flex; justify-content: space-between; align-items: flex-start;",
|
||||
div {
|
||||
div { style: "font-weight: 600; font-size: 1rem; margin-bottom: 4px; color: var(--text-primary);",
|
||||
"{target_name}"
|
||||
}
|
||||
div { style: "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;",
|
||||
span {
|
||||
class: "badge",
|
||||
style: "{status_style}",
|
||||
"{status}"
|
||||
}
|
||||
span {
|
||||
class: "badge",
|
||||
style: "background: var(--bg-tertiary); color: var(--text-secondary);",
|
||||
"{strategy}"
|
||||
}
|
||||
}
|
||||
}
|
||||
span {
|
||||
class: "badge",
|
||||
style: "background: var(--bg-tertiary); color: var(--text-secondary);",
|
||||
"{strategy}"
|
||||
div { style: "text-align: right; font-size: 0.85rem; color: var(--text-secondary);",
|
||||
div { style: "margin-bottom: 4px;",
|
||||
Icon { icon: BsShieldExclamation, width: 12, height: 12 }
|
||||
" {findings_count} findings"
|
||||
}
|
||||
div { style: "margin-bottom: 4px;",
|
||||
Icon { icon: BsWrench, width: 12, height: 12 }
|
||||
" {tool_count} tools"
|
||||
}
|
||||
div { "{created_at}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { style: "text-align: right; font-size: 0.85rem; color: var(--text-secondary);",
|
||||
div { style: "margin-bottom: 4px;",
|
||||
Icon { icon: BsShieldExclamation, width: 12, height: 12 }
|
||||
" {findings_count} findings"
|
||||
if is_session_running {
|
||||
div { style: "margin-top: 8px; display: flex; justify-content: flex-end;",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.8rem; padding: 4px 12px; color: #dc2626; border-color: #dc2626;",
|
||||
onclick: move |e| {
|
||||
e.stop_propagation();
|
||||
e.prevent_default();
|
||||
let sid = stop_id.clone();
|
||||
spawn(async move {
|
||||
let _ = stop_pentest_session(sid).await;
|
||||
sessions.restart();
|
||||
});
|
||||
},
|
||||
Icon { icon: BsStopCircle, width: 12, height: 12 }
|
||||
" Stop"
|
||||
}
|
||||
}
|
||||
div { style: "margin-bottom: 4px;",
|
||||
Icon { icon: BsWrench, width: 12, height: 12 }
|
||||
" {tool_count} tools"
|
||||
}
|
||||
div { "{created_at}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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' },
|
||||
|
||||
@@ -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.
|
||||
:::
|
||||
|
||||
110
docs/features/pentest.md
Normal file
110
docs/features/pentest.md
Normal file
@@ -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.
|
||||
:::
|
||||
Reference in New Issue
Block a user