feat: pentest feature improvements — streaming, pause/resume, encryption, browser tool, reports, docs
- True SSE streaming via broadcast channels (DashMap per session) - Session pause/resume with watch channels + dashboard buttons - AES-256-GCM credential encryption at rest (PENTEST_ENCRYPTION_KEY) - Concurrency limiter (Semaphore, max 5 sessions, 429 on overflow) - Browser tool: headless Chrome CDP automation (navigate, click, fill, screenshot, evaluate) - Report code-level correlation: SAST findings, code graph, SBOM linked per DAST finding - Split html.rs (1919 LOC) into html/ module directory (8 files) - Wizard: target/repo dropdowns from existing data, SSH key display, close button on all steps - Auth: auto-register with optional registration URL (Playwright discovery), plus-addressing email, IMAP overrides - Attack chain: tool input/output in detail panel, running node pulse animation - Architecture docs with Mermaid diagrams + 8 screenshots Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
130
Cargo.lock
generated
@@ -8,6 +8,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
@@ -19,6 +29,20 @@ dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -635,13 +659,16 @@ dependencies = [
|
||||
name = "compliance-agent"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"compliance-core",
|
||||
"compliance-dast",
|
||||
"compliance-graph",
|
||||
"dashmap",
|
||||
"dotenvy",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"git2",
|
||||
"hex",
|
||||
@@ -658,6 +685,8 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-cron-scheduler",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -730,11 +759,13 @@ dependencies = [
|
||||
name = "compliance-dast"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bollard",
|
||||
"bson",
|
||||
"chromiumoxide",
|
||||
"chrono",
|
||||
"compliance-core",
|
||||
"futures-util",
|
||||
"mongodb",
|
||||
"native-tls",
|
||||
"reqwest",
|
||||
@@ -744,6 +775,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -1089,6 +1121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -1115,6 +1148,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
@@ -2314,6 +2356,16 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "git2"
|
||||
version = "0.20.4"
|
||||
@@ -2672,7 +2724,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3513,7 +3565,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"typed-builder",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3747,6 +3799,12 @@ version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
@@ -4052,6 +4110,18 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -4456,7 +4526,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5662,6 +5732,22 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite 0.26.2",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.27.0"
|
||||
@@ -6060,6 +6146,25 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.27.0"
|
||||
@@ -6171,6 +6276,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -6448,6 +6563,15 @@ dependencies = [
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
|
||||
@@ -30,3 +30,6 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
regex = "1"
|
||||
zip = { version = "2", features = ["aes-crypto", "deflate"] }
|
||||
dashmap = "6"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
aes-gcm = "0.10"
|
||||
|
||||
@@ -37,5 +37,8 @@ urlencoding = "2"
|
||||
futures-util = "0.3"
|
||||
jsonwebtoken = "9"
|
||||
zip = { workspace = true }
|
||||
aes-gcm = { workspace = true }
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-core = "0.3"
|
||||
dashmap = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tokio::sync::{broadcast, watch, Semaphore};
|
||||
|
||||
use compliance_core::models::pentest::PentestEvent;
|
||||
use compliance_core::AgentConfig;
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::llm::LlmClient;
|
||||
use crate::pipeline::orchestrator::PipelineOrchestrator;
|
||||
|
||||
/// Default maximum concurrent pentest sessions.
|
||||
const DEFAULT_MAX_CONCURRENT_SESSIONS: usize = 5;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ComplianceAgent {
|
||||
pub config: AgentConfig,
|
||||
pub db: Database,
|
||||
pub llm: Arc<LlmClient>,
|
||||
pub http: reqwest::Client,
|
||||
/// Per-session broadcast senders for SSE streaming.
|
||||
pub session_streams: Arc<DashMap<String, broadcast::Sender<PentestEvent>>>,
|
||||
/// Per-session pause controls (true = paused).
|
||||
pub session_pause: Arc<DashMap<String, watch::Sender<bool>>>,
|
||||
/// Semaphore limiting concurrent pentest sessions.
|
||||
pub session_semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl ComplianceAgent {
|
||||
@@ -27,6 +40,9 @@ impl ComplianceAgent {
|
||||
db,
|
||||
llm,
|
||||
http: reqwest::Client::new(),
|
||||
session_streams: Arc::new(DashMap::new()),
|
||||
session_pause: Arc::new(DashMap::new()),
|
||||
session_semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_SESSIONS)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,4 +90,54 @@ impl ComplianceAgent {
|
||||
.run_pr_review(&repo, repo_id, pr_number, base_sha, head_sha)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Session stream management ──────────────────────────────────
|
||||
|
||||
/// Register a broadcast sender for a session. Returns the sender.
|
||||
pub fn register_session_stream(&self, session_id: &str) -> broadcast::Sender<PentestEvent> {
|
||||
let (tx, _) = broadcast::channel(256);
|
||||
self.session_streams
|
||||
.insert(session_id.to_string(), tx.clone());
|
||||
tx
|
||||
}
|
||||
|
||||
/// Subscribe to a session's broadcast stream.
|
||||
pub fn subscribe_session(&self, session_id: &str) -> Option<broadcast::Receiver<PentestEvent>> {
|
||||
self.session_streams
|
||||
.get(session_id)
|
||||
.map(|tx| tx.subscribe())
|
||||
}
|
||||
|
||||
// ── Session pause/resume management ────────────────────────────
|
||||
|
||||
/// Register a pause control for a session. Returns the watch receiver.
|
||||
pub fn register_pause_control(&self, session_id: &str) -> watch::Receiver<bool> {
|
||||
let (tx, rx) = watch::channel(false);
|
||||
self.session_pause.insert(session_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
|
||||
/// Pause a session.
|
||||
pub fn pause_session(&self, session_id: &str) -> bool {
|
||||
if let Some(tx) = self.session_pause.get(session_id) {
|
||||
tx.send(true).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume a session.
|
||||
pub fn resume_session(&self, session_id: &str) -> bool {
|
||||
if let Some(tx) = self.session_pause.get(session_id) {
|
||||
tx.send(false).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up all per-session resources.
|
||||
pub fn cleanup_session(&self, session_id: &str) {
|
||||
self.session_streams.remove(session_id);
|
||||
self.session_pause.remove(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
use serde::Deserialize;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
use compliance_core::models::finding::Finding;
|
||||
use compliance_core::models::pentest::*;
|
||||
use compliance_core::models::sbom::SbomEntry;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
|
||||
@@ -103,6 +107,97 @@ pub async fn export_session_report(
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
// Fetch SAST findings, SBOM, and code context for the linked repository
|
||||
let repo_id = session
|
||||
.repo_id
|
||||
.clone()
|
||||
.or_else(|| target.as_ref().and_then(|t| t.repo_id.clone()));
|
||||
|
||||
let (sast_findings, sbom_entries, code_context) = if let Some(ref rid) = repo_id {
|
||||
let sast: Vec<Finding> = match agent
|
||||
.db
|
||||
.findings()
|
||||
.find(doc! {
|
||||
"repo_id": rid,
|
||||
"status": { "$in": ["open", "triaged"] },
|
||||
})
|
||||
.sort(doc! { "severity": -1 })
|
||||
.limit(100)
|
||||
.await
|
||||
{
|
||||
Ok(mut cursor) => {
|
||||
let mut results = Vec::new();
|
||||
while let Some(Ok(f)) = cursor.next().await {
|
||||
results.push(f);
|
||||
}
|
||||
results
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let sbom: Vec<SbomEntry> = match agent
|
||||
.db
|
||||
.sbom_entries()
|
||||
.find(doc! {
|
||||
"repo_id": rid,
|
||||
"known_vulnerabilities": { "$exists": true, "$ne": [] },
|
||||
})
|
||||
.limit(50)
|
||||
.await
|
||||
{
|
||||
Ok(mut cursor) => {
|
||||
let mut results = Vec::new();
|
||||
while let Some(Ok(e)) = cursor.next().await {
|
||||
results.push(e);
|
||||
}
|
||||
results
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
// Build code context from graph nodes
|
||||
let code_ctx: Vec<CodeContextHint> = match agent
|
||||
.db
|
||||
.graph_nodes()
|
||||
.find(doc! { "repo_id": rid, "is_entry_point": true })
|
||||
.limit(50)
|
||||
.await
|
||||
{
|
||||
Ok(mut cursor) => {
|
||||
let mut nodes_vec = Vec::new();
|
||||
while let Some(Ok(n)) = cursor.next().await {
|
||||
let linked_vulns: Vec<String> = sast
|
||||
.iter()
|
||||
.filter(|f| f.file_path.as_deref() == Some(&n.file_path))
|
||||
.map(|f| {
|
||||
format!(
|
||||
"[{}] {}: {} (line {})",
|
||||
f.severity,
|
||||
f.scanner,
|
||||
f.title,
|
||||
f.line_number.unwrap_or(0)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
nodes_vec.push(CodeContextHint {
|
||||
endpoint_pattern: n.qualified_name.clone(),
|
||||
handler_function: n.name.clone(),
|
||||
file_path: n.file_path.clone(),
|
||||
code_snippet: String::new(),
|
||||
known_vulnerabilities: linked_vulns,
|
||||
});
|
||||
}
|
||||
nodes_vec
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
(sast, sbom, code_ctx)
|
||||
} else {
|
||||
(Vec::new(), Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
let config = session.config.clone();
|
||||
let ctx = crate::pentest::report::ReportContext {
|
||||
session,
|
||||
target_name,
|
||||
@@ -115,6 +210,10 @@ pub async fn export_session_report(
|
||||
body.requester_name
|
||||
},
|
||||
requester_email: body.requester_email,
|
||||
config,
|
||||
sast_findings,
|
||||
sbom_entries,
|
||||
code_context,
|
||||
};
|
||||
|
||||
let report = crate::pentest::generate_encrypted_report(&ctx, &body.password)
|
||||
|
||||
@@ -17,10 +17,12 @@ type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateSessionRequest {
|
||||
pub target_id: String,
|
||||
pub target_id: Option<String>,
|
||||
#[serde(default = "default_strategy")]
|
||||
pub strategy: String,
|
||||
pub message: Option<String>,
|
||||
/// Wizard configuration — if present, takes precedence over legacy fields
|
||||
pub config: Option<PentestConfig>,
|
||||
}
|
||||
|
||||
fn default_strategy() -> String {
|
||||
@@ -32,83 +34,310 @@ pub struct SendMessageRequest {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LookupRepoQuery {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// POST /api/v1/pentest/sessions — Create a new pentest session and start the orchestrator
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn create_session(
|
||||
Extension(agent): AgentExt,
|
||||
Json(req): Json<CreateSessionRequest>,
|
||||
) -> Result<Json<ApiResponse<PentestSession>>, (StatusCode, String)> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&req.target_id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid target_id format".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Look up the target
|
||||
let target = agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Try to acquire a concurrency permit
|
||||
let permit = agent
|
||||
.session_semaphore
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Maximum concurrent pentest sessions reached. Try again later.".to_string(),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Target not found".to_string()))?;
|
||||
})?;
|
||||
|
||||
// Parse strategy
|
||||
let strategy = match req.strategy.as_str() {
|
||||
if let Some(ref config) = req.config {
|
||||
// ── Wizard path ──────────────────────────────────────────────
|
||||
if !config.disclaimer_accepted {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Disclaimer must be accepted".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Look up or auto-create DastTarget by app_url
|
||||
let target = match agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "base_url": &config.app_url })
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||
{
|
||||
Some(t) => t,
|
||||
None => {
|
||||
use compliance_core::models::dast::{DastTarget, DastTargetType};
|
||||
let mut t = DastTarget::new(
|
||||
config.app_url.clone(),
|
||||
config.app_url.clone(),
|
||||
DastTargetType::WebApp,
|
||||
);
|
||||
if let Some(rl) = config.rate_limit {
|
||||
t.rate_limit = rl;
|
||||
}
|
||||
t.allow_destructive = config.allow_destructive;
|
||||
t.excluded_paths = config.scope_exclusions.clone();
|
||||
let res = agent.db.dast_targets().insert_one(&t).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create target: {e}"),
|
||||
)
|
||||
})?;
|
||||
t.id = res.inserted_id.as_object_id();
|
||||
t
|
||||
}
|
||||
};
|
||||
|
||||
let target_id = target.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
|
||||
// Parse strategy from config or request
|
||||
let strat_str = config.strategy.as_deref().unwrap_or(req.strategy.as_str());
|
||||
let strategy = parse_strategy(strat_str);
|
||||
|
||||
let mut session = PentestSession::new(target_id, strategy);
|
||||
session.config = Some(config.clone());
|
||||
session.repo_id = target.repo_id.clone();
|
||||
|
||||
// Resolve repo_id from git_repo_url if provided
|
||||
if let Some(ref git_url) = config.git_repo_url {
|
||||
if let Ok(Some(repo)) = agent
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(doc! { "git_url": git_url })
|
||||
.await
|
||||
{
|
||||
session.repo_id = repo.id.map(|oid| oid.to_hex());
|
||||
}
|
||||
}
|
||||
|
||||
let insert_result = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.insert_one(&session)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create session: {e}"),
|
||||
)
|
||||
})?;
|
||||
session.id = insert_result.inserted_id.as_object_id();
|
||||
|
||||
let session_id_str = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
|
||||
// Register broadcast stream and pause control
|
||||
let event_tx = agent.register_session_stream(&session_id_str);
|
||||
let pause_rx = agent.register_pause_control(&session_id_str);
|
||||
|
||||
// Encrypt credentials before they linger in memory
|
||||
let mut session_for_task = session.clone();
|
||||
if let Some(ref mut cfg) = session_for_task.config {
|
||||
cfg.auth.username = cfg
|
||||
.auth
|
||||
.username
|
||||
.as_ref()
|
||||
.map(|u| crate::pentest::crypto::encrypt(u));
|
||||
cfg.auth.password = cfg
|
||||
.auth
|
||||
.password
|
||||
.as_ref()
|
||||
.map(|p| crate::pentest::crypto::encrypt(p));
|
||||
}
|
||||
|
||||
// Persist encrypted credentials to DB
|
||||
if session_for_task.config.is_some() {
|
||||
if let Some(sid) = session.id {
|
||||
let _ = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": sid },
|
||||
doc! { "$set": {
|
||||
"config.auth.username": session_for_task.config.as_ref()
|
||||
.and_then(|c| c.auth.username.as_deref())
|
||||
.map(|s| mongodb::bson::Bson::String(s.to_string()))
|
||||
.unwrap_or(mongodb::bson::Bson::Null),
|
||||
"config.auth.password": session_for_task.config.as_ref()
|
||||
.and_then(|c| c.auth.password.as_deref())
|
||||
.map(|s| mongodb::bson::Bson::String(s.to_string()))
|
||||
.unwrap_or(mongodb::bson::Bson::Null),
|
||||
}},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let initial_message = config
|
||||
.initial_instructions
|
||||
.clone()
|
||||
.or(req.message.clone())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Begin a {} penetration test against {} ({}). \
|
||||
Identify vulnerabilities and provide evidence for each finding.",
|
||||
session.strategy, target.name, target.base_url,
|
||||
)
|
||||
});
|
||||
|
||||
let llm = agent.llm.clone();
|
||||
let db = agent.db.clone();
|
||||
let session_clone = session.clone();
|
||||
let target_clone = target.clone();
|
||||
let agent_ref = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, Some(pause_rx));
|
||||
orchestrator
|
||||
.run_session_guarded(&session_clone, &target_clone, &initial_message)
|
||||
.await;
|
||||
// Clean up session resources
|
||||
agent_ref.cleanup_session(&session_id_str);
|
||||
// Release concurrency permit
|
||||
drop(permit);
|
||||
});
|
||||
|
||||
// Redact credentials in response
|
||||
let mut response_session = session;
|
||||
if let Some(ref mut cfg) = response_session.config {
|
||||
if cfg.auth.username.is_some() {
|
||||
cfg.auth.username = Some("********".to_string());
|
||||
}
|
||||
if cfg.auth.password.is_some() {
|
||||
cfg.auth.password = Some("********".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: response_session,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
} else {
|
||||
// ── Legacy path ──────────────────────────────────────────────
|
||||
let target_id = req.target_id.clone().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"target_id is required for legacy creation".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&target_id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid target_id format".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let target = agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Target not found".to_string()))?;
|
||||
|
||||
let strategy = parse_strategy(&req.strategy);
|
||||
|
||||
let mut session = PentestSession::new(target_id, strategy);
|
||||
session.repo_id = target.repo_id.clone();
|
||||
|
||||
let insert_result = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.insert_one(&session)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create session: {e}"),
|
||||
)
|
||||
})?;
|
||||
session.id = insert_result.inserted_id.as_object_id();
|
||||
|
||||
let session_id_str = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
|
||||
// Register broadcast stream and pause control
|
||||
let event_tx = agent.register_session_stream(&session_id_str);
|
||||
let pause_rx = agent.register_pause_control(&session_id_str);
|
||||
|
||||
let initial_message = req.message.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Begin a {} penetration test against {} ({}). \
|
||||
Identify vulnerabilities and provide evidence for each finding.",
|
||||
session.strategy, target.name, target.base_url,
|
||||
)
|
||||
});
|
||||
|
||||
let llm = agent.llm.clone();
|
||||
let db = agent.db.clone();
|
||||
let session_clone = session.clone();
|
||||
let target_clone = target.clone();
|
||||
let agent_ref = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, Some(pause_rx));
|
||||
orchestrator
|
||||
.run_session_guarded(&session_clone, &target_clone, &initial_message)
|
||||
.await;
|
||||
agent_ref.cleanup_session(&session_id_str);
|
||||
drop(permit);
|
||||
});
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: session,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_strategy(s: &str) -> PentestStrategy {
|
||||
match s {
|
||||
"quick" => PentestStrategy::Quick,
|
||||
"targeted" => PentestStrategy::Targeted,
|
||||
"aggressive" => PentestStrategy::Aggressive,
|
||||
"stealth" => PentestStrategy::Stealth,
|
||||
_ => PentestStrategy::Comprehensive,
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v1/pentest/lookup-repo — Look up a tracked repository by git URL
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn lookup_repo(
|
||||
Extension(agent): AgentExt,
|
||||
Query(params): Query<LookupRepoQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, StatusCode> {
|
||||
let repo = agent
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(doc! { "git_url": ¶ms.url })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let data = match repo {
|
||||
Some(r) => serde_json::json!({
|
||||
"name": r.name,
|
||||
"default_branch": r.default_branch,
|
||||
"last_scanned_commit": r.last_scanned_commit,
|
||||
}),
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
|
||||
// Create session
|
||||
let mut session = PentestSession::new(req.target_id.clone(), strategy);
|
||||
session.repo_id = target.repo_id.clone();
|
||||
|
||||
let insert_result = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.insert_one(&session)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create session: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Set the generated ID back on the session so the orchestrator has it
|
||||
session.id = insert_result.inserted_id.as_object_id();
|
||||
|
||||
let initial_message = req.message.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Begin a {} penetration test against {} ({}). \
|
||||
Identify vulnerabilities and provide evidence for each finding.",
|
||||
session.strategy, target.name, target.base_url,
|
||||
)
|
||||
});
|
||||
|
||||
// Spawn the orchestrator on a background task
|
||||
let llm = agent.llm.clone();
|
||||
let db = agent.db.clone();
|
||||
let session_clone = session.clone();
|
||||
let target_clone = target.clone();
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = PentestOrchestrator::new(llm, db);
|
||||
orchestrator
|
||||
.run_session_guarded(&session_clone, &target_clone, &initial_message)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: session,
|
||||
data,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
@@ -158,7 +387,7 @@ pub async fn get_session(
|
||||
) -> Result<Json<ApiResponse<PentestSession>>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let session = agent
|
||||
let mut session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
@@ -166,6 +395,16 @@ pub async fn get_session(
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Redact credentials in response
|
||||
if let Some(ref mut cfg) = session.config {
|
||||
if cfg.auth.username.is_some() {
|
||||
cfg.auth.username = Some("********".to_string());
|
||||
}
|
||||
if cfg.auth.password.is_some() {
|
||||
cfg.auth.password = Some("********".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: session,
|
||||
total: None,
|
||||
@@ -241,8 +480,20 @@ pub async fn send_message(
|
||||
let llm = agent.llm.clone();
|
||||
let db = agent.db.clone();
|
||||
let message = req.message.clone();
|
||||
|
||||
// Use existing broadcast sender if available, otherwise create a new one
|
||||
let event_tx = agent
|
||||
.subscribe_session(&session_id)
|
||||
.and_then(|_| {
|
||||
agent
|
||||
.session_streams
|
||||
.get(&session_id)
|
||||
.map(|entry| entry.value().clone())
|
||||
})
|
||||
.unwrap_or_else(|| agent.register_session_stream(&session_id));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = PentestOrchestrator::new(llm, db);
|
||||
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, None);
|
||||
orchestrator
|
||||
.run_session_guarded(&session, &target, &message)
|
||||
.await;
|
||||
@@ -277,10 +528,10 @@ pub async fn stop_session(
|
||||
})?
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found".to_string()))?;
|
||||
|
||||
if session.status != PentestStatus::Running {
|
||||
if session.status != PentestStatus::Running && session.status != PentestStatus::Paused {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Session is {}, not running", session.status),
|
||||
format!("Session is {}, not running or paused", session.status),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -303,6 +554,9 @@ pub async fn stop_session(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Clean up session resources
|
||||
agent.cleanup_session(&id);
|
||||
|
||||
let updated = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
@@ -328,6 +582,92 @@ pub async fn stop_session(
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/pentest/sessions/:id/pause — Pause a running pentest session
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn pause_session(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, (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),
|
||||
));
|
||||
}
|
||||
|
||||
if !agent.pause_session(&id) {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to send pause signal".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: serde_json::json!({ "status": "paused" }),
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/pentest/sessions/:id/resume — Resume a paused pentest session
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn resume_session(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, (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::Paused {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Session is {}, not paused", session.status),
|
||||
));
|
||||
}
|
||||
|
||||
if !agent.resume_session(&id) {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to send resume signal".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: serde_json::json!({ "status": "running" }),
|
||||
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(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::sse::{Event, Sse};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures_util::stream;
|
||||
use mongodb::bson::doc;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use compliance_core::models::pentest::*;
|
||||
|
||||
@@ -16,16 +20,13 @@ type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
/// GET /api/v1/pentest/sessions/:id/stream — SSE endpoint for real-time events
|
||||
///
|
||||
/// Returns recent messages as SSE events (polling approach).
|
||||
/// True real-time streaming with broadcast channels will be added in a future iteration.
|
||||
/// Replays stored messages/nodes as initial burst, then subscribes to the
|
||||
/// broadcast channel for live updates. Sends keepalive comments every 15s.
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn session_stream(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<
|
||||
Sse<impl futures_util::Stream<Item = Result<Event, std::convert::Infallible>>>,
|
||||
StatusCode,
|
||||
> {
|
||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
// Verify session exists
|
||||
@@ -37,6 +38,10 @@ pub async fn session_stream(
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// ── Initial burst: replay stored data ──────────────────────────
|
||||
|
||||
let mut initial_events: Vec<Result<Event, Infallible>> = Vec::new();
|
||||
|
||||
// Fetch recent messages for this session
|
||||
let messages: Vec<PentestMessage> = match agent
|
||||
.db
|
||||
@@ -63,9 +68,6 @@ pub async fn session_stream(
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
// Build SSE events from stored data
|
||||
let mut events: Vec<Result<Event, std::convert::Infallible>> = Vec::new();
|
||||
|
||||
for msg in &messages {
|
||||
let event_data = serde_json::json!({
|
||||
"type": "message",
|
||||
@@ -74,7 +76,7 @@ pub async fn session_stream(
|
||||
"created_at": msg.created_at.to_rfc3339(),
|
||||
});
|
||||
if let Ok(data) = serde_json::to_string(&event_data) {
|
||||
events.push(Ok(Event::default().event("message").data(data)));
|
||||
initial_events.push(Ok(Event::default().event("message").data(data)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +89,11 @@ pub async fn session_stream(
|
||||
"findings_produced": node.findings_produced,
|
||||
});
|
||||
if let Ok(data) = serde_json::to_string(&event_data) {
|
||||
events.push(Ok(Event::default().event("tool").data(data)));
|
||||
initial_events.push(Ok(Event::default().event("tool").data(data)));
|
||||
}
|
||||
}
|
||||
|
||||
// Add session status event
|
||||
// Add current session status event
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
@@ -108,9 +110,49 @@ pub async fn session_stream(
|
||||
"tool_invocations": s.tool_invocations,
|
||||
});
|
||||
if let Ok(data) = serde_json::to_string(&status_data) {
|
||||
events.push(Ok(Event::default().event("status").data(data)));
|
||||
initial_events.push(Ok(Event::default().event("status").data(data)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Sse::new(stream::iter(events)))
|
||||
// ── Live stream: subscribe to broadcast ────────────────────────
|
||||
|
||||
let live_stream = if let Some(rx) = agent.subscribe_session(&id) {
|
||||
let broadcast = BroadcastStream::new(rx).filter_map(|result| match result {
|
||||
Ok(event) => {
|
||||
if let Ok(data) = serde_json::to_string(&event) {
|
||||
let event_type = match &event {
|
||||
PentestEvent::ToolStart { .. } => "tool_start",
|
||||
PentestEvent::ToolComplete { .. } => "tool_complete",
|
||||
PentestEvent::Finding { .. } => "finding",
|
||||
PentestEvent::Message { .. } => "message",
|
||||
PentestEvent::Complete { .. } => "complete",
|
||||
PentestEvent::Error { .. } => "error",
|
||||
PentestEvent::Thinking { .. } => "thinking",
|
||||
PentestEvent::Paused => "paused",
|
||||
PentestEvent::Resumed => "resumed",
|
||||
};
|
||||
Some(Ok(Event::default().event(event_type).data(data)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
});
|
||||
// Box to unify types
|
||||
Box::pin(broadcast)
|
||||
as std::pin::Pin<Box<dyn futures_util::Stream<Item = Result<Event, Infallible>> + Send>>
|
||||
} else {
|
||||
// No active broadcast — return empty stream
|
||||
Box::pin(stream::empty())
|
||||
as std::pin::Pin<Box<dyn futures_util::Stream<Item = Result<Event, Infallible>> + Send>>
|
||||
};
|
||||
|
||||
// Chain initial burst + live stream
|
||||
let combined = stream::iter(initial_events).chain(live_stream);
|
||||
|
||||
Ok(Sse::new(combined).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text("keepalive"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@ pub fn build_router() -> Router {
|
||||
get(handlers::chat::embedding_status),
|
||||
)
|
||||
// Pentest API endpoints
|
||||
.route(
|
||||
"/api/v1/pentest/lookup-repo",
|
||||
get(handlers::pentest::lookup_repo),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions",
|
||||
get(handlers::pentest::list_sessions).post(handlers::pentest::create_session),
|
||||
@@ -116,6 +120,14 @@ pub fn build_router() -> Router {
|
||||
"/api/v1/pentest/sessions/{id}/stop",
|
||||
post(handlers::pentest::stop_session),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/pause",
|
||||
post(handlers::pentest::pause_session),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/resume",
|
||||
post(handlers::pentest::resume_session),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/stream",
|
||||
get(handlers::pentest::session_stream),
|
||||
|
||||
117
compliance-agent/src/pentest/crypto.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use aes_gcm::aead::AeadCore;
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use base64::Engine;
|
||||
|
||||
/// Load the 32-byte encryption key from PENTEST_ENCRYPTION_KEY env var.
|
||||
/// Returns None if not set or invalid length.
|
||||
pub fn load_encryption_key() -> Option<[u8; 32]> {
|
||||
let hex_key = std::env::var("PENTEST_ENCRYPTION_KEY").ok()?;
|
||||
let bytes = hex::decode(hex_key).ok()?;
|
||||
if bytes.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&bytes);
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext string. Returns base64-encoded nonce+ciphertext.
|
||||
/// Returns the original string if no encryption key is available.
|
||||
pub fn encrypt(plaintext: &str) -> String {
|
||||
let Some(key_bytes) = load_encryption_key() else {
|
||||
return plaintext.to_string();
|
||||
};
|
||||
let Ok(cipher) = Aes256Gcm::new_from_slice(&key_bytes) else {
|
||||
return plaintext.to_string();
|
||||
};
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let Ok(ciphertext) = cipher.encrypt(&nonce, plaintext.as_bytes()) else {
|
||||
return plaintext.to_string();
|
||||
};
|
||||
let mut combined = nonce.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
base64::engine::general_purpose::STANDARD.encode(&combined)
|
||||
}
|
||||
|
||||
/// Decrypt a base64-encoded nonce+ciphertext string.
|
||||
/// Returns None if decryption fails.
|
||||
pub fn decrypt(encrypted: &str) -> Option<String> {
|
||||
let key_bytes = load_encryption_key()?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key_bytes).ok()?;
|
||||
let combined = base64::engine::general_purpose::STANDARD
|
||||
.decode(encrypted)
|
||||
.ok()?;
|
||||
if combined.len() < 12 {
|
||||
return None;
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let plaintext = cipher.decrypt(nonce, ciphertext).ok()?;
|
||||
String::from_utf8(plaintext).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Guard to serialize tests that touch env vars
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn with_key<F: FnOnce()>(hex_key: &str, f: F) {
|
||||
let _guard = ENV_LOCK.lock();
|
||||
unsafe { std::env::set_var("PENTEST_ENCRYPTION_KEY", hex_key) };
|
||||
f();
|
||||
unsafe { std::env::remove_var("PENTEST_ENCRYPTION_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
with_key(key, || {
|
||||
let plaintext = "my_secret_password";
|
||||
let encrypted = encrypt(plaintext);
|
||||
assert_ne!(encrypted, plaintext);
|
||||
let decrypted = decrypt(&encrypted);
|
||||
assert_eq!(decrypted, Some(plaintext.to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails() {
|
||||
let _guard = ENV_LOCK.lock();
|
||||
let key1 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let key2 = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
let encrypted = {
|
||||
unsafe { std::env::set_var("PENTEST_ENCRYPTION_KEY", key1) };
|
||||
let e = encrypt("secret");
|
||||
unsafe { std::env::remove_var("PENTEST_ENCRYPTION_KEY") };
|
||||
e
|
||||
};
|
||||
unsafe { std::env::set_var("PENTEST_ENCRYPTION_KEY", key2) };
|
||||
assert!(decrypt(&encrypted).is_none());
|
||||
unsafe { std::env::remove_var("PENTEST_ENCRYPTION_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_key_passthrough() {
|
||||
let _guard = ENV_LOCK.lock();
|
||||
unsafe { std::env::remove_var("PENTEST_ENCRYPTION_KEY") };
|
||||
let result = encrypt("plain");
|
||||
assert_eq!(result, "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_ciphertext() {
|
||||
let key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
with_key(key, || {
|
||||
assert!(decrypt("not-valid-base64!!!").is_none());
|
||||
// Valid base64 but wrong content
|
||||
let garbage = base64::engine::general_purpose::STANDARD.encode(b"tooshort");
|
||||
assert!(decrypt(&garbage).is_none());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod context;
|
||||
pub mod crypto;
|
||||
pub mod orchestrator;
|
||||
mod prompt_builder;
|
||||
pub mod report;
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use mongodb::bson::doc;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
|
||||
use compliance_core::models::dast::DastTarget;
|
||||
use compliance_core::models::pentest::*;
|
||||
@@ -22,29 +22,27 @@ pub struct PentestOrchestrator {
|
||||
pub(crate) llm: Arc<LlmClient>,
|
||||
pub(crate) db: Database,
|
||||
pub(crate) event_tx: broadcast::Sender<PentestEvent>,
|
||||
pub(crate) pause_rx: Option<watch::Receiver<bool>>,
|
||||
}
|
||||
|
||||
impl PentestOrchestrator {
|
||||
pub fn new(llm: Arc<LlmClient>, db: Database) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
/// Create a new orchestrator with an externally-provided broadcast sender
|
||||
/// and an optional pause receiver.
|
||||
pub fn new(
|
||||
llm: Arc<LlmClient>,
|
||||
db: Database,
|
||||
event_tx: broadcast::Sender<PentestEvent>,
|
||||
pause_rx: Option<watch::Receiver<bool>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_registry: ToolRegistry::new(),
|
||||
llm,
|
||||
db,
|
||||
event_tx,
|
||||
pause_rx,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<PentestEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn event_sender(&self) -> broadcast::Sender<PentestEvent> {
|
||||
self.event_tx.clone()
|
||||
}
|
||||
|
||||
/// Run a pentest session with timeout and automatic failure marking on errors.
|
||||
pub async fn run_session_guarded(
|
||||
&self,
|
||||
@@ -54,8 +52,18 @@ impl PentestOrchestrator {
|
||||
) {
|
||||
let session_id = session.id;
|
||||
|
||||
// Use config-specified timeout or default
|
||||
let timeout_duration = session
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.max_duration_minutes)
|
||||
.map(|m| Duration::from_secs(m as u64 * 60))
|
||||
.unwrap_or(SESSION_TIMEOUT);
|
||||
|
||||
let timeout_minutes = timeout_duration.as_secs() / 60;
|
||||
|
||||
match tokio::time::timeout(
|
||||
SESSION_TIMEOUT,
|
||||
timeout_duration,
|
||||
self.run_session(session, target, initial_message),
|
||||
)
|
||||
.await
|
||||
@@ -72,12 +80,10 @@ impl PentestOrchestrator {
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(?session_id, "Pentest session timed out after 30 minutes");
|
||||
self.mark_session_failed(session_id, "Session timed out after 30 minutes")
|
||||
.await;
|
||||
let _ = self.event_tx.send(PentestEvent::Error {
|
||||
message: "Session timed out after 30 minutes".to_string(),
|
||||
});
|
||||
let msg = format!("Session timed out after {timeout_minutes} minutes");
|
||||
tracing::warn!(?session_id, "{msg}");
|
||||
self.mark_session_failed(session_id, &msg).await;
|
||||
let _ = self.event_tx.send(PentestEvent::Error { message: msg });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +109,45 @@ impl PentestOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the session is paused; if so, update DB status and wait until resumed.
|
||||
async fn wait_if_paused(&self, session: &PentestSession) {
|
||||
let Some(ref pause_rx) = self.pause_rx else {
|
||||
return;
|
||||
};
|
||||
let mut rx = pause_rx.clone();
|
||||
|
||||
if !*rx.borrow() {
|
||||
return;
|
||||
}
|
||||
|
||||
// We are paused — update DB status
|
||||
if let Some(sid) = session.id {
|
||||
let _ = self
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(doc! { "_id": sid }, doc! { "$set": { "status": "paused" }})
|
||||
.await;
|
||||
}
|
||||
let _ = self.event_tx.send(PentestEvent::Paused);
|
||||
|
||||
// Wait until unpaused
|
||||
while *rx.borrow() {
|
||||
if rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Resumed — update DB status back to running
|
||||
if let Some(sid) = session.id {
|
||||
let _ = self
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(doc! { "_id": sid }, doc! { "$set": { "status": "running" }})
|
||||
.await;
|
||||
}
|
||||
let _ = self.event_tx.send(PentestEvent::Resumed);
|
||||
}
|
||||
|
||||
async fn run_session(
|
||||
&self,
|
||||
session: &PentestSession,
|
||||
@@ -175,6 +220,9 @@ impl PentestOrchestrator {
|
||||
let mut prev_node_ids: Vec<String> = Vec::new();
|
||||
|
||||
for _iteration in 0..max_iterations {
|
||||
// Check pause state at top of each iteration
|
||||
self.wait_if_paused(session).await;
|
||||
|
||||
let response = self
|
||||
.llm
|
||||
.chat_with_tools(messages.clone(), &tool_defs, Some(0.2), Some(8192))
|
||||
@@ -417,6 +465,21 @@ impl PentestOrchestrator {
|
||||
.await;
|
||||
}
|
||||
|
||||
// If cleanup_test_user is requested, append a cleanup instruction
|
||||
if session
|
||||
.config
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.auth.cleanup_test_user)
|
||||
{
|
||||
let cleanup_msg = PentestMessage::user(
|
||||
session_id.clone(),
|
||||
"Testing is complete. Now please clean up: navigate to the application and delete \
|
||||
the test user account that was created during this session. Confirm once done."
|
||||
.to_string(),
|
||||
);
|
||||
let _ = self.db.pentest_messages().insert_one(&cleanup_msg).await;
|
||||
}
|
||||
|
||||
let _ = self.event_tx.send(PentestEvent::Complete {
|
||||
summary: format!(
|
||||
"Pentest complete. {} findings from {} tool invocations.",
|
||||
|
||||
@@ -5,6 +5,100 @@ use compliance_core::models::sbom::SbomEntry;
|
||||
|
||||
use super::orchestrator::PentestOrchestrator;
|
||||
|
||||
/// Attempt to decrypt a field; if decryption fails, return the original value
|
||||
/// (which may be plaintext from before encryption was enabled).
|
||||
fn decrypt_field(value: &str) -> String {
|
||||
super::crypto::decrypt(value).unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
/// Build additional prompt sections from PentestConfig when present.
|
||||
fn build_config_sections(config: &PentestConfig) -> String {
|
||||
let mut sections = String::new();
|
||||
|
||||
// Authentication section
|
||||
match config.auth.mode {
|
||||
AuthMode::Manual => {
|
||||
sections.push_str("\n## Authentication\n");
|
||||
sections.push_str("- **Mode**: Manual credentials\n");
|
||||
if let Some(ref u) = config.auth.username {
|
||||
let decrypted = decrypt_field(u);
|
||||
sections.push_str(&format!("- **Username**: {decrypted}\n"));
|
||||
}
|
||||
if let Some(ref p) = config.auth.password {
|
||||
let decrypted = decrypt_field(p);
|
||||
sections.push_str(&format!("- **Password**: {decrypted}\n"));
|
||||
}
|
||||
sections.push_str(
|
||||
"Use these credentials to log in before testing authenticated endpoints.\n",
|
||||
);
|
||||
}
|
||||
AuthMode::AutoRegister => {
|
||||
sections.push_str("\n## Authentication\n");
|
||||
sections.push_str("- **Mode**: Auto-register\n");
|
||||
if let Some(ref url) = config.auth.registration_url {
|
||||
sections.push_str(&format!("- **Registration URL**: {url}\n"));
|
||||
} else {
|
||||
sections.push_str(
|
||||
"- **Registration URL**: Not provided — use Playwright to discover the registration page.\n",
|
||||
);
|
||||
}
|
||||
if let Some(ref email) = config.auth.verification_email {
|
||||
sections.push_str(&format!(
|
||||
"- **Verification Email**: Use plus-addressing from `{email}` \
|
||||
(e.g. `{base}+{{session_id}}@{domain}`) for email verification. \
|
||||
The system will poll the IMAP mailbox for verification links.\n",
|
||||
base = email.split('@').next().unwrap_or(email),
|
||||
domain = email.split('@').nth(1).unwrap_or("example.com"),
|
||||
));
|
||||
}
|
||||
sections.push_str(
|
||||
"Register a new test account using the registration page, then use it for testing.\n",
|
||||
);
|
||||
}
|
||||
AuthMode::None => {}
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
if !config.custom_headers.is_empty() {
|
||||
sections.push_str("\n## Custom HTTP Headers\n");
|
||||
sections.push_str("Include these headers in all HTTP requests:\n");
|
||||
for (k, v) in &config.custom_headers {
|
||||
sections.push_str(&format!("- `{k}: {v}`\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Scope exclusions
|
||||
if !config.scope_exclusions.is_empty() {
|
||||
sections.push_str("\n## Scope Exclusions\n");
|
||||
sections.push_str("Do NOT test the following paths:\n");
|
||||
for path in &config.scope_exclusions {
|
||||
sections.push_str(&format!("- `{path}`\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Git context
|
||||
if config.git_repo_url.is_some() || config.branch.is_some() || config.commit_hash.is_some() {
|
||||
sections.push_str("\n## Git Context\n");
|
||||
if let Some(ref url) = config.git_repo_url {
|
||||
sections.push_str(&format!("- **Repository**: {url}\n"));
|
||||
}
|
||||
if let Some(ref branch) = config.branch {
|
||||
sections.push_str(&format!("- **Branch**: {branch}\n"));
|
||||
}
|
||||
if let Some(ref commit) = config.commit_hash {
|
||||
sections.push_str(&format!("- **Commit**: {commit}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Environment
|
||||
sections.push_str(&format!(
|
||||
"\n## Environment\n- **Target environment**: {}\n",
|
||||
config.environment
|
||||
));
|
||||
|
||||
sections
|
||||
}
|
||||
|
||||
/// Return strategy guidance text for the given strategy.
|
||||
fn strategy_guidance(strategy: &PentestStrategy) -> &'static str {
|
||||
match strategy {
|
||||
@@ -155,6 +249,11 @@ impl PentestOrchestrator {
|
||||
let sast_section = build_sast_section(sast_findings);
|
||||
let sbom_section = build_sbom_section(sbom_entries);
|
||||
let code_section = build_code_section(code_context);
|
||||
let config_sections = session
|
||||
.config
|
||||
.as_ref()
|
||||
.map(build_config_sections)
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
r#"You are an expert penetration tester conducting an authorized security assessment.
|
||||
@@ -178,7 +277,7 @@ impl PentestOrchestrator {
|
||||
|
||||
## Code Entry Points (Knowledge Graph)
|
||||
{code_section}
|
||||
|
||||
{config_sections}
|
||||
## Available Tools
|
||||
{tool_names}
|
||||
|
||||
|
||||
40
compliance-agent/src/pentest/report/html/appendix.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use super::html_escape;
|
||||
|
||||
pub(super) fn appendix(session_id: &str) -> String {
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 5. APPENDIX ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">5.</span> Appendix</h2>
|
||||
|
||||
<h3>Severity Definitions</h3>
|
||||
<table class="info">
|
||||
<tr><td style="color: var(--sev-critical); font-weight: 700;">Critical</td><td>Vulnerabilities that can be exploited remotely without authentication to execute arbitrary code, exfiltrate sensitive data, or fully compromise the system.</td></tr>
|
||||
<tr><td style="color: var(--sev-high); font-weight: 700;">High</td><td>Vulnerabilities that allow significant unauthorized access or data exposure, typically requiring minimal user interaction or privileges.</td></tr>
|
||||
<tr><td style="color: var(--sev-medium); font-weight: 700;">Medium</td><td>Vulnerabilities that may lead to limited data exposure or require specific conditions to exploit, but still represent meaningful risk.</td></tr>
|
||||
<tr><td style="color: var(--sev-low); font-weight: 700;">Low</td><td>Minor issues with limited direct impact. May contribute to broader attack chains or indicate defense-in-depth weaknesses.</td></tr>
|
||||
<tr><td style="color: var(--sev-info); font-weight: 700;">Info</td><td>Observations and best-practice recommendations that do not represent direct security vulnerabilities.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Disclaimer</h3>
|
||||
<p style="font-size: 9pt; color: var(--text-secondary);">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<!-- ═══════════════ FOOTER ═══════════════ -->
|
||||
<div class="report-footer">
|
||||
<div class="footer-company">Compliance Scanner</div>
|
||||
<div>AI-Powered Security Assessment Platform</div>
|
||||
<div style="margin-top: 6px;">This document is confidential and intended solely for the named recipient.</div>
|
||||
<div>Report ID: {session_id}</div>
|
||||
</div>
|
||||
|
||||
</div><!-- .report-body -->
|
||||
</body>
|
||||
</html>"##,
|
||||
session_id = html_escape(session_id),
|
||||
)
|
||||
}
|
||||
175
compliance-agent/src/pentest/report/html/attack_chain.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use super::html_escape;
|
||||
use compliance_core::models::pentest::AttackChainNode;
|
||||
|
||||
pub(super) fn attack_chain(chain: &[AttackChainNode]) -> String {
|
||||
let chain_section = if chain.is_empty() {
|
||||
r#"<p style="color: var(--text-muted);">No attack chain steps recorded.</p>"#.to_string()
|
||||
} else {
|
||||
build_chain_html(chain)
|
||||
};
|
||||
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 4. ATTACK CHAIN ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">4.</span> Attack Chain Timeline</h2>
|
||||
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 16px;">
|
||||
{chain_section}
|
||||
</div>"##
|
||||
)
|
||||
}
|
||||
|
||||
fn build_chain_html(chain: &[AttackChainNode]) -> String {
|
||||
let mut chain_html = String::new();
|
||||
|
||||
// Compute phases via BFS from root nodes
|
||||
let mut phase_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
|
||||
|
||||
for node in 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 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 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> = 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#"<div class="phase-block">
|
||||
<div class="phase-header">
|
||||
<span class="phase-num">Phase {}</span>
|
||||
<span class="phase-label">{}</span>
|
||||
<span class="phase-count">{} step{}</span>
|
||||
</div>
|
||||
<div class="phase-steps">"#,
|
||||
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#"<span class="step-findings">{} finding{}</span>"#,
|
||||
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#"<span class="step-risk {risk_class}">Risk: {r}</span>"#)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let reasoning_html = if node.llm_reasoning.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
r#"<div class="step-reasoning">{}</div>"#,
|
||||
html_escape(&node.llm_reasoning)
|
||||
)
|
||||
};
|
||||
|
||||
chain_html.push_str(&format!(
|
||||
r#"<div class="step-row">
|
||||
<div class="step-num">{num}</div>
|
||||
<div class="step-connector"></div>
|
||||
<div class="step-content">
|
||||
<div class="step-header">
|
||||
<span class="step-tool">{tool_name}</span>
|
||||
<span class="step-status {status_class}">{status_label}</span>
|
||||
{findings_badge}
|
||||
{risk_badge}
|
||||
</div>
|
||||
{reasoning_html}
|
||||
</div>
|
||||
</div>"#,
|
||||
num = i + 1,
|
||||
tool_name = html_escape(&node.tool_name),
|
||||
));
|
||||
}
|
||||
|
||||
chain_html.push_str("</div></div>");
|
||||
}
|
||||
|
||||
chain_html
|
||||
}
|
||||
56
compliance-agent/src/pentest/report/html/cover.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use super::html_escape;
|
||||
|
||||
pub(super) fn cover(
|
||||
target_name: &str,
|
||||
session_id: &str,
|
||||
date_short: &str,
|
||||
target_url: &str,
|
||||
requester_name: &str,
|
||||
requester_email: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
r##"<!-- ═══════════════ COVER PAGE ═══════════════ -->
|
||||
<div class="cover">
|
||||
<svg class="cover-shield" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
|
||||
<defs>
|
||||
<linearGradient id="sg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0d2137"/>
|
||||
<stop offset="100%" stop-color="#1a56db"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M48 6 L22 22 L22 48 C22 66 34 80 48 86 C62 80 74 66 74 48 L74 22 Z"
|
||||
fill="none" stroke="url(#sg)" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M48 12 L26 26 L26 47 C26 63 36 76 48 82 C60 76 70 63 70 47 L70 26 Z"
|
||||
fill="url(#sg)" opacity="0.07"/>
|
||||
<circle cx="44" cy="44" r="11" fill="none" stroke="#0d2137" stroke-width="2.5"/>
|
||||
<line x1="52" y1="52" x2="62" y2="62" stroke="#0d2137" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M39 44 L42.5 47.5 L49 41" fill="none" stroke="#166534" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
<div class="cover-tag">CONFIDENTIAL</div>
|
||||
|
||||
<div class="cover-title">Penetration Test Report</div>
|
||||
<div class="cover-subtitle">{target_name}</div>
|
||||
|
||||
<div class="cover-divider"></div>
|
||||
|
||||
<div class="cover-meta">
|
||||
<strong>Report ID:</strong> {session_id}<br>
|
||||
<strong>Date:</strong> {date_short}<br>
|
||||
<strong>Target:</strong> {target_url}<br>
|
||||
<strong>Prepared for:</strong> {requester_name} ({requester_email})
|
||||
</div>
|
||||
|
||||
<div class="cover-footer">
|
||||
Compliance Scanner — AI-Powered Security Assessment Platform
|
||||
</div>
|
||||
</div>"##,
|
||||
target_name = html_escape(target_name),
|
||||
session_id = html_escape(session_id),
|
||||
date_short = date_short,
|
||||
target_url = html_escape(target_url),
|
||||
requester_name = html_escape(requester_name),
|
||||
requester_email = html_escape(requester_email),
|
||||
)
|
||||
}
|
||||
238
compliance-agent/src/pentest/report/html/executive_summary.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use super::html_escape;
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
|
||||
pub(super) fn executive_summary(
|
||||
findings: &[DastFinding],
|
||||
target_name: &str,
|
||||
target_url: &str,
|
||||
tool_count: usize,
|
||||
tool_invocations: u32,
|
||||
success_rate: f64,
|
||||
) -> String {
|
||||
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 exploitable = findings.iter().filter(|f| f.exploitable).count();
|
||||
let total = 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",
|
||||
};
|
||||
|
||||
let risk_score: usize =
|
||||
std::cmp::min(100, critical * 25 + high * 15 + medium * 8 + low * 3 + info);
|
||||
|
||||
let severity_bar = build_severity_bar(critical, high, medium, low, info, total);
|
||||
|
||||
// Table of contents finding sub-entries
|
||||
let severity_order = ["critical", "high", "medium", "low", "info"];
|
||||
let toc_findings_sub = if !findings.is_empty() {
|
||||
let mut sub = String::new();
|
||||
let mut fnum = 0usize;
|
||||
for &sev_key in severity_order.iter() {
|
||||
let count = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == sev_key)
|
||||
.count();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
for f in findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == sev_key)
|
||||
{
|
||||
fnum += 1;
|
||||
sub.push_str(&format!(
|
||||
r#"<div class="toc-sub">F-{:03} — {}</div>"#,
|
||||
fnum,
|
||||
html_escape(&f.title),
|
||||
));
|
||||
}
|
||||
}
|
||||
sub
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let critical_high_str = format!("{} / {}", critical, high);
|
||||
let escaped_target_name = html_escape(target_name);
|
||||
let escaped_target_url = html_escape(target_url);
|
||||
|
||||
format!(
|
||||
r##"<!-- ═══════════════ TABLE OF CONTENTS ═══════════════ -->
|
||||
<div class="report-body">
|
||||
|
||||
<div class="toc">
|
||||
<h2>Table of Contents</h2>
|
||||
<div class="toc-entry"><span class="toc-num">1</span><span class="toc-label">Executive Summary</span></div>
|
||||
<div class="toc-entry"><span class="toc-num">2</span><span class="toc-label">Scope & Methodology</span></div>
|
||||
<div class="toc-entry"><span class="toc-num">3</span><span class="toc-label">Findings ({total_findings})</span></div>
|
||||
{toc_findings_sub}
|
||||
<div class="toc-entry"><span class="toc-num">4</span><span class="toc-label">Attack Chain Timeline</span></div>
|
||||
<div class="toc-entry"><span class="toc-num">5</span><span class="toc-label">Appendix</span></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ 1. EXECUTIVE SUMMARY ═══════════════ -->
|
||||
<h2><span class="section-num">1.</span> Executive Summary</h2>
|
||||
|
||||
<div class="risk-gauge">
|
||||
<div class="risk-gauge-meter">
|
||||
<div class="risk-gauge-track">
|
||||
<div class="risk-gauge-fill" style="width: {risk_score}%; background: {risk_color};"></div>
|
||||
</div>
|
||||
<div class="risk-gauge-score" style="color: {risk_color};">{risk_score} / 100</div>
|
||||
</div>
|
||||
<div class="risk-gauge-text">
|
||||
<div class="risk-gauge-label" style="color: {risk_color};">Overall Risk: {overall_risk}</div>
|
||||
<div class="risk-gauge-desc">
|
||||
Based on {total_findings} finding{findings_plural} identified across the target application.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exec-grid">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value">{total_findings}</div>
|
||||
<div class="kpi-label">Total Findings</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value" style="color: var(--sev-critical);">{critical_high}</div>
|
||||
<div class="kpi-label">Critical / High</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value" style="color: var(--sev-critical);">{exploitable_count}</div>
|
||||
<div class="kpi-label">Exploitable</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value">{tool_count}</div>
|
||||
<div class="kpi-label">Tools Used</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Severity Distribution</h3>
|
||||
{severity_bar}
|
||||
|
||||
<p>
|
||||
This report presents the results of an automated penetration test conducted against
|
||||
<strong>{target_name}</strong> (<code>{target_url}</code>) using the Compliance Scanner
|
||||
AI-powered testing engine. A total of <strong>{total_findings} vulnerabilities</strong> were
|
||||
identified, of which <strong>{exploitable_count}</strong> were confirmed exploitable with
|
||||
working proof-of-concept payloads. The assessment employed <strong>{tool_count} security tools</strong>
|
||||
across <strong>{tool_invocations} invocations</strong> ({success_rate:.0}% success rate).
|
||||
</p>"##,
|
||||
total_findings = total,
|
||||
findings_plural = if total == 1 { "" } else { "s" },
|
||||
critical_high = critical_high_str,
|
||||
exploitable_count = exploitable,
|
||||
target_name = escaped_target_name,
|
||||
target_url = escaped_target_url,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_severity_bar(
|
||||
critical: usize,
|
||||
high: usize,
|
||||
medium: usize,
|
||||
low: usize,
|
||||
info: usize,
|
||||
total: usize,
|
||||
) -> String {
|
||||
if total == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
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#"<div class="sev-bar">"#);
|
||||
if critical > 0 {
|
||||
bar.push_str(&format!(
|
||||
r#"<div class="sev-bar-seg sev-bar-critical" style="width:{}%"><span>{}</span></div>"#,
|
||||
std::cmp::max(crit_pct, 4),
|
||||
critical
|
||||
));
|
||||
}
|
||||
if high > 0 {
|
||||
bar.push_str(&format!(
|
||||
r#"<div class="sev-bar-seg sev-bar-high" style="width:{}%"><span>{}</span></div>"#,
|
||||
std::cmp::max(high_pct, 4),
|
||||
high
|
||||
));
|
||||
}
|
||||
if medium > 0 {
|
||||
bar.push_str(&format!(
|
||||
r#"<div class="sev-bar-seg sev-bar-medium" style="width:{}%"><span>{}</span></div>"#,
|
||||
std::cmp::max(med_pct, 4),
|
||||
medium
|
||||
));
|
||||
}
|
||||
if low > 0 {
|
||||
bar.push_str(&format!(
|
||||
r#"<div class="sev-bar-seg sev-bar-low" style="width:{}%"><span>{}</span></div>"#,
|
||||
std::cmp::max(low_pct, 4),
|
||||
low
|
||||
));
|
||||
}
|
||||
if info > 0 {
|
||||
bar.push_str(&format!(
|
||||
r#"<div class="sev-bar-seg sev-bar-info" style="width:{}%"><span>{}</span></div>"#,
|
||||
std::cmp::max(info_pct, 4),
|
||||
info
|
||||
));
|
||||
}
|
||||
bar.push_str("</div>");
|
||||
bar.push_str(r#"<div class="sev-bar-legend">"#);
|
||||
if critical > 0 {
|
||||
bar.push_str(r#"<span><i class="sev-dot" style="background:#991b1b"></i> Critical</span>"#);
|
||||
}
|
||||
if high > 0 {
|
||||
bar.push_str(r#"<span><i class="sev-dot" style="background:#c2410c"></i> High</span>"#);
|
||||
}
|
||||
if medium > 0 {
|
||||
bar.push_str(r#"<span><i class="sev-dot" style="background:#a16207"></i> Medium</span>"#);
|
||||
}
|
||||
if low > 0 {
|
||||
bar.push_str(r#"<span><i class="sev-dot" style="background:#1d4ed8"></i> Low</span>"#);
|
||||
}
|
||||
if info > 0 {
|
||||
bar.push_str(r#"<span><i class="sev-dot" style="background:#4b5563"></i> Info</span>"#);
|
||||
}
|
||||
bar.push_str("</div>");
|
||||
bar
|
||||
}
|
||||
369
compliance-agent/src/pentest/report/html/findings.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use super::html_escape;
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
use compliance_core::models::finding::Finding;
|
||||
use compliance_core::models::pentest::CodeContextHint;
|
||||
use compliance_core::models::sbom::SbomEntry;
|
||||
|
||||
/// Render the findings section with code-level correlation.
|
||||
///
|
||||
/// For each DAST finding, if a linked SAST finding exists (via `linked_sast_finding_id`)
|
||||
/// or if we can match the endpoint to a code entry point, we render a "Code-Level
|
||||
/// Remediation" block showing the exact file, line, code snippet, and suggested fix.
|
||||
pub(super) fn findings(
|
||||
findings_list: &[DastFinding],
|
||||
sast_findings: &[Finding],
|
||||
code_context: &[CodeContextHint],
|
||||
sbom_entries: &[SbomEntry],
|
||||
) -> String {
|
||||
if findings_list.is_empty() {
|
||||
return r#"<!-- ═══════════════ 3. FINDINGS ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">3.</span> Findings</h2>
|
||||
|
||||
<p style="color: var(--text-muted);">No vulnerabilities were identified during this assessment.</p>"#.to_string();
|
||||
}
|
||||
|
||||
let severity_order = ["critical", "high", "medium", "low", "info"];
|
||||
let severity_labels = ["Critical", "High", "Medium", "Low", "Informational"];
|
||||
let severity_colors = ["#991b1b", "#c2410c", "#a16207", "#1d4ed8", "#4b5563"];
|
||||
|
||||
// Build SAST lookup by ObjectId hex string
|
||||
let sast_by_id: std::collections::HashMap<String, &Finding> = sast_findings
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let id = f.id.as_ref()?.to_hex();
|
||||
Some((id, f))
|
||||
})
|
||||
.collect();
|
||||
|
||||
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> = findings_list
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == sev_key)
|
||||
.collect();
|
||||
if sev_findings.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
findings_html.push_str(&format!(
|
||||
r#"<h4 class="sev-group-title" style="border-color: {color}">{label} ({count})</h4>"#,
|
||||
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#"<span class="badge badge-exploit">EXPLOITABLE</span>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let cwe_cell = f
|
||||
.cwe
|
||||
.as_deref()
|
||||
.map(|c| format!("<tr><td>CWE</td><td>{}</td></tr>", html_escape(c)))
|
||||
.unwrap_or_default();
|
||||
let param_row = f
|
||||
.parameter
|
||||
.as_deref()
|
||||
.map(|p| {
|
||||
format!(
|
||||
"<tr><td>Parameter</td><td><code>{}</code></td></tr>",
|
||||
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 = build_evidence_html(f);
|
||||
|
||||
// ── Code-level correlation ──────────────────────────────
|
||||
let code_correlation =
|
||||
build_code_correlation(f, &sast_by_id, code_context, sbom_entries);
|
||||
|
||||
findings_html.push_str(&format!(
|
||||
r#"
|
||||
<div class="finding" style="border-left-color: {sev_color}">
|
||||
<div class="finding-header">
|
||||
<span class="finding-id">F-{num:03}</span>
|
||||
<span class="finding-title">{title}</span>
|
||||
{exploitable_badge}
|
||||
</div>
|
||||
<table class="finding-meta">
|
||||
<tr><td>Type</td><td>{vuln_type}</td></tr>
|
||||
<tr><td>Endpoint</td><td><code>{method} {endpoint}</code></td></tr>
|
||||
{param_row}
|
||||
{cwe_cell}
|
||||
</table>
|
||||
<div class="finding-desc">{description}</div>
|
||||
{evidence_html}
|
||||
{code_correlation}
|
||||
<div class="remediation">
|
||||
<div class="remediation-label">Recommendation</div>
|
||||
{remediation}
|
||||
</div>
|
||||
</div>
|
||||
"#,
|
||||
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),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 3. FINDINGS ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">3.</span> Findings</h2>
|
||||
|
||||
{findings_html}"##
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the evidence table HTML for a finding.
|
||||
fn build_evidence_html(f: &DastFinding) -> String {
|
||||
if f.evidence.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut eh = String::from(
|
||||
r#"<div class="evidence-block"><div class="evidence-title">Evidence</div><table class="evidence-table"><thead><tr><th>Request</th><th>Status</th><th>Details</th></tr></thead><tbody>"#,
|
||||
);
|
||||
for ev in &f.evidence {
|
||||
let payload_info = ev
|
||||
.payload
|
||||
.as_deref()
|
||||
.map(|p| {
|
||||
format!(
|
||||
"<br><span class=\"evidence-payload\">Payload: <code>{}</code></span>",
|
||||
html_escape(p)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
eh.push_str(&format!(
|
||||
"<tr><td><code>{} {}</code></td><td>{}</td><td>{}{}</td></tr>",
|
||||
html_escape(&ev.request_method),
|
||||
html_escape(&ev.request_url),
|
||||
ev.response_status,
|
||||
ev.response_snippet
|
||||
.as_deref()
|
||||
.map(html_escape)
|
||||
.unwrap_or_default(),
|
||||
payload_info,
|
||||
));
|
||||
}
|
||||
eh.push_str("</tbody></table></div>");
|
||||
eh
|
||||
}
|
||||
|
||||
/// Build the code-level correlation block for a DAST finding.
|
||||
///
|
||||
/// Attempts correlation in priority order:
|
||||
/// 1. Direct link via `linked_sast_finding_id` → shows exact file, line, snippet, suggested fix
|
||||
/// 2. Endpoint match via code context → shows handler function, file, known SAST vulns
|
||||
/// 3. CWE/CVE match to SBOM → shows vulnerable dependency + version to upgrade
|
||||
fn build_code_correlation(
|
||||
dast_finding: &DastFinding,
|
||||
sast_by_id: &std::collections::HashMap<String, &Finding>,
|
||||
code_context: &[CodeContextHint],
|
||||
sbom_entries: &[SbomEntry],
|
||||
) -> String {
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
|
||||
// 1. Direct SAST link
|
||||
if let Some(ref sast_id) = dast_finding.linked_sast_finding_id {
|
||||
if let Some(sast) = sast_by_id.get(sast_id) {
|
||||
let mut s = String::new();
|
||||
s.push_str(r#"<div class="code-correlation-item">"#);
|
||||
s.push_str(r#"<div class="code-correlation-badge">SAST Correlation</div>"#);
|
||||
s.push_str("<table class=\"code-meta\">");
|
||||
|
||||
if let Some(ref fp) = sast.file_path {
|
||||
let line_info = sast
|
||||
.line_number
|
||||
.map(|l| format!(":{l}"))
|
||||
.unwrap_or_default();
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Location</td><td><code>{}{}</code></td></tr>",
|
||||
html_escape(fp),
|
||||
line_info,
|
||||
));
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Scanner</td><td>{} — {}</td></tr>",
|
||||
html_escape(&sast.scanner),
|
||||
html_escape(&sast.title),
|
||||
));
|
||||
if let Some(ref cwe) = sast.cwe {
|
||||
s.push_str(&format!(
|
||||
"<tr><td>CWE</td><td>{}</td></tr>",
|
||||
html_escape(cwe)
|
||||
));
|
||||
}
|
||||
if let Some(ref rule) = sast.rule_id {
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Rule</td><td><code>{}</code></td></tr>",
|
||||
html_escape(rule)
|
||||
));
|
||||
}
|
||||
s.push_str("</table>");
|
||||
|
||||
// Code snippet
|
||||
if let Some(ref snippet) = sast.code_snippet {
|
||||
if !snippet.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-snippet-block\"><div class=\"code-snippet-label\">Vulnerable Code</div><pre class=\"code-snippet\">{}</pre></div>",
|
||||
html_escape(snippet)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Suggested fix
|
||||
if let Some(ref fix) = sast.suggested_fix {
|
||||
if !fix.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-fix-block\"><div class=\"code-fix-label\">Suggested Fix</div><pre class=\"code-fix\">{}</pre></div>",
|
||||
html_escape(fix)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Remediation from SAST
|
||||
if let Some(ref rem) = sast.remediation {
|
||||
if !rem.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-remediation\">{}</div>",
|
||||
html_escape(rem)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
s.push_str("</div>");
|
||||
sections.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Endpoint match via code context
|
||||
let endpoint_lower = dast_finding.endpoint.to_lowercase();
|
||||
let matching_hints: Vec<&CodeContextHint> = code_context
|
||||
.iter()
|
||||
.filter(|hint| {
|
||||
// Match by endpoint pattern overlap
|
||||
let pattern_lower = hint.endpoint_pattern.to_lowercase();
|
||||
endpoint_lower.contains(&pattern_lower)
|
||||
|| pattern_lower.contains(&endpoint_lower)
|
||||
|| hint.file_path.to_lowercase().contains(
|
||||
&endpoint_lower
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or("")
|
||||
.replace(".html", "")
|
||||
.replace(".php", ""),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for hint in &matching_hints {
|
||||
let mut s = String::new();
|
||||
s.push_str(r#"<div class="code-correlation-item">"#);
|
||||
s.push_str(r#"<div class="code-correlation-badge">Code Entry Point</div>"#);
|
||||
s.push_str("<table class=\"code-meta\">");
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Handler</td><td><code>{}</code></td></tr>",
|
||||
html_escape(&hint.handler_function),
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"<tr><td>File</td><td><code>{}</code></td></tr>",
|
||||
html_escape(&hint.file_path),
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Route</td><td><code>{}</code></td></tr>",
|
||||
html_escape(&hint.endpoint_pattern),
|
||||
));
|
||||
s.push_str("</table>");
|
||||
|
||||
if !hint.known_vulnerabilities.is_empty() {
|
||||
s.push_str("<div class=\"code-linked-vulns\"><strong>Known SAST issues in this file:</strong><ul>");
|
||||
for vuln in &hint.known_vulnerabilities {
|
||||
s.push_str(&format!("<li>{}</li>", html_escape(vuln)));
|
||||
}
|
||||
s.push_str("</ul></div>");
|
||||
}
|
||||
|
||||
s.push_str("</div>");
|
||||
sections.push(s);
|
||||
}
|
||||
|
||||
// 3. SBOM match — if a linked SAST finding has a CVE, or we can match by CWE
|
||||
let linked_cve = dast_finding
|
||||
.linked_sast_finding_id
|
||||
.as_deref()
|
||||
.and_then(|id| sast_by_id.get(id))
|
||||
.and_then(|f| f.cve.as_deref());
|
||||
|
||||
if let Some(cve_id) = linked_cve {
|
||||
let matching_deps: Vec<&SbomEntry> = sbom_entries
|
||||
.iter()
|
||||
.filter(|e| e.known_vulnerabilities.iter().any(|v| v.id == cve_id))
|
||||
.collect();
|
||||
|
||||
for dep in &matching_deps {
|
||||
let mut s = String::new();
|
||||
s.push_str(r#"<div class="code-correlation-item">"#);
|
||||
s.push_str(r#"<div class="code-correlation-badge">Vulnerable Dependency</div>"#);
|
||||
s.push_str("<table class=\"code-meta\">");
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Package</td><td><code>{} {}</code> ({})</td></tr>",
|
||||
html_escape(&dep.name),
|
||||
html_escape(&dep.version),
|
||||
html_escape(&dep.package_manager),
|
||||
));
|
||||
let cve_ids: Vec<&str> = dep
|
||||
.known_vulnerabilities
|
||||
.iter()
|
||||
.map(|v| v.id.as_str())
|
||||
.collect();
|
||||
s.push_str(&format!(
|
||||
"<tr><td>CVEs</td><td>{}</td></tr>",
|
||||
cve_ids.join(", "),
|
||||
));
|
||||
if let Some(ref purl) = dep.purl {
|
||||
s.push_str(&format!(
|
||||
"<tr><td>PURL</td><td><code>{}</code></td></tr>",
|
||||
html_escape(purl),
|
||||
));
|
||||
}
|
||||
s.push_str("</table>");
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-remediation\">Upgrade <code>{}</code> to the latest patched version to resolve {}.</div>",
|
||||
html_escape(&dep.name),
|
||||
html_escape(cve_id),
|
||||
));
|
||||
s.push_str("</div>");
|
||||
sections.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
if sections.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
format!(
|
||||
r#"<div class="code-correlation">
|
||||
<div class="code-correlation-title">Code-Level Remediation</div>
|
||||
{}
|
||||
</div>"#,
|
||||
sections.join("\n")
|
||||
)
|
||||
}
|
||||
473
compliance-agent/src/pentest/report/html/mod.rs
Normal file
@@ -0,0 +1,473 @@
|
||||
mod appendix;
|
||||
mod attack_chain;
|
||||
mod cover;
|
||||
mod executive_summary;
|
||||
mod findings;
|
||||
mod scope;
|
||||
mod styles;
|
||||
|
||||
use super::ReportContext;
|
||||
|
||||
#[allow(clippy::format_in_format_args)]
|
||||
pub(super) 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());
|
||||
|
||||
// Collect unique tool names used
|
||||
let tool_names: Vec<String> = {
|
||||
let mut names: Vec<String> = ctx
|
||||
.attack_chain
|
||||
.iter()
|
||||
.map(|n| n.tool_name.clone())
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
};
|
||||
|
||||
let styles_html = styles::styles();
|
||||
let cover_html = cover::cover(
|
||||
&ctx.target_name,
|
||||
&session_id,
|
||||
&date_short,
|
||||
&ctx.target_url,
|
||||
&ctx.requester_name,
|
||||
&ctx.requester_email,
|
||||
);
|
||||
let exec_html = executive_summary::executive_summary(
|
||||
&ctx.findings,
|
||||
&ctx.target_name,
|
||||
&ctx.target_url,
|
||||
tool_names.len(),
|
||||
session.tool_invocations,
|
||||
session.success_rate(),
|
||||
);
|
||||
let scope_html = scope::scope(
|
||||
session,
|
||||
&ctx.target_name,
|
||||
&ctx.target_url,
|
||||
&date_str,
|
||||
&completed_str,
|
||||
&tool_names,
|
||||
ctx.config.as_ref(),
|
||||
);
|
||||
let findings_html = findings::findings(
|
||||
&ctx.findings,
|
||||
&ctx.sast_findings,
|
||||
&ctx.code_context,
|
||||
&ctx.sbom_entries,
|
||||
);
|
||||
let chain_html = attack_chain::attack_chain(&ctx.attack_chain);
|
||||
let appendix_html = appendix::appendix(&session_id);
|
||||
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Penetration Test Report — {target_name}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Source+Sans+3:ital,wght@0,300;0,400;0,600;0,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
{styles_html}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{cover_html}
|
||||
|
||||
{exec_html}
|
||||
|
||||
{scope_html}
|
||||
|
||||
{findings_html}
|
||||
|
||||
{chain_html}
|
||||
|
||||
{appendix_html}
|
||||
"#,
|
||||
target_name = html_escape(&ctx.target_name),
|
||||
)
|
||||
}
|
||||
|
||||
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('"', """)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::dast::{DastFinding, DastVulnType};
|
||||
use compliance_core::models::finding::Severity;
|
||||
use compliance_core::models::pentest::{
|
||||
AttackChainNode, AttackNodeStatus, PentestSession, PentestStrategy,
|
||||
};
|
||||
|
||||
// ── html_escape ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn html_escape_handles_ampersand() {
|
||||
assert_eq!(html_escape("a & b"), "a & b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_handles_angle_brackets() {
|
||||
assert_eq!(html_escape("<script>"), "<script>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_handles_quotes() {
|
||||
assert_eq!(html_escape(r#"key="val""#), "key="val"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_handles_all_special_chars() {
|
||||
assert_eq!(
|
||||
html_escape(r#"<a href="x">&y</a>"#),
|
||||
"<a href="x">&y</a>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_no_change_for_plain_text() {
|
||||
assert_eq!(html_escape("hello world"), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_empty_string() {
|
||||
assert_eq!(html_escape(""), "");
|
||||
}
|
||||
|
||||
// ── tool_category ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_category_nmap() {
|
||||
assert_eq!(tool_category("nmap_scan"), "Network Reconnaissance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_port_scanner() {
|
||||
assert_eq!(tool_category("port_scanner"), "Network Reconnaissance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_nikto() {
|
||||
assert_eq!(tool_category("nikto"), "Web Server Analysis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_header_check() {
|
||||
assert_eq!(
|
||||
tool_category("security_header_check"),
|
||||
"Web Server Analysis"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_zap_spider() {
|
||||
assert_eq!(tool_category("zap_spider"), "Web Application Scanning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_sqlmap() {
|
||||
assert_eq!(tool_category("sqlmap"), "SQL Injection Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_xss_scanner() {
|
||||
assert_eq!(tool_category("xss_scanner"), "Cross-Site Scripting Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_dir_bruteforce() {
|
||||
assert_eq!(tool_category("dir_bruteforce"), "Directory Enumeration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_gobuster() {
|
||||
assert_eq!(tool_category("gobuster"), "Directory Enumeration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_ssl_check() {
|
||||
assert_eq!(tool_category("ssl_check"), "SSL/TLS Analysis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_tls_scan() {
|
||||
assert_eq!(tool_category("tls_scan"), "SSL/TLS Analysis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_api_test() {
|
||||
assert_eq!(tool_category("api_endpoint_test"), "API Security Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_auth_bypass() {
|
||||
assert_eq!(tool_category("auth_bypass_check"), "Authentication Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_cors() {
|
||||
assert_eq!(tool_category("cors_check"), "CORS Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_csrf() {
|
||||
assert_eq!(tool_category("csrf_scanner"), "CSRF Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_nuclei() {
|
||||
assert_eq!(tool_category("nuclei"), "Vulnerability Scanning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_whatweb() {
|
||||
assert_eq!(tool_category("whatweb"), "Technology Fingerprinting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_unknown_defaults_to_security_testing() {
|
||||
assert_eq!(tool_category("custom_tool"), "Security Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_is_case_insensitive() {
|
||||
assert_eq!(tool_category("NMAP_Scanner"), "Network Reconnaissance");
|
||||
assert_eq!(tool_category("SQLMap"), "SQL Injection Testing");
|
||||
}
|
||||
|
||||
// ── build_html_report ────────────────────────────────────────────
|
||||
|
||||
fn make_session(strategy: PentestStrategy) -> PentestSession {
|
||||
let mut s = PentestSession::new("target-1".into(), strategy);
|
||||
s.tool_invocations = 5;
|
||||
s.tool_successes = 4;
|
||||
s.findings_count = 2;
|
||||
s.exploitable_count = 1;
|
||||
s
|
||||
}
|
||||
|
||||
fn make_finding(severity: Severity, title: &str, exploitable: bool) -> DastFinding {
|
||||
let mut f = DastFinding::new(
|
||||
"run-1".into(),
|
||||
"target-1".into(),
|
||||
DastVulnType::Xss,
|
||||
title.into(),
|
||||
"description".into(),
|
||||
severity,
|
||||
"https://example.com/test".into(),
|
||||
"GET".into(),
|
||||
);
|
||||
f.exploitable = exploitable;
|
||||
f
|
||||
}
|
||||
|
||||
fn make_attack_node(tool_name: &str) -> AttackChainNode {
|
||||
let mut node = AttackChainNode::new(
|
||||
"session-1".into(),
|
||||
"node-1".into(),
|
||||
tool_name.into(),
|
||||
serde_json::json!({}),
|
||||
"Testing this tool".into(),
|
||||
);
|
||||
node.status = AttackNodeStatus::Completed;
|
||||
node
|
||||
}
|
||||
|
||||
fn make_report_context(
|
||||
findings: Vec<DastFinding>,
|
||||
chain: Vec<AttackChainNode>,
|
||||
) -> ReportContext {
|
||||
ReportContext {
|
||||
session: make_session(PentestStrategy::Comprehensive),
|
||||
target_name: "Test App".into(),
|
||||
target_url: "https://example.com".into(),
|
||||
findings,
|
||||
attack_chain: chain,
|
||||
requester_name: "Alice".into(),
|
||||
requester_email: "alice@example.com".into(),
|
||||
config: None,
|
||||
sast_findings: Vec::new(),
|
||||
sbom_entries: Vec::new(),
|
||||
code_context: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_contains_target_info() {
|
||||
let ctx = make_report_context(vec![], vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("Test App"));
|
||||
assert!(html.contains("https://example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_contains_requester_info() {
|
||||
let ctx = make_report_context(vec![], vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("Alice"));
|
||||
assert!(html.contains("alice@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shows_informational_risk_when_no_findings() {
|
||||
let ctx = make_report_context(vec![], vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("INFORMATIONAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shows_critical_risk_with_critical_finding() {
|
||||
let findings = vec![make_finding(Severity::Critical, "Critical XSS", true)];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("CRITICAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shows_high_risk_without_critical() {
|
||||
let findings = vec![make_finding(Severity::High, "High SQLi", false)];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
// Should show HIGH, not CRITICAL
|
||||
assert!(html.contains("HIGH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shows_medium_risk_level() {
|
||||
let findings = vec![make_finding(Severity::Medium, "Medium Issue", false)];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("MEDIUM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_includes_finding_title() {
|
||||
let findings = vec![make_finding(
|
||||
Severity::High,
|
||||
"Reflected XSS in /search",
|
||||
true,
|
||||
)];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("Reflected XSS in /search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shows_exploitable_badge() {
|
||||
let findings = vec![make_finding(Severity::Critical, "SQLi", true)];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
// The report should mark exploitable findings
|
||||
assert!(html.contains("EXPLOITABLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_includes_attack_chain_tool_names() {
|
||||
let chain = vec![make_attack_node("nmap_scan"), make_attack_node("sqlmap")];
|
||||
let ctx = make_report_context(vec![], chain);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("nmap_scan"));
|
||||
assert!(html.contains("sqlmap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_is_valid_html_structure() {
|
||||
let ctx = make_report_context(vec![], vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("<!DOCTYPE html>") || html.contains("<html"));
|
||||
assert!(html.contains("</html>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_strategy_appears() {
|
||||
let ctx = make_report_context(vec![], vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
// PentestStrategy::Comprehensive => "comprehensive"
|
||||
assert!(html.contains("comprehensive") || html.contains("Comprehensive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_finding_count_is_correct() {
|
||||
let findings = vec![
|
||||
make_finding(Severity::Critical, "F1", true),
|
||||
make_finding(Severity::High, "F2", false),
|
||||
make_finding(Severity::Low, "F3", false),
|
||||
];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
// The total count "3" should appear somewhere
|
||||
assert!(
|
||||
html.contains(">3<")
|
||||
|| html.contains(">3 ")
|
||||
|| html.contains("3 findings")
|
||||
|| html.contains("3 Total")
|
||||
);
|
||||
}
|
||||
}
|
||||
127
compliance-agent/src/pentest/report/html/scope.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use super::{html_escape, tool_category};
|
||||
use compliance_core::models::pentest::{AuthMode, PentestConfig, PentestSession};
|
||||
|
||||
pub(super) fn scope(
|
||||
session: &PentestSession,
|
||||
target_name: &str,
|
||||
target_url: &str,
|
||||
date_str: &str,
|
||||
completed_str: &str,
|
||||
tool_names: &[String],
|
||||
config: Option<&PentestConfig>,
|
||||
) -> String {
|
||||
let tools_table: String = tool_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let category = tool_category(t);
|
||||
format!(
|
||||
"<tr><td>{}</td><td><code>{}</code></td><td>{}</td></tr>",
|
||||
i + 1,
|
||||
html_escape(t),
|
||||
category,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let engagement_config_section = if let Some(cfg) = config {
|
||||
let mut rows = String::new();
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Environment</td><td>{}</td></tr>",
|
||||
html_escape(&cfg.environment.to_string())
|
||||
));
|
||||
if let Some(ref app_type) = cfg.app_type {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Application Type</td><td>{}</td></tr>",
|
||||
html_escape(app_type)
|
||||
));
|
||||
}
|
||||
let auth_mode = match cfg.auth.mode {
|
||||
AuthMode::None => "No authentication",
|
||||
AuthMode::Manual => "Manual credentials",
|
||||
AuthMode::AutoRegister => "Auto-register",
|
||||
};
|
||||
rows.push_str(&format!("<tr><td>Auth Mode</td><td>{auth_mode}</td></tr>"));
|
||||
if !cfg.scope_exclusions.is_empty() {
|
||||
let excl = cfg
|
||||
.scope_exclusions
|
||||
.iter()
|
||||
.map(|s| html_escape(s))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Scope Exclusions</td><td><code>{excl}</code></td></tr>"
|
||||
));
|
||||
}
|
||||
if !cfg.tester.name.is_empty() {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Tester</td><td>{} ({})</td></tr>",
|
||||
html_escape(&cfg.tester.name),
|
||||
html_escape(&cfg.tester.email)
|
||||
));
|
||||
}
|
||||
if let Some(ref ts) = cfg.disclaimer_accepted_at {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Disclaimer Accepted</td><td>{}</td></tr>",
|
||||
ts.format("%B %d, %Y at %H:%M UTC")
|
||||
));
|
||||
}
|
||||
if let Some(ref branch) = cfg.branch {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Git Branch</td><td>{}</td></tr>",
|
||||
html_escape(branch)
|
||||
));
|
||||
}
|
||||
if let Some(ref commit) = cfg.commit_hash {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Git Commit</td><td><code>{}</code></td></tr>",
|
||||
html_escape(commit)
|
||||
));
|
||||
}
|
||||
format!("<h3>Engagement Configuration</h3>\n<table class=\"info\">\n{rows}\n</table>")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!(
|
||||
r##"
|
||||
<!-- ═══════════════ 2. SCOPE & METHODOLOGY ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">2.</span> Scope & Methodology</h2>
|
||||
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>Engagement Details</h3>
|
||||
<table class="info">
|
||||
<tr><td>Target</td><td><strong>{target_name}</strong></td></tr>
|
||||
<tr><td>URL</td><td><code>{target_url}</code></td></tr>
|
||||
<tr><td>Strategy</td><td>{strategy}</td></tr>
|
||||
<tr><td>Status</td><td>{status}</td></tr>
|
||||
<tr><td>Started</td><td>{date_str}</td></tr>
|
||||
<tr><td>Completed</td><td>{completed_str}</td></tr>
|
||||
<tr><td>Tool Invocations</td><td>{tool_invocations} ({tool_successes} successful, {success_rate:.1}% success rate)</td></tr>
|
||||
</table>
|
||||
|
||||
{engagement_config_section}
|
||||
|
||||
<h3>Tools Employed</h3>
|
||||
<table class="tools-table">
|
||||
<thead><tr><th>#</th><th>Tool</th><th>Category</th></tr></thead>
|
||||
<tbody>{tools_table}</tbody>
|
||||
</table>"##,
|
||||
target_name = html_escape(target_name),
|
||||
target_url = html_escape(target_url),
|
||||
strategy = session.strategy,
|
||||
status = session.status,
|
||||
date_str = date_str,
|
||||
completed_str = completed_str,
|
||||
tool_invocations = session.tool_invocations,
|
||||
tool_successes = session.tool_successes,
|
||||
success_rate = session.success_rate(),
|
||||
)
|
||||
}
|
||||
889
compliance-agent/src/pentest/report/html/styles.rs
Normal file
@@ -0,0 +1,889 @@
|
||||
pub(super) fn styles() -> String {
|
||||
r##"<style>
|
||||
/* ──────────────── Base / Print-first ──────────────── */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm 18mm 25mm 18mm;
|
||||
}
|
||||
@page :first {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--text: #1a1a2e;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #64748b;
|
||||
--heading: #0d2137;
|
||||
--accent: #1a56db;
|
||||
--accent-light: #dbeafe;
|
||||
--border: #d1d5db;
|
||||
--border-light: #e5e7eb;
|
||||
--bg-subtle: #f8fafc;
|
||||
--bg-section: #f1f5f9;
|
||||
--sev-critical: #991b1b;
|
||||
--sev-high: #c2410c;
|
||||
--sev-medium: #a16207;
|
||||
--sev-low: #1d4ed8;
|
||||
--sev-info: #4b5563;
|
||||
--font-serif: 'Libre Baskerville', 'Georgia', serif;
|
||||
--font-sans: 'Source Sans 3', 'Helvetica Neue', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
line-height: 1.65;
|
||||
font-size: 10.5pt;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.report-body {
|
||||
max-width: 190mm;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* ──────────────── Cover Page ──────────────── */
|
||||
.cover {
|
||||
height: 100vh;
|
||||
min-height: 297mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 40mm 30mm;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cover-shield {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.cover-tag {
|
||||
display: inline-block;
|
||||
background: var(--sev-critical);
|
||||
color: #fff;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 16px;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.cover-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 28pt;
|
||||
font-weight: 700;
|
||||
color: var(--heading);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cover-subtitle {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 14pt;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.cover-meta {
|
||||
font-size: 10pt;
|
||||
color: var(--text-secondary);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.cover-meta strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cover-divider {
|
||||
width: 60px;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
margin: 24px auto;
|
||||
}
|
||||
|
||||
.cover-footer {
|
||||
position: absolute;
|
||||
bottom: 30mm;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ──────────────── Typography ──────────────── */
|
||||
h2 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 16pt;
|
||||
font-weight: 700;
|
||||
color: var(--heading);
|
||||
margin: 36px 0 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid var(--heading);
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
color: var(--heading);
|
||||
margin: 24px 0 10px;
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
font-size: 10.5pt;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9pt;
|
||||
background: var(--bg-section);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-light);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ──────────────── Section Numbers ──────────────── */
|
||||
.section-num {
|
||||
color: var(--accent);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ──────────────── Table of Contents ──────────────── */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.toc h2 {
|
||||
border-bottom-color: var(--accent);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.toc-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dotted var(--border);
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.toc-entry .toc-num {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
min-width: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.toc-entry .toc-label {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: var(--heading);
|
||||
}
|
||||
|
||||
.toc-sub {
|
||||
padding: 3px 0 3px 34px;
|
||||
font-size: 9.5pt;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ──────────────── Executive Summary ──────────────── */
|
||||
.exec-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin: 16px 0 20px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 14px 12px;
|
||||
text-align: center;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 22pt;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 8pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Risk gauge */
|
||||
.risk-gauge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-subtle);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.risk-gauge-meter {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.risk-gauge-track {
|
||||
height: 10px;
|
||||
background: var(--border-light);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.risk-gauge-fill {
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.risk-gauge-score {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.risk-gauge-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.risk-gauge-label {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 14pt;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.risk-gauge-desc {
|
||||
font-size: 9.5pt;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Severity bar */
|
||||
.sev-bar {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 12px 0 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sev-bar-seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 8.5pt;
|
||||
font-weight: 700;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.sev-bar-critical { background: var(--sev-critical); }
|
||||
.sev-bar-high { background: var(--sev-high); }
|
||||
.sev-bar-medium { background: var(--sev-medium); }
|
||||
.sev-bar-low { background: var(--sev-low); }
|
||||
.sev-bar-info { background: var(--sev-info); }
|
||||
|
||||
.sev-bar-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 8.5pt;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sev-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ──────────────── Info Tables ──────────────── */
|
||||
table.info {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
table.info td,
|
||||
table.info th {
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table.info td:first-child,
|
||||
table.info th:first-child {
|
||||
width: 160px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
/* Methodology tools table */
|
||||
table.tools-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
table.tools-table th {
|
||||
background: var(--heading);
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 9pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
table.tools-table td {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
table.tools-table tr:nth-child(even) td {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
table.tools-table td:first-child {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ──────────────── Badges ──────────────── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge-exploit {
|
||||
background: var(--sev-critical);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ──────────────── Findings ──────────────── */
|
||||
.sev-group-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
color: var(--heading);
|
||||
padding: 8px 0 6px 12px;
|
||||
margin: 20px 0 8px;
|
||||
border-left: 4px solid;
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
.finding {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid;
|
||||
border-radius: 0 4px 4px 0;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.finding-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.finding-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9pt;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-section);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.finding-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 700;
|
||||
font-size: 11pt;
|
||||
flex: 1;
|
||||
color: var(--heading);
|
||||
}
|
||||
|
||||
.finding-meta {
|
||||
border-collapse: collapse;
|
||||
margin: 6px 0;
|
||||
font-size: 9.5pt;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.finding-meta td {
|
||||
padding: 3px 10px 3px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.finding-meta td:first-child {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
width: 90px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.finding-desc {
|
||||
margin: 8px 0;
|
||||
font-size: 10pt;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.remediation {
|
||||
margin-top: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--accent-light);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.remediation-label {
|
||||
font-weight: 700;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.evidence-block {
|
||||
margin: 10px 0;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.evidence-title {
|
||||
font-weight: 700;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.evidence-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.evidence-table th {
|
||||
background: var(--bg-section);
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.evidence-table td {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.evidence-payload {
|
||||
font-size: 8.5pt;
|
||||
color: var(--sev-critical);
|
||||
}
|
||||
|
||||
.linked-sast {
|
||||
font-size: 9pt;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ──────────────── Code-Level Correlation ──────────────── */
|
||||
.code-correlation {
|
||||
margin: 12px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-correlation-title {
|
||||
background: #1e293b;
|
||||
color: #f8fafc;
|
||||
padding: 6px 12px;
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.code-correlation-item {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.code-correlation-item:last-child { border-bottom: none; }
|
||||
.code-correlation-badge {
|
||||
display: inline-block;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
font-size: 7pt;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.code-meta {
|
||||
width: 100%;
|
||||
font-size: 8.5pt;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.code-meta td:first-child {
|
||||
width: 80px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px 2px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.code-meta td:last-child {
|
||||
padding: 2px 0;
|
||||
}
|
||||
.code-snippet-block, .code-fix-block {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.code-snippet-label, .code-fix-label {
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.code-snippet-label { color: #dc2626; }
|
||||
.code-fix-label { color: #16a34a; }
|
||||
.code-snippet {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 3px solid #dc2626;
|
||||
padding: 8px 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 8pt;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
.code-fix {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-left: 3px solid #16a34a;
|
||||
padding: 8px 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 8pt;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
.code-remediation {
|
||||
font-size: 8.5pt;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.code-linked-vulns {
|
||||
font-size: 8.5pt;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.code-linked-vulns ul {
|
||||
margin: 2px 0 0 16px;
|
||||
padding: 0;
|
||||
}
|
||||
.code-linked-vulns li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ──────────────── Attack Chain ──────────────── */
|
||||
.phase-block {
|
||||
margin-bottom: 20px;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.phase-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: var(--heading);
|
||||
color: #fff;
|
||||
border-radius: 4px 4px 0 0;
|
||||
font-size: 9.5pt;
|
||||
}
|
||||
|
||||
.phase-num {
|
||||
font-weight: 700;
|
||||
font-size: 8pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
background: rgba(255,255,255,0.15);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.phase-label {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.phase-count {
|
||||
font-size: 8.5pt;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.phase-steps {
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-section);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.step-tool {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5pt;
|
||||
font-weight: 500;
|
||||
color: var(--heading);
|
||||
}
|
||||
|
||||
.step-status {
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 7px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.step-completed { background: #dcfce7; color: #166534; }
|
||||
.step-failed { background: #fef2f2; color: #991b1b; }
|
||||
.step-running { background: #fef9c3; color: #854d0e; }
|
||||
|
||||
.step-findings {
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
color: var(--sev-high);
|
||||
background: #fff7ed;
|
||||
padding: 1px 7px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #fed7aa;
|
||||
}
|
||||
|
||||
.step-risk {
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.risk-high { background: #fef2f2; color: var(--sev-critical); border: 1px solid #fecaca; }
|
||||
.risk-med { background: #fffbeb; color: var(--sev-medium); border: 1px solid #fde68a; }
|
||||
.risk-low { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
||||
|
||||
.step-reasoning {
|
||||
font-size: 9pt;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ──────────────── Footer ──────────────── */
|
||||
.report-footer {
|
||||
margin-top: 48px;
|
||||
padding-top: 14px;
|
||||
border-top: 2px solid var(--heading);
|
||||
font-size: 8pt;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.report-footer .footer-company {
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ──────────────── Page Break Utilities ──────────────── */
|
||||
.page-break {
|
||||
page-break-before: always;
|
||||
break-before: page;
|
||||
}
|
||||
|
||||
.avoid-break {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
/* ──────────────── Print Overrides ──────────────── */
|
||||
@media print {
|
||||
body {
|
||||
font-size: 10pt;
|
||||
}
|
||||
.cover {
|
||||
height: auto;
|
||||
min-height: 250mm;
|
||||
padding: 50mm 20mm;
|
||||
}
|
||||
.report-body {
|
||||
padding: 0;
|
||||
}
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────────────── Screen Enhancements ──────────────── */
|
||||
@media screen {
|
||||
body {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.cover {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
.report-body {
|
||||
background: #fff;
|
||||
padding: 20px 32px 40px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
}
|
||||
</style>"##
|
||||
.to_string()
|
||||
}
|
||||
@@ -3,7 +3,11 @@ mod html;
|
||||
mod pdf;
|
||||
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
use compliance_core::models::pentest::{AttackChainNode, PentestSession};
|
||||
use compliance_core::models::finding::Finding;
|
||||
use compliance_core::models::pentest::{
|
||||
AttackChainNode, CodeContextHint, PentestConfig, PentestSession,
|
||||
};
|
||||
use compliance_core::models::sbom::SbomEntry;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Report archive with metadata
|
||||
@@ -23,6 +27,13 @@ pub struct ReportContext {
|
||||
pub attack_chain: Vec<AttackChainNode>,
|
||||
pub requester_name: String,
|
||||
pub requester_email: String,
|
||||
pub config: Option<PentestConfig>,
|
||||
/// SAST findings for the linked repository (for code-level correlation)
|
||||
pub sast_findings: Vec<Finding>,
|
||||
/// Vulnerable dependencies from SBOM
|
||||
pub sbom_entries: Vec<SbomEntry>,
|
||||
/// Code knowledge graph entry points linked to SAST findings
|
||||
pub code_context: Vec<CodeContextHint>,
|
||||
}
|
||||
|
||||
/// Generate a password-protected ZIP archive containing the pentest report.
|
||||
|
||||
@@ -28,9 +28,9 @@ pub use graph::{
|
||||
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
||||
pub use mcp::{McpServerConfig, McpServerStatus, McpTransport};
|
||||
pub use pentest::{
|
||||
AttackChainNode, AttackNodeStatus, CodeContextHint, PentestEvent, PentestMessage,
|
||||
PentestSession, PentestStats, PentestStatus, PentestStrategy, SeverityDistribution,
|
||||
ToolCallRecord,
|
||||
AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, PentestAuthConfig,
|
||||
PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats, PentestStatus,
|
||||
PentestStrategy, SeverityDistribution, TesterInfo, ToolCallRecord,
|
||||
};
|
||||
pub use repository::{ScanTrigger, TrackedRepository};
|
||||
pub use sbom::{SbomEntry, VulnRef};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -50,6 +52,104 @@ impl std::fmt::Display for PentestStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication mode for the pentest target
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthMode {
|
||||
#[default]
|
||||
None,
|
||||
Manual,
|
||||
AutoRegister,
|
||||
}
|
||||
|
||||
/// Target environment classification
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Environment {
|
||||
#[default]
|
||||
Development,
|
||||
Staging,
|
||||
Production,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Environment {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Development => write!(f, "Development"),
|
||||
Self::Staging => write!(f, "Staging"),
|
||||
Self::Production => write!(f, "Production"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tester identity for the engagement record
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TesterInfo {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Authentication configuration for the pentest session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PentestAuthConfig {
|
||||
pub mode: AuthMode,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
/// Optional — if omitted the orchestrator uses Playwright to discover it.
|
||||
pub registration_url: Option<String>,
|
||||
/// Base email for plus-addressing (e.g. `pentest@scanner.example.com`).
|
||||
/// The orchestrator generates `base+{session_id}@domain` per session.
|
||||
pub verification_email: Option<String>,
|
||||
/// IMAP server to poll for verification emails (e.g. `imap.example.com`).
|
||||
pub imap_host: Option<String>,
|
||||
/// IMAP port (default 993 for TLS).
|
||||
pub imap_port: Option<u16>,
|
||||
/// IMAP username (defaults to `verification_email` if omitted).
|
||||
pub imap_username: Option<String>,
|
||||
/// IMAP password / app-specific password.
|
||||
pub imap_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cleanup_test_user: bool,
|
||||
}
|
||||
|
||||
/// Full wizard configuration for a pentest session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PentestConfig {
|
||||
// Step 1: Target & Scope
|
||||
pub app_url: String,
|
||||
pub git_repo_url: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub commit_hash: Option<String>,
|
||||
pub app_type: Option<String>,
|
||||
pub rate_limit: Option<u32>,
|
||||
|
||||
// Step 2: Authentication
|
||||
#[serde(default)]
|
||||
pub auth: PentestAuthConfig,
|
||||
#[serde(default)]
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
|
||||
// Step 3: Strategy & Instructions
|
||||
pub strategy: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allow_destructive: bool,
|
||||
pub initial_instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scope_exclusions: Vec<String>,
|
||||
|
||||
// Step 4: Disclaimer & Confirm
|
||||
#[serde(default)]
|
||||
pub disclaimer_accepted: bool,
|
||||
pub disclaimer_accepted_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub environment: Environment,
|
||||
#[serde(default)]
|
||||
pub tester: TesterInfo,
|
||||
pub max_duration_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub skip_mode: bool,
|
||||
}
|
||||
|
||||
/// A pentest session initiated via the chat interface
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PentestSession {
|
||||
@@ -60,6 +160,8 @@ pub struct PentestSession {
|
||||
pub repo_id: Option<String>,
|
||||
pub status: PentestStatus,
|
||||
pub strategy: PentestStrategy,
|
||||
/// Wizard configuration (None for legacy sessions)
|
||||
pub config: Option<PentestConfig>,
|
||||
pub created_by: Option<String>,
|
||||
/// Total number of tool invocations in this session
|
||||
pub tool_invocations: u32,
|
||||
@@ -83,6 +185,7 @@ impl PentestSession {
|
||||
repo_id: None,
|
||||
status: PentestStatus::Running,
|
||||
strategy,
|
||||
config: None,
|
||||
created_by: None,
|
||||
tool_invocations: 0,
|
||||
tool_successes: 0,
|
||||
@@ -261,6 +364,10 @@ pub enum PentestEvent {
|
||||
Complete { summary: String },
|
||||
/// Error occurred
|
||||
Error { message: String },
|
||||
/// Session paused
|
||||
Paused,
|
||||
/// Session resumed
|
||||
Resumed,
|
||||
}
|
||||
|
||||
/// Aggregated stats for the pentest dashboard
|
||||
|
||||
@@ -436,6 +436,87 @@ fn pentest_event_serde_finding() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PentestEvent Paused/Resumed ───
|
||||
|
||||
#[test]
|
||||
fn pentest_event_serde_paused() {
|
||||
let event = pentest::PentestEvent::Paused;
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""type":"paused""#));
|
||||
let back: pentest::PentestEvent = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, pentest::PentestEvent::Paused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_event_serde_resumed() {
|
||||
let event = pentest::PentestEvent::Resumed;
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""type":"resumed""#));
|
||||
let back: pentest::PentestEvent = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, pentest::PentestEvent::Resumed));
|
||||
}
|
||||
|
||||
// ─── PentestConfig serde ───
|
||||
|
||||
#[test]
|
||||
fn pentest_config_serde_roundtrip() {
|
||||
let config = pentest::PentestConfig {
|
||||
app_url: "https://example.com".into(),
|
||||
git_repo_url: Some("https://github.com/org/repo".into()),
|
||||
branch: Some("main".into()),
|
||||
commit_hash: None,
|
||||
app_type: Some("web".into()),
|
||||
rate_limit: Some(10),
|
||||
auth: pentest::PentestAuthConfig {
|
||||
mode: pentest::AuthMode::Manual,
|
||||
username: Some("admin".into()),
|
||||
password: Some("pass123".into()),
|
||||
registration_url: None,
|
||||
verification_email: None,
|
||||
imap_host: None,
|
||||
imap_port: None,
|
||||
imap_username: None,
|
||||
imap_password: None,
|
||||
cleanup_test_user: true,
|
||||
},
|
||||
custom_headers: [("X-Token".to_string(), "abc".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
strategy: Some("comprehensive".into()),
|
||||
allow_destructive: false,
|
||||
initial_instructions: Some("Test the login flow".into()),
|
||||
scope_exclusions: vec!["/admin".into()],
|
||||
disclaimer_accepted: true,
|
||||
disclaimer_accepted_at: Some(chrono::Utc::now()),
|
||||
environment: pentest::Environment::Staging,
|
||||
tester: pentest::TesterInfo {
|
||||
name: "Alice".into(),
|
||||
email: "alice@example.com".into(),
|
||||
},
|
||||
max_duration_minutes: Some(30),
|
||||
skip_mode: false,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let back: pentest::PentestConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.app_url, "https://example.com");
|
||||
assert_eq!(back.auth.mode, pentest::AuthMode::Manual);
|
||||
assert_eq!(back.auth.username, Some("admin".into()));
|
||||
assert!(back.auth.cleanup_test_user);
|
||||
assert_eq!(back.scope_exclusions, vec!["/admin".to_string()]);
|
||||
assert_eq!(back.environment, pentest::Environment::Staging);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_auth_config_default() {
|
||||
let auth = pentest::PentestAuthConfig::default();
|
||||
assert_eq!(auth.mode, pentest::AuthMode::None);
|
||||
assert!(auth.username.is_none());
|
||||
assert!(auth.password.is_none());
|
||||
assert!(auth.verification_email.is_none());
|
||||
assert!(auth.imap_host.is_none());
|
||||
assert!(!auth.cleanup_test_user);
|
||||
}
|
||||
|
||||
// ─── Serde helpers (BSON datetime) ───
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3305,7 +3305,7 @@ tbody tr:last-child td {
|
||||
transition: max-height 0.28s cubic-bezier(0.16,1,0.3,1);
|
||||
}
|
||||
.ac-tool-detail.open {
|
||||
max-height: 300px;
|
||||
max-height: 800px;
|
||||
}
|
||||
.ac-tool-detail-inner {
|
||||
padding: 6px 10px 10px 49px;
|
||||
@@ -3338,3 +3338,310 @@ tbody tr:last-child td {
|
||||
.ac-detail-value {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Running node pulse animation */
|
||||
.ac-node-running {
|
||||
animation: ac-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ac-pulse {
|
||||
0%, 100% { box-shadow: inset 0 0 0 transparent; }
|
||||
50% { box-shadow: inset 0 0 12px rgba(217, 119, 6, 0.15); }
|
||||
}
|
||||
|
||||
/* Tool input/output data blocks */
|
||||
.ac-data-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ac-data-label {
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.ac-data-block {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border, #162038);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Pentest Wizard
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
.wizard-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.wizard-dialog {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 600px;
|
||||
max-width: 92vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Close button (top-right corner, always visible) */
|
||||
.wizard-close-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.wizard-close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Dropdown for existing targets/repos */
|
||||
.wizard-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background: var(--bg-elevated, var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0 0 8px 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.wizard-dropdown-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.wizard-dropdown-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.wizard-dropdown-item:hover {
|
||||
background: var(--bg-card-hover, rgba(255,255,255,0.04));
|
||||
}
|
||||
|
||||
/* SSH key display */
|
||||
.wizard-ssh-key {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 200, 255, 0.04);
|
||||
border: 1px solid var(--border-accent, rgba(0,200,255,0.15));
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wizard-ssh-key-box {
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wizard-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-tertiary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wizard-step + .wizard-step::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.wizard-step.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.wizard-step.completed {
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.wizard-step-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wizard-step.active .wizard-step-dot {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.wizard-step.completed .wizard-step-dot {
|
||||
background: var(--status-success);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.wizard-step-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.wizard-step-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-body {
|
||||
padding: 20px 24px;
|
||||
min-height: 300px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wizard-body h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wizard-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wizard-field label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wizard-field .chat-input,
|
||||
.wizard-field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wizard-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.wizard-disclaimer {
|
||||
background: rgba(255, 176, 32, 0.08);
|
||||
border: 1px solid rgba(255, 176, 32, 0.25);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.wizard-summary {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.wizard-summary dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wizard-summary dt {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wizard-summary dd {
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-toggle {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wizard-toggle.active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.wizard-toggle-knob {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.wizard-toggle.active .wizard-toggle-knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
@@ -270,8 +270,17 @@ pub fn AttackChainView(
|
||||
let duration = compute_duration(step);
|
||||
let started = step.get("started_at").map(format_bson_time).unwrap_or_default();
|
||||
|
||||
let tool_input_json = step.get("tool_input")
|
||||
.map(|v| serde_json::to_string_pretty(v).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let tool_output_json = step.get("tool_output")
|
||||
.map(|v| serde_json::to_string_pretty(v).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_pending = status == "pending";
|
||||
let is_node_running = status == "running";
|
||||
let pending_cls = if is_pending { " is-pending" } else { "" };
|
||||
let running_cls = if is_node_running { " ac-node-running" } else { "" };
|
||||
|
||||
let duration_cls = if status == "running" { "ac-tool-duration running-text" } else { "ac-tool-duration" };
|
||||
let duration_text = if status == "running" {
|
||||
@@ -299,7 +308,7 @@ pub fn AttackChainView(
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "ac-tool-row{pending_cls}",
|
||||
class: "ac-tool-row{pending_cls}{running_cls}",
|
||||
id: "{row_id}",
|
||||
onclick: move |_| {
|
||||
if is_pending { return; }
|
||||
@@ -321,30 +330,40 @@ pub fn AttackChainView(
|
||||
div {
|
||||
class: "ac-tool-detail",
|
||||
id: "{detail_id_clone}",
|
||||
if !reasoning.is_empty() || !started.is_empty() {
|
||||
div { class: "ac-tool-detail-inner",
|
||||
if !reasoning.is_empty() {
|
||||
div { class: "ac-reasoning-block", "{reasoning}" }
|
||||
}
|
||||
if !started.is_empty() {
|
||||
div { class: "ac-detail-grid",
|
||||
span { class: "ac-detail-label", "Started" }
|
||||
span { class: "ac-detail-value", "{started}" }
|
||||
if !duration_text.is_empty() && status != "running" && duration_text != "\u{2014}" {
|
||||
span { class: "ac-detail-label", "Duration" }
|
||||
span { class: "ac-detail-value", "{duration_text}" }
|
||||
}
|
||||
span { class: "ac-detail-label", "Status" }
|
||||
if status == "completed" {
|
||||
span { class: "ac-detail-value", style: "color: var(--success, #16a34a);", "Completed" }
|
||||
} else if status == "failed" {
|
||||
span { class: "ac-detail-value", style: "color: var(--danger, #dc2626);", "Failed" }
|
||||
} else if status == "running" {
|
||||
span { class: "ac-detail-value", style: "color: var(--warning, #d97706);", "Running" }
|
||||
} else {
|
||||
span { class: "ac-detail-value", "{status}" }
|
||||
}
|
||||
div { class: "ac-tool-detail-inner",
|
||||
if !reasoning.is_empty() {
|
||||
div { class: "ac-reasoning-block", "{reasoning}" }
|
||||
}
|
||||
if !started.is_empty() {
|
||||
div { class: "ac-detail-grid",
|
||||
span { class: "ac-detail-label", "Started" }
|
||||
span { class: "ac-detail-value", "{started}" }
|
||||
if !duration_text.is_empty() && status != "running" && duration_text != "\u{2014}" {
|
||||
span { class: "ac-detail-label", "Duration" }
|
||||
span { class: "ac-detail-value", "{duration_text}" }
|
||||
}
|
||||
span { class: "ac-detail-label", "Status" }
|
||||
if status == "completed" {
|
||||
span { class: "ac-detail-value", style: "color: var(--success, #16a34a);", "Completed" }
|
||||
} else if status == "failed" {
|
||||
span { class: "ac-detail-value", style: "color: var(--danger, #dc2626);", "Failed" }
|
||||
} else if status == "running" {
|
||||
span { class: "ac-detail-value", style: "color: var(--warning, #d97706);", "Running" }
|
||||
} else {
|
||||
span { class: "ac-detail-value", "{status}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if !tool_input_json.is_empty() && tool_input_json != "null" {
|
||||
div { class: "ac-data-section",
|
||||
div { class: "ac-data-label", "Input" }
|
||||
pre { class: "ac-data-block", "{tool_input_json}" }
|
||||
}
|
||||
}
|
||||
if !tool_output_json.is_empty() && tool_output_json != "null" {
|
||||
div { class: "ac-data-section",
|
||||
div { class: "ac-data-label", "Output" }
|
||||
pre { class: "ac-data-block", "{tool_output_json}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod code_snippet;
|
||||
pub mod file_tree;
|
||||
pub mod page_header;
|
||||
pub mod pagination;
|
||||
pub mod pentest_wizard;
|
||||
pub mod severity_badge;
|
||||
pub mod sidebar;
|
||||
pub mod stat_card;
|
||||
|
||||
925
compliance-dashboard/src/components/pentest_wizard.rs
Normal file
@@ -0,0 +1,925 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::*;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::infrastructure::dast::fetch_dast_targets;
|
||||
use crate::infrastructure::pentest::{create_pentest_session_wizard, lookup_repo_by_url};
|
||||
use crate::infrastructure::repositories::{fetch_repositories, fetch_ssh_public_key};
|
||||
|
||||
const DISCLAIMER_TEXT: &str = "I confirm that I have authorization to perform security testing \
|
||||
against the specified target. I understand that penetration testing may cause disruption to the \
|
||||
target application. I accept full responsibility for ensuring this test is conducted within \
|
||||
legal boundaries and with proper authorization from the system owner.";
|
||||
|
||||
/// Returns true if a git URL looks like an SSH URL (git@ or ssh://)
|
||||
fn is_ssh_url(url: &str) -> bool {
|
||||
let trimmed = url.trim();
|
||||
trimmed.starts_with("git@") || trimmed.starts_with("ssh://")
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn PentestWizard(show: Signal<bool>) -> Element {
|
||||
let mut step = use_signal(|| 1u8);
|
||||
let mut creating = use_signal(|| false);
|
||||
|
||||
// Step 1: Target & Scope
|
||||
let mut app_url = use_signal(String::new);
|
||||
let mut git_repo_url = use_signal(String::new);
|
||||
let mut branch = use_signal(String::new);
|
||||
let mut commit_hash = use_signal(String::new);
|
||||
let mut app_type = use_signal(|| "web_app".to_string());
|
||||
let mut rate_limit = use_signal(|| "10".to_string());
|
||||
|
||||
// Repo lookup state
|
||||
let mut repo_looked_up = use_signal(|| false);
|
||||
let mut repo_name = use_signal(String::new);
|
||||
|
||||
// Dropdown state: existing targets and repos
|
||||
let mut show_target_dropdown = use_signal(|| false);
|
||||
let mut show_repo_dropdown = use_signal(|| false);
|
||||
let existing_targets = use_resource(|| async { fetch_dast_targets().await.ok() });
|
||||
let existing_repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||
|
||||
// SSH key state for private repos
|
||||
let mut ssh_public_key = use_signal(String::new);
|
||||
let mut ssh_key_loaded = use_signal(|| false);
|
||||
|
||||
// Step 2: Authentication
|
||||
let mut requires_auth = use_signal(|| false);
|
||||
let mut auth_mode = use_signal(|| "manual".to_string()); // "manual" | "auto_register"
|
||||
let mut auth_username = use_signal(String::new);
|
||||
let mut auth_password = use_signal(String::new);
|
||||
let mut registration_url = use_signal(String::new);
|
||||
let mut verification_email = use_signal(String::new);
|
||||
let mut imap_host = use_signal(String::new);
|
||||
let mut imap_port = use_signal(|| "993".to_string());
|
||||
let mut imap_username = use_signal(String::new);
|
||||
let mut imap_password = use_signal(String::new);
|
||||
let mut show_imap_settings = use_signal(|| false);
|
||||
let mut cleanup_test_user = use_signal(|| false);
|
||||
let mut custom_headers = use_signal(Vec::<(String, String)>::new);
|
||||
|
||||
// Step 3: Strategy & Instructions
|
||||
let mut strategy = use_signal(|| "comprehensive".to_string());
|
||||
let mut allow_destructive = use_signal(|| false);
|
||||
let mut initial_instructions = use_signal(String::new);
|
||||
let mut scope_exclusions = use_signal(String::new);
|
||||
let mut environment = use_signal(|| "development".to_string());
|
||||
let mut max_duration = use_signal(|| "30".to_string());
|
||||
let mut tester_name = use_signal(String::new);
|
||||
let mut tester_email = use_signal(String::new);
|
||||
|
||||
// Step 4: Disclaimer
|
||||
let mut disclaimer_accepted = use_signal(|| false);
|
||||
|
||||
let close = move |_| {
|
||||
show.set(false);
|
||||
step.set(1);
|
||||
};
|
||||
|
||||
let on_skip_to_blackbox = move |_| {
|
||||
// Jump to step 4 with skip mode
|
||||
step.set(4);
|
||||
};
|
||||
|
||||
let can_skip = !app_url.read().is_empty();
|
||||
|
||||
let on_submit = move |_| {
|
||||
creating.set(true);
|
||||
let url = app_url.read().clone();
|
||||
let git = git_repo_url.read().clone();
|
||||
let br = branch.read().clone();
|
||||
let ch = commit_hash.read().clone();
|
||||
let at = app_type.read().clone();
|
||||
let rl = rate_limit.read().parse::<u32>().unwrap_or(10);
|
||||
let req_auth = *requires_auth.read();
|
||||
let am = auth_mode.read().clone();
|
||||
let au = auth_username.read().clone();
|
||||
let ap = auth_password.read().clone();
|
||||
let ru = registration_url.read().clone();
|
||||
let ve = verification_email.read().clone();
|
||||
let ih = imap_host.read().clone();
|
||||
let ip = imap_port.read().parse::<u16>().unwrap_or(993);
|
||||
let iu = imap_username.read().clone();
|
||||
let iw = imap_password.read().clone();
|
||||
let cu = *cleanup_test_user.read();
|
||||
let hdrs = custom_headers.read().clone();
|
||||
let strat = strategy.read().clone();
|
||||
let ad = *allow_destructive.read();
|
||||
let ii = initial_instructions.read().clone();
|
||||
let se = scope_exclusions.read().clone();
|
||||
let env = environment.read().clone();
|
||||
let md = max_duration.read().parse::<u32>().unwrap_or(30);
|
||||
let tn = tester_name.read().clone();
|
||||
let te = tester_email.read().clone();
|
||||
let skip = *step.read() == 4 && !req_auth; // simplified skip check
|
||||
|
||||
let mut show = show;
|
||||
spawn(async move {
|
||||
let headers_map: std::collections::HashMap<String, String> = hdrs
|
||||
.into_iter()
|
||||
.filter(|(k, v)| !k.is_empty() && !v.is_empty())
|
||||
.collect();
|
||||
let scope_excl: Vec<String> = se
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
|
||||
let config = serde_json::json!({
|
||||
"app_url": url,
|
||||
"git_repo_url": if git.is_empty() { None } else { Some(git) },
|
||||
"branch": if br.is_empty() { None } else { Some(br) },
|
||||
"commit_hash": if ch.is_empty() { None } else { Some(ch) },
|
||||
"app_type": if at.is_empty() { None } else { Some(at) },
|
||||
"rate_limit": rl,
|
||||
"auth": {
|
||||
"mode": if !req_auth { "none" } else { &am },
|
||||
"username": if au.is_empty() { None } else { Some(&au) },
|
||||
"password": if ap.is_empty() { None } else { Some(&ap) },
|
||||
"registration_url": if ru.is_empty() { None } else { Some(&ru) },
|
||||
"verification_email": if ve.is_empty() { None } else { Some(&ve) },
|
||||
"imap_host": if ih.is_empty() { None } else { Some(&ih) },
|
||||
"imap_port": ip,
|
||||
"imap_username": if iu.is_empty() { None } else { Some(&iu) },
|
||||
"imap_password": if iw.is_empty() { None } else { Some(&iw) },
|
||||
"cleanup_test_user": cu,
|
||||
},
|
||||
"custom_headers": headers_map,
|
||||
"strategy": strat,
|
||||
"allow_destructive": ad,
|
||||
"initial_instructions": if ii.is_empty() { None } else { Some(&ii) },
|
||||
"scope_exclusions": scope_excl,
|
||||
"disclaimer_accepted": true,
|
||||
"disclaimer_accepted_at": chrono::Utc::now().to_rfc3339(),
|
||||
"environment": env,
|
||||
"tester": { "name": tn, "email": te },
|
||||
"max_duration_minutes": md,
|
||||
"skip_mode": skip,
|
||||
});
|
||||
|
||||
match create_pentest_session_wizard(config.to_string()).await {
|
||||
Ok(resp) => {
|
||||
let session_id = resp
|
||||
.data
|
||||
.get("_id")
|
||||
.and_then(|v| v.get("$oid"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
creating.set(false);
|
||||
show.set(false);
|
||||
if !session_id.is_empty() {
|
||||
navigator().push(Route::PentestSessionPage {
|
||||
session_id: session_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
creating.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Build filtered target list for dropdown
|
||||
let target_options: Vec<(String, String)> = {
|
||||
let t = existing_targets.read();
|
||||
match &*t {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let url = t.get("base_url").and_then(|v| v.as_str())?.to_string();
|
||||
let name = t
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&url)
|
||||
.to_string();
|
||||
Some((url, name))
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
// Build filtered repo list for dropdown
|
||||
let repo_options: Vec<(String, String)> = {
|
||||
let r = existing_repos.read();
|
||||
match &*r {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.iter()
|
||||
.map(|r| (r.git_url.clone(), r.name.clone()))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
// Filter targets based on current input
|
||||
let app_url_val = app_url.read().clone();
|
||||
let filtered_targets: Vec<(String, String)> = if app_url_val.is_empty() {
|
||||
target_options.clone()
|
||||
} else {
|
||||
let lower = app_url_val.to_lowercase();
|
||||
target_options
|
||||
.iter()
|
||||
.filter(|(url, name)| {
|
||||
url.to_lowercase().contains(&lower) || name.to_lowercase().contains(&lower)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Filter repos based on current input
|
||||
let git_url_val = git_repo_url.read().clone();
|
||||
let filtered_repos: Vec<(String, String)> = if git_url_val.is_empty() {
|
||||
repo_options.clone()
|
||||
} else {
|
||||
let lower = git_url_val.to_lowercase();
|
||||
repo_options
|
||||
.iter()
|
||||
.filter(|(url, name)| {
|
||||
url.to_lowercase().contains(&lower) || name.to_lowercase().contains(&lower)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
let current_step = *step.read();
|
||||
let show_ssh_section = is_ssh_url(&git_repo_url.read());
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "wizard-backdrop",
|
||||
onclick: close,
|
||||
div {
|
||||
class: "wizard-dialog",
|
||||
onclick: move |e| e.stop_propagation(),
|
||||
|
||||
// Close button (always visible)
|
||||
button {
|
||||
class: "wizard-close-btn",
|
||||
onclick: close,
|
||||
Icon { icon: BsXLg, width: 16, height: 16 }
|
||||
}
|
||||
|
||||
// Step indicator
|
||||
div { class: "wizard-steps",
|
||||
for (i, label) in [(1, "Target"), (2, "Auth"), (3, "Strategy"), (4, "Confirm")].iter() {
|
||||
{
|
||||
let step_class = if current_step == *i {
|
||||
"wizard-step active"
|
||||
} else if current_step > *i {
|
||||
"wizard-step completed"
|
||||
} else {
|
||||
"wizard-step"
|
||||
};
|
||||
rsx! {
|
||||
div { class: "{step_class}",
|
||||
div { class: "wizard-step-dot", "{i}" }
|
||||
span { class: "wizard-step-label", "{label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
div { class: "wizard-body",
|
||||
match current_step {
|
||||
1 => rsx! {
|
||||
h3 { style: "margin: 0 0 16px 0;", "Target & Scope" }
|
||||
|
||||
// App URL with dropdown
|
||||
div { class: "wizard-field", style: "position: relative;",
|
||||
label { "App URL " span { style: "color: #dc2626;", "*" } }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "url",
|
||||
placeholder: "https://example.com",
|
||||
value: "{app_url}",
|
||||
oninput: move |e| {
|
||||
app_url.set(e.value());
|
||||
show_target_dropdown.set(true);
|
||||
},
|
||||
onfocus: move |_| show_target_dropdown.set(true),
|
||||
}
|
||||
// Dropdown of existing targets
|
||||
if *show_target_dropdown.read() && !filtered_targets.is_empty() {
|
||||
div { class: "wizard-dropdown",
|
||||
for (url, name) in filtered_targets.iter() {
|
||||
{
|
||||
let url_clone = url.clone();
|
||||
let display_name = name.clone();
|
||||
let display_url = url.clone();
|
||||
rsx! {
|
||||
div {
|
||||
class: "wizard-dropdown-item",
|
||||
onclick: move |_| {
|
||||
app_url.set(url_clone.clone());
|
||||
show_target_dropdown.set(false);
|
||||
},
|
||||
div { style: "font-weight: 500;", "{display_name}" }
|
||||
div { style: "font-size: 0.75rem; color: var(--text-secondary); font-family: monospace;", "{display_url}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Git Repo URL with dropdown
|
||||
div { class: "wizard-field", style: "position: relative;",
|
||||
label { "Git Repository URL" }
|
||||
div { style: "display: flex; gap: 8px;",
|
||||
div { style: "flex: 1; position: relative;",
|
||||
input {
|
||||
class: "chat-input",
|
||||
style: "width: 100%;",
|
||||
placeholder: "https://github.com/org/repo.git",
|
||||
value: "{git_repo_url}",
|
||||
oninput: move |e| {
|
||||
git_repo_url.set(e.value());
|
||||
repo_looked_up.set(false);
|
||||
show_repo_dropdown.set(true);
|
||||
// Fetch SSH key if it looks like an SSH URL
|
||||
if is_ssh_url(&e.value()) && !*ssh_key_loaded.read() {
|
||||
spawn(async move {
|
||||
match fetch_ssh_public_key().await {
|
||||
Ok(key) => {
|
||||
ssh_public_key.set(key);
|
||||
ssh_key_loaded.set(true);
|
||||
}
|
||||
Err(_) => {
|
||||
ssh_public_key.set("(not available)".to_string());
|
||||
ssh_key_loaded.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onfocus: move |_| show_repo_dropdown.set(true),
|
||||
}
|
||||
// Dropdown of existing repos
|
||||
if *show_repo_dropdown.read() && !filtered_repos.is_empty() {
|
||||
div { class: "wizard-dropdown",
|
||||
for (url, name) in filtered_repos.iter() {
|
||||
{
|
||||
let url_clone = url.clone();
|
||||
let display_name = name.clone();
|
||||
let display_url = url.clone();
|
||||
let is_ssh = is_ssh_url(&url_clone);
|
||||
rsx! {
|
||||
div {
|
||||
class: "wizard-dropdown-item",
|
||||
onclick: move |_| {
|
||||
git_repo_url.set(url_clone.clone());
|
||||
show_repo_dropdown.set(false);
|
||||
repo_looked_up.set(false);
|
||||
// Auto-fetch SSH key if SSH URL selected
|
||||
if is_ssh && !*ssh_key_loaded.read() {
|
||||
spawn(async move {
|
||||
match fetch_ssh_public_key().await {
|
||||
Ok(key) => {
|
||||
ssh_public_key.set(key);
|
||||
ssh_key_loaded.set(true);
|
||||
}
|
||||
Err(_) => {
|
||||
ssh_public_key.set("(not available)".to_string());
|
||||
ssh_key_loaded.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
div { style: "font-weight: 500;", "{display_name}" }
|
||||
div { style: "font-size: 0.75rem; color: var(--text-secondary); font-family: monospace;", "{display_url}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
disabled: git_repo_url.read().is_empty(),
|
||||
onclick: move |_| {
|
||||
let url = git_repo_url.read().clone();
|
||||
spawn(async move {
|
||||
if let Ok(resp) = lookup_repo_by_url(url).await {
|
||||
if let Some(name) = resp.get("name").and_then(|v| v.as_str()) {
|
||||
repo_name.set(name.to_string());
|
||||
if let Some(b) = resp.get("default_branch").and_then(|v| v.as_str()) {
|
||||
branch.set(b.to_string());
|
||||
}
|
||||
if let Some(c) = resp.get("last_scanned_commit").and_then(|v| v.as_str()) {
|
||||
commit_hash.set(c.to_string());
|
||||
}
|
||||
}
|
||||
repo_looked_up.set(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
"Lookup"
|
||||
}
|
||||
}
|
||||
if *repo_looked_up.read() && !repo_name.read().is_empty() {
|
||||
div { style: "font-size: 0.8rem; color: var(--accent); margin-top: 4px;",
|
||||
Icon { icon: BsCheckCircle, width: 12, height: 12 }
|
||||
" Found: {repo_name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SSH deploy key section (shown for SSH URLs)
|
||||
if show_ssh_section {
|
||||
div { class: "wizard-ssh-key",
|
||||
div { style: "display: flex; align-items: center; gap: 6px; margin-bottom: 6px;",
|
||||
Icon { icon: BsKeyFill, width: 14, height: 14 }
|
||||
span { style: "font-size: 0.8rem; font-weight: 600;", "SSH Deploy Key" }
|
||||
}
|
||||
p { style: "font-size: 0.75rem; color: var(--text-secondary); margin: 0 0 6px 0;",
|
||||
"Add this read-only deploy key to your repository settings:"
|
||||
}
|
||||
div { class: "wizard-ssh-key-box",
|
||||
if ssh_public_key.read().is_empty() {
|
||||
"Loading..."
|
||||
} else {
|
||||
"{ssh_public_key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "Branch" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "main",
|
||||
value: "{branch}",
|
||||
oninput: move |e| branch.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Commit" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "HEAD",
|
||||
value: "{commit_hash}",
|
||||
oninput: move |e| commit_hash.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "App Type" }
|
||||
select {
|
||||
class: "chat-input",
|
||||
value: "{app_type}",
|
||||
onchange: move |e| app_type.set(e.value()),
|
||||
option { value: "web_app", "Web Application" }
|
||||
option { value: "api", "API" }
|
||||
option { value: "spa", "Single-Page App" }
|
||||
option { value: "mobile_backend", "Mobile Backend" }
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Rate Limit (req/s)" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "number",
|
||||
value: "{rate_limit}",
|
||||
oninput: move |e| rate_limit.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
2 => rsx! {
|
||||
h3 { style: "margin: 0 0 16px 0;", "Authentication" }
|
||||
|
||||
div { class: "wizard-field",
|
||||
label { style: "display: flex; align-items: center; gap: 8px;",
|
||||
"Requires authentication?"
|
||||
div {
|
||||
class: if *requires_auth.read() { "wizard-toggle active" } else { "wizard-toggle" },
|
||||
onclick: move |_| { let v = *requires_auth.read(); requires_auth.set(!v); },
|
||||
div { class: "wizard-toggle-knob" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *requires_auth.read() {
|
||||
div { class: "wizard-field",
|
||||
div { style: "display: flex; gap: 12px; margin-bottom: 12px;",
|
||||
label { style: "display: flex; align-items: center; gap: 4px; cursor: pointer;",
|
||||
input {
|
||||
r#type: "radio",
|
||||
name: "auth_mode",
|
||||
value: "manual",
|
||||
checked: auth_mode.read().as_str() == "manual",
|
||||
onchange: move |_| auth_mode.set("manual".to_string()),
|
||||
}
|
||||
"Manual Credentials"
|
||||
}
|
||||
label { style: "display: flex; align-items: center; gap: 4px; cursor: pointer;",
|
||||
input {
|
||||
r#type: "radio",
|
||||
name: "auth_mode",
|
||||
value: "auto_register",
|
||||
checked: auth_mode.read().as_str() == "auto_register",
|
||||
onchange: move |_| auth_mode.set("auto_register".to_string()),
|
||||
}
|
||||
"Auto-Register"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if auth_mode.read().as_str() == "manual" {
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "Username" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
value: "{auth_username}",
|
||||
oninput: move |e| auth_username.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Password" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "password",
|
||||
value: "{auth_password}",
|
||||
oninput: move |e| auth_password.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if auth_mode.read().as_str() == "auto_register" {
|
||||
div { class: "wizard-field",
|
||||
label { "Registration URL"
|
||||
span { style: "font-weight: 400; color: var(--text-tertiary); font-size: 0.75rem; margin-left: 6px;", "(optional)" }
|
||||
}
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "https://example.com/register",
|
||||
value: "{registration_url}",
|
||||
oninput: move |e| registration_url.set(e.value()),
|
||||
}
|
||||
div { style: "font-size: 0.75rem; color: var(--text-tertiary); margin-top: 3px;",
|
||||
"If omitted, the orchestrator will use Playwright to discover the registration page automatically."
|
||||
}
|
||||
}
|
||||
|
||||
// Verification email (plus-addressing) — optional override
|
||||
div { class: "wizard-field",
|
||||
label { "Verification Email"
|
||||
span { style: "font-weight: 400; color: var(--text-tertiary); font-size: 0.75rem; margin-left: 6px;", "(optional override)" }
|
||||
}
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "pentest@scanner.example.com",
|
||||
value: "{verification_email}",
|
||||
oninput: move |e| verification_email.set(e.value()),
|
||||
}
|
||||
div { style: "font-size: 0.75rem; color: var(--text-tertiary); margin-top: 3px;",
|
||||
"Overrides the agent's default mailbox. Uses plus-addressing: "
|
||||
code { style: "font-size: 0.7rem;", "base+sessionid@domain" }
|
||||
". Leave blank to use the server default."
|
||||
}
|
||||
}
|
||||
|
||||
// IMAP settings (collapsible)
|
||||
div { class: "wizard-field",
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
style: "font-size: 0.8rem; padding: 2px 8px;",
|
||||
onclick: move |_| { let v = *show_imap_settings.read(); show_imap_settings.set(!v); },
|
||||
if *show_imap_settings.read() {
|
||||
Icon { icon: BsChevronDown, width: 10, height: 10 }
|
||||
} else {
|
||||
Icon { icon: BsChevronRight, width: 10, height: 10 }
|
||||
}
|
||||
" IMAP Settings"
|
||||
}
|
||||
}
|
||||
if *show_imap_settings.read() {
|
||||
div { style: "display: grid; grid-template-columns: 2fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "IMAP Host" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "imap.example.com",
|
||||
value: "{imap_host}",
|
||||
oninput: move |e| imap_host.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Port" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "number",
|
||||
value: "{imap_port}",
|
||||
oninput: move |e| imap_port.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "IMAP Username"
|
||||
span { style: "font-weight: 400; color: var(--text-tertiary); font-size: 0.75rem; margin-left: 6px;", "(defaults to email)" }
|
||||
}
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "pentest@scanner.example.com",
|
||||
value: "{imap_username}",
|
||||
oninput: move |e| imap_username.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "IMAP Password" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "password",
|
||||
placeholder: "App password",
|
||||
value: "{imap_password}",
|
||||
oninput: move |e| imap_password.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup option
|
||||
div { style: "margin-top: 8px;",
|
||||
label { style: "display: flex; align-items: center; gap: 6px; font-size: 0.85rem; cursor: pointer;",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: *cleanup_test_user.read(),
|
||||
onchange: move |_| { let v = *cleanup_test_user.read(); cleanup_test_user.set(!v); },
|
||||
}
|
||||
"Cleanup test user after"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
div { class: "wizard-field", style: "margin-top: 16px;",
|
||||
label { "Custom HTTP Headers" }
|
||||
for (idx, _) in custom_headers.read().iter().enumerate() {
|
||||
{
|
||||
let key = custom_headers.read().get(idx).map(|(k, _)| k.clone()).unwrap_or_default();
|
||||
let val = custom_headers.read().get(idx).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
rsx! {
|
||||
div { style: "display: flex; gap: 8px; margin-bottom: 4px;",
|
||||
input {
|
||||
class: "chat-input",
|
||||
style: "flex: 1;",
|
||||
placeholder: "Header name",
|
||||
value: "{key}",
|
||||
oninput: move |e| {
|
||||
let mut h = custom_headers.write();
|
||||
if let Some(pair) = h.get_mut(idx) {
|
||||
pair.0 = e.value();
|
||||
}
|
||||
},
|
||||
}
|
||||
input {
|
||||
class: "chat-input",
|
||||
style: "flex: 1;",
|
||||
placeholder: "Value",
|
||||
value: "{val}",
|
||||
oninput: move |e| {
|
||||
let mut h = custom_headers.write();
|
||||
if let Some(pair) = h.get_mut(idx) {
|
||||
pair.1 = e.value();
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
style: "color: #dc2626;",
|
||||
onclick: move |_| {
|
||||
custom_headers.write().remove(idx);
|
||||
},
|
||||
Icon { icon: BsXCircle, width: 14, height: 14 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
onclick: move |_| {
|
||||
custom_headers.write().push((String::new(), String::new()));
|
||||
},
|
||||
Icon { icon: BsPlusCircle, width: 12, height: 12 }
|
||||
" Add Header"
|
||||
}
|
||||
}
|
||||
},
|
||||
3 => rsx! {
|
||||
h3 { style: "margin: 0 0 16px 0;", "Strategy & Instructions" }
|
||||
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "Strategy" }
|
||||
select {
|
||||
class: "chat-input",
|
||||
value: "{strategy}",
|
||||
onchange: move |e| strategy.set(e.value()),
|
||||
option { value: "comprehensive", "Comprehensive" }
|
||||
option { value: "quick", "Quick Scan" }
|
||||
option { value: "targeted", "Targeted (SAST-guided)" }
|
||||
option { value: "aggressive", "Aggressive" }
|
||||
option { value: "stealth", "Stealth" }
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Environment" }
|
||||
select {
|
||||
class: "chat-input",
|
||||
value: "{environment}",
|
||||
onchange: move |e| environment.set(e.value()),
|
||||
option { value: "development", "Development" }
|
||||
option { value: "staging", "Staging" }
|
||||
option { value: "production", "Production" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "wizard-field",
|
||||
label { style: "display: flex; align-items: center; gap: 8px;",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: *allow_destructive.read(),
|
||||
onchange: move |_| { let v = *allow_destructive.read(); allow_destructive.set(!v); },
|
||||
}
|
||||
"Allow destructive tests (DELETE, PUT, data modification)"
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "wizard-field",
|
||||
label { "Initial Instructions" }
|
||||
textarea {
|
||||
class: "chat-input",
|
||||
style: "width: 100%; min-height: 80px;",
|
||||
placeholder: "Describe focus areas, known issues, or specific test scenarios...",
|
||||
value: "{initial_instructions}",
|
||||
oninput: move |e| initial_instructions.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "wizard-field",
|
||||
label { "Scope Exclusions (one path per line)" }
|
||||
textarea {
|
||||
class: "chat-input",
|
||||
style: "width: 100%; min-height: 60px;",
|
||||
placeholder: "/admin\n/health\n/api/v1/internal",
|
||||
value: "{scope_exclusions}",
|
||||
oninput: move |e| scope_exclusions.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "Max Duration (min)" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "number",
|
||||
value: "{max_duration}",
|
||||
oninput: move |e| max_duration.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Tester Name" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
value: "{tester_name}",
|
||||
oninput: move |e| tester_name.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Tester Email" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "email",
|
||||
value: "{tester_email}",
|
||||
oninput: move |e| tester_email.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
4 => rsx! {
|
||||
h3 { style: "margin: 0 0 16px 0;", "Review & Confirm" }
|
||||
|
||||
// Summary
|
||||
div { class: "wizard-summary",
|
||||
dl {
|
||||
dt { "Target URL" }
|
||||
dd { code { "{app_url}" } }
|
||||
|
||||
if !git_repo_url.read().is_empty() {
|
||||
dt { "Git Repository" }
|
||||
dd { "{git_repo_url}" }
|
||||
}
|
||||
|
||||
dt { "Strategy" }
|
||||
dd { "{strategy}" }
|
||||
|
||||
dt { "Environment" }
|
||||
dd { "{environment}" }
|
||||
|
||||
dt { "Auth Mode" }
|
||||
dd { if *requires_auth.read() { "{auth_mode}" } else { "None" } }
|
||||
|
||||
dt { "Max Duration" }
|
||||
dd { "{max_duration} minutes" }
|
||||
|
||||
if *allow_destructive.read() {
|
||||
dt { "Destructive Tests" }
|
||||
dd { "Allowed" }
|
||||
}
|
||||
|
||||
if !tester_name.read().is_empty() {
|
||||
dt { "Tester" }
|
||||
dd { "{tester_name} ({tester_email})" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disclaimer
|
||||
div { class: "wizard-disclaimer",
|
||||
Icon { icon: BsExclamationTriangle, width: 16, height: 16 }
|
||||
p { style: "margin: 8px 0;", "{DISCLAIMER_TEXT}" }
|
||||
label { style: "display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600;",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: *disclaimer_accepted.read(),
|
||||
onchange: move |_| { let v = *disclaimer_accepted.read(); disclaimer_accepted.set(!v); },
|
||||
}
|
||||
"I accept this disclaimer"
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => rsx! {},
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
div { class: "wizard-footer",
|
||||
// Left side: skip button
|
||||
div {
|
||||
if current_step == 1 && can_skip {
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
onclick: on_skip_to_blackbox,
|
||||
Icon { icon: BsLightning, width: 12, height: 12 }
|
||||
" Skip to Black Box"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Right side: navigation
|
||||
div { style: "display: flex; gap: 8px;",
|
||||
if current_step == 1 {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: close,
|
||||
"Cancel"
|
||||
}
|
||||
} else {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| step.set(current_step - 1),
|
||||
"Back"
|
||||
}
|
||||
}
|
||||
if current_step < 4 {
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: current_step == 1 && app_url.read().is_empty(),
|
||||
onclick: move |_| step.set(current_step + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
if current_step == 4 {
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: !*disclaimer_accepted.read() || *creating.read(),
|
||||
onclick: on_submit,
|
||||
if *creating.read() { "Starting..." } else { "Start Pentest" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,65 @@ pub async fn create_pentest_session(
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Create a pentest session using the wizard configuration
|
||||
#[server]
|
||||
pub async fn create_pentest_session_wizard(
|
||||
config_json: String,
|
||||
) -> Result<PentestSessionResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/pentest/sessions", state.agent_api_url);
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(&config_json).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "config": config }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Failed to create session: {text}"
|
||||
)));
|
||||
}
|
||||
let body: PentestSessionResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Look up a tracked repository by its git URL
|
||||
#[server]
|
||||
pub async fn lookup_repo_by_url(url: String) -> Result<serde_json::Value, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let encoded_url: String = url
|
||||
.bytes()
|
||||
.flat_map(|b| {
|
||||
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~' {
|
||||
vec![b as char]
|
||||
} else {
|
||||
format!("%{:02X}", b).chars().collect()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let api_url = format!(
|
||||
"{}/api/v1/pentest/lookup-repo?url={}",
|
||||
state.agent_api_url, encoded_url
|
||||
);
|
||||
let resp = reqwest::get(&api_url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body.get("data").cloned().unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn send_pentest_message(
|
||||
session_id: String,
|
||||
@@ -250,6 +309,48 @@ pub async fn stop_pentest_session(session_id: String) -> Result<(), ServerFnErro
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn pause_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}/pause",
|
||||
state.agent_api_url
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!("Pause failed: {text}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn resume_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}/resume",
|
||||
state.agent_api_url
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!("Resume failed: {text}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_pentest_findings(
|
||||
session_id: String,
|
||||
|
||||
@@ -4,59 +4,18 @@ use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::dast::fetch_dast_targets;
|
||||
use crate::components::pentest_wizard::PentestWizard;
|
||||
use crate::infrastructure::pentest::{
|
||||
create_pentest_session, fetch_pentest_sessions, fetch_pentest_stats, stop_pentest_session,
|
||||
fetch_pentest_sessions, fetch_pentest_stats, pause_pentest_session, resume_pentest_session,
|
||||
stop_pentest_session,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn PentestDashboardPage() -> Element {
|
||||
let mut sessions = use_resource(|| async { fetch_pentest_sessions().await.ok() });
|
||||
let stats = use_resource(|| async { fetch_pentest_stats().await.ok() });
|
||||
let targets = use_resource(|| async { fetch_dast_targets().await.ok() });
|
||||
|
||||
let mut show_modal = use_signal(|| false);
|
||||
let mut new_target_id = use_signal(String::new);
|
||||
let mut new_strategy = use_signal(|| "comprehensive".to_string());
|
||||
let mut new_message = use_signal(String::new);
|
||||
let mut creating = use_signal(|| false);
|
||||
|
||||
let on_create = move |_| {
|
||||
let tid = new_target_id.read().clone();
|
||||
let strat = new_strategy.read().clone();
|
||||
let msg = new_message.read().clone();
|
||||
if tid.is_empty() || msg.is_empty() {
|
||||
return;
|
||||
}
|
||||
creating.set(true);
|
||||
spawn(async move {
|
||||
match create_pentest_session(tid, strat, msg).await {
|
||||
Ok(resp) => {
|
||||
let session_id = resp
|
||||
.data
|
||||
.get("_id")
|
||||
.and_then(|v| v.get("$oid"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
creating.set(false);
|
||||
show_modal.set(false);
|
||||
new_target_id.set(String::new());
|
||||
new_message.set(String::new());
|
||||
if !session_id.is_empty() {
|
||||
navigator().push(Route::PentestSessionPage {
|
||||
session_id: session_id.clone(),
|
||||
});
|
||||
} else {
|
||||
sessions.restart();
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
creating.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
let mut show_wizard = use_signal(|| false);
|
||||
|
||||
// Extract stats values
|
||||
let running_sessions = {
|
||||
@@ -193,7 +152,7 @@ pub fn PentestDashboardPage() -> Element {
|
||||
div { style: "display: flex; gap: 12px; margin-bottom: 24px;",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| show_modal.set(true),
|
||||
onclick: move |_| show_wizard.set(true),
|
||||
Icon { icon: BsPlusCircle, width: 14, height: 14 }
|
||||
" New Pentest"
|
||||
}
|
||||
@@ -235,7 +194,10 @@ pub fn PentestDashboardPage() -> Element {
|
||||
};
|
||||
{
|
||||
let is_session_running = status == "running";
|
||||
let is_session_paused = status == "paused";
|
||||
let stop_id = id.clone();
|
||||
let pause_id = id.clone();
|
||||
let resume_id = id.clone();
|
||||
rsx! {
|
||||
div { class: "card", style: "padding: 16px; transition: border-color 0.15s;",
|
||||
Link {
|
||||
@@ -272,8 +234,42 @@ pub fn PentestDashboardPage() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_session_running {
|
||||
div { style: "margin-top: 8px; display: flex; justify-content: flex-end;",
|
||||
if is_session_running || is_session_paused {
|
||||
div { style: "margin-top: 8px; display: flex; justify-content: flex-end; gap: 6px;",
|
||||
if is_session_running {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.8rem; padding: 4px 12px; color: #d97706; border-color: #d97706;",
|
||||
onclick: move |e| {
|
||||
e.stop_propagation();
|
||||
e.prevent_default();
|
||||
let sid = pause_id.clone();
|
||||
spawn(async move {
|
||||
let _ = pause_pentest_session(sid).await;
|
||||
sessions.restart();
|
||||
});
|
||||
},
|
||||
Icon { icon: BsPauseCircle, width: 12, height: 12 }
|
||||
" Pause"
|
||||
}
|
||||
}
|
||||
if is_session_paused {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.8rem; padding: 4px 12px; color: #16a34a; border-color: #16a34a;",
|
||||
onclick: move |e| {
|
||||
e.stop_propagation();
|
||||
e.prevent_default();
|
||||
let sid = resume_id.clone();
|
||||
spawn(async move {
|
||||
let _ = resume_pentest_session(sid).await;
|
||||
sessions.restart();
|
||||
});
|
||||
},
|
||||
Icon { icon: BsPlayCircle, width: 12, height: 12 }
|
||||
" Resume"
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.8rem; padding: 4px 12px; color: #dc2626; border-color: #dc2626;",
|
||||
@@ -305,97 +301,9 @@ pub fn PentestDashboardPage() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
// New Pentest Modal
|
||||
if *show_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_modal.set(false),
|
||||
div {
|
||||
style: "background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 24px; width: 480px; max-width: 90vw;",
|
||||
onclick: move |e| e.stop_propagation(),
|
||||
h3 { style: "margin: 0 0 16px 0;", "New Pentest Session" }
|
||||
|
||||
// Target selection
|
||||
div { style: "margin-bottom: 12px;",
|
||||
label { style: "display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px;",
|
||||
"Target"
|
||||
}
|
||||
select {
|
||||
class: "chat-input",
|
||||
style: "width: 100%; padding: 8px; resize: none; height: auto;",
|
||||
value: "{new_target_id}",
|
||||
onchange: move |e| new_target_id.set(e.value()),
|
||||
option { value: "", "Select a target..." }
|
||||
match &*targets.read() {
|
||||
Some(Some(data)) => {
|
||||
rsx! {
|
||||
for target in &data.data {
|
||||
{
|
||||
let tid = target.get("_id")
|
||||
.and_then(|v| v.get("$oid"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("").to_string();
|
||||
let tname = target.get("name").and_then(|v| v.as_str()).unwrap_or("Unknown").to_string();
|
||||
let turl = target.get("base_url").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
rsx! {
|
||||
option { value: "{tid}", "{tname} ({turl})" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => rsx! {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy selection
|
||||
div { style: "margin-bottom: 12px;",
|
||||
label { style: "display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px;",
|
||||
"Strategy"
|
||||
}
|
||||
select {
|
||||
class: "chat-input",
|
||||
style: "width: 100%; padding: 8px; resize: none; height: auto;",
|
||||
value: "{new_strategy}",
|
||||
onchange: move |e| new_strategy.set(e.value()),
|
||||
option { value: "comprehensive", "Comprehensive" }
|
||||
option { value: "quick", "Quick Scan" }
|
||||
option { value: "owasp_top_10", "OWASP Top 10" }
|
||||
option { value: "api_focused", "API Focused" }
|
||||
option { value: "authentication", "Authentication" }
|
||||
}
|
||||
}
|
||||
|
||||
// Initial message
|
||||
div { style: "margin-bottom: 16px;",
|
||||
label { style: "display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px;",
|
||||
"Initial Instructions"
|
||||
}
|
||||
textarea {
|
||||
class: "chat-input",
|
||||
style: "width: 100%; min-height: 80px;",
|
||||
placeholder: "Describe the scope and goals of this pentest...",
|
||||
value: "{new_message}",
|
||||
oninput: move |e| new_message.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "display: flex; justify-content: flex-end; gap: 8px;",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| show_modal.set(false),
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: *creating.read() || new_target_id.read().is_empty() || new_message.read().is_empty(),
|
||||
onclick: on_create,
|
||||
if *creating.read() { "Creating..." } else { "Start Pentest" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pentest Wizard
|
||||
if *show_wizard.read() {
|
||||
PentestWizard { show: show_wizard }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::components::attack_chain::AttackChainView;
|
||||
use crate::components::severity_badge::SeverityBadge;
|
||||
use crate::infrastructure::pentest::{
|
||||
export_pentest_report, fetch_attack_chain, fetch_pentest_findings, fetch_pentest_session,
|
||||
pause_pentest_session, resume_pentest_session,
|
||||
};
|
||||
|
||||
#[component]
|
||||
@@ -87,11 +88,13 @@ pub fn PentestSessionPage(session_id: String) -> Element {
|
||||
};
|
||||
|
||||
let is_running = session_status == "running";
|
||||
let is_paused = session_status == "paused";
|
||||
let is_active = is_running || is_paused;
|
||||
|
||||
// Poll while running
|
||||
// Poll while running or paused
|
||||
use_effect(move || {
|
||||
let _gen = *poll_gen.read();
|
||||
if is_running {
|
||||
if is_active {
|
||||
spawn(async move {
|
||||
#[cfg(feature = "web")]
|
||||
gloo_timers::future::TimeoutFuture::new(3_000).await;
|
||||
@@ -226,9 +229,55 @@ pub fn PentestSessionPage(session_id: String) -> Element {
|
||||
" Running..."
|
||||
}
|
||||
}
|
||||
if is_paused {
|
||||
span { style: "font-size: 0.8rem; color: #d97706;",
|
||||
Icon { icon: BsPauseCircle, width: 12, height: 12 }
|
||||
" Paused"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { style: "display: flex; gap: 8px;",
|
||||
if is_running {
|
||||
{
|
||||
let sid_pause = session_id.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.85rem; color: #d97706; border-color: #d97706;",
|
||||
onclick: move |_| {
|
||||
let sid = sid_pause.clone();
|
||||
spawn(async move {
|
||||
let _ = pause_pentest_session(sid).await;
|
||||
session.restart();
|
||||
});
|
||||
},
|
||||
Icon { icon: BsPauseCircle, width: 14, height: 14 }
|
||||
" Pause"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_paused {
|
||||
{
|
||||
let sid_resume = session_id.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.85rem; color: #16a34a; border-color: #16a34a;",
|
||||
onclick: move |_| {
|
||||
let sid = sid_resume.clone();
|
||||
spawn(async move {
|
||||
let _ = resume_pentest_session(sid).await;
|
||||
session.restart();
|
||||
});
|
||||
},
|
||||
Icon { icon: BsPlayCircle, width: 14, height: 14 }
|
||||
" Resume"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
style: "font-size: 0.85rem;",
|
||||
|
||||
@@ -31,6 +31,11 @@ bollard = "0.18"
|
||||
native-tls = "0.2"
|
||||
tokio-native-tls = "0.3"
|
||||
|
||||
# CDP WebSocket (browser tool)
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
base64 = "0.22"
|
||||
|
||||
# Serialization
|
||||
bson = { version = "2", features = ["chrono-0_4"] }
|
||||
url = "2"
|
||||
|
||||
488
compliance-dast/src/tools/browser.rs
Normal file
@@ -0,0 +1,488 @@
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::info;
|
||||
|
||||
type WsStream =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
/// A browser automation tool that exposes headless Chrome actions to the LLM
|
||||
/// via the Chrome DevTools Protocol. Reuses the same `CHROME_WS_URL` used for
|
||||
/// PDF generation.
|
||||
///
|
||||
/// Supported actions: navigate, screenshot, click, fill, get_content, evaluate.
|
||||
pub struct BrowserTool;
|
||||
|
||||
impl Default for BrowserTool {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl PentestTool for BrowserTool {
|
||||
fn name(&self) -> &str {
|
||||
"browser"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Headless browser automation via Chrome DevTools Protocol. \
|
||||
Supports navigating to URLs, taking screenshots, clicking elements, \
|
||||
filling form fields, reading page content, and evaluating JavaScript. \
|
||||
Use CSS selectors to target elements. Useful for discovering registration pages, \
|
||||
filling out forms, extracting verification links, and visual inspection."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["navigate", "screenshot", "click", "fill", "get_content", "evaluate"],
|
||||
"description": "Action to perform"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to navigate to (for 'navigate' action)"
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector for click/fill actions"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Text value for 'fill' action, or JS expression for 'evaluate'"
|
||||
},
|
||||
"wait_ms": {
|
||||
"type": "integer",
|
||||
"description": "Milliseconds to wait after action (default: 500)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
input: serde_json::Value,
|
||||
_context: &'a PentestToolContext,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
let action = input.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = input.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let selector = input.get("selector").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let value = input.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let wait_ms = input.get("wait_ms").and_then(|v| v.as_u64()).unwrap_or(500);
|
||||
|
||||
let mut session = BrowserSession::connect()
|
||||
.await
|
||||
.map_err(|e| CoreError::Other(format!("Browser connect failed: {e}")))?;
|
||||
|
||||
let result = match action {
|
||||
"navigate" => session.navigate(url, wait_ms).await,
|
||||
"screenshot" => session.screenshot().await,
|
||||
"click" => session.click(selector, wait_ms).await,
|
||||
"fill" => session.fill(selector, value, wait_ms).await,
|
||||
"get_content" => session.get_content().await,
|
||||
"evaluate" => session.evaluate(value).await,
|
||||
_ => Err(format!("Unknown browser action: {action}")),
|
||||
};
|
||||
|
||||
// Always try to clean up
|
||||
let _ = session.close().await;
|
||||
|
||||
match result {
|
||||
Ok(data) => {
|
||||
let summary = match action {
|
||||
"navigate" => format!("Navigated to {url}"),
|
||||
"screenshot" => "Captured page screenshot".to_string(),
|
||||
"click" => format!("Clicked element: {selector}"),
|
||||
"fill" => format!("Filled element: {selector}"),
|
||||
"get_content" => "Retrieved page content".to_string(),
|
||||
"evaluate" => "Evaluated JavaScript".to_string(),
|
||||
_ => "Browser action completed".to_string(),
|
||||
};
|
||||
info!(action, %summary, "Browser tool executed");
|
||||
Ok(PentestToolResult {
|
||||
summary,
|
||||
findings: Vec::new(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(PentestToolResult {
|
||||
summary: format!("Browser action '{action}' failed: {e}"),
|
||||
findings: Vec::new(),
|
||||
data: json!({ "error": e }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A single CDP session wrapping a browser tab.
|
||||
struct BrowserSession {
|
||||
ws: WsStream,
|
||||
next_id: u64,
|
||||
session_id: String,
|
||||
target_id: String,
|
||||
}
|
||||
|
||||
impl BrowserSession {
|
||||
/// Connect to headless Chrome and create a new tab.
|
||||
async fn connect() -> Result<Self, String> {
|
||||
let ws_url = std::env::var("CHROME_WS_URL").map_err(|_| {
|
||||
"CHROME_WS_URL not set — headless Chrome is required for browser actions".to_string()
|
||||
})?;
|
||||
|
||||
// Discover browser WS endpoint
|
||||
let http_url = ws_url
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://");
|
||||
let version_url = format!("{http_url}/json/version");
|
||||
|
||||
let version: serde_json::Value = reqwest::get(&version_url)
|
||||
.await
|
||||
.map_err(|e| format!("Cannot reach Chrome at {version_url}: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid /json/version response: {e}"))?;
|
||||
|
||||
let browser_ws = version["webSocketDebuggerUrl"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "No webSocketDebuggerUrl in /json/version".to_string())?;
|
||||
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(browser_ws)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket connect failed: {e}"))?;
|
||||
|
||||
let mut next_id: u64 = 1;
|
||||
|
||||
// Create tab
|
||||
let resp = cdp_send(
|
||||
&mut ws,
|
||||
next_id,
|
||||
"Target.createTarget",
|
||||
json!({ "url": "about:blank" }),
|
||||
)
|
||||
.await?;
|
||||
next_id += 1;
|
||||
|
||||
let target_id = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("targetId"))
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or("No targetId in createTarget response")?
|
||||
.to_string();
|
||||
|
||||
// Attach
|
||||
let resp = cdp_send(
|
||||
&mut ws,
|
||||
next_id,
|
||||
"Target.attachToTarget",
|
||||
json!({ "targetId": target_id, "flatten": true }),
|
||||
)
|
||||
.await?;
|
||||
next_id += 1;
|
||||
|
||||
let session_id = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("sessionId"))
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or("No sessionId in attachToTarget response")?
|
||||
.to_string();
|
||||
|
||||
// Enable domains
|
||||
cdp_send_session(&mut ws, next_id, &session_id, "Page.enable", json!({})).await?;
|
||||
next_id += 1;
|
||||
|
||||
cdp_send_session(&mut ws, next_id, &session_id, "Runtime.enable", json!({})).await?;
|
||||
next_id += 1;
|
||||
|
||||
Ok(Self {
|
||||
ws,
|
||||
next_id,
|
||||
session_id,
|
||||
target_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn navigate(&mut self, url: &str, wait_ms: u64) -> Result<serde_json::Value, String> {
|
||||
let resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Page.navigate",
|
||||
json!({ "url": url }),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
|
||||
|
||||
// Get page title
|
||||
let title_resp = self.evaluate_raw("document.title").await?;
|
||||
let page_url_resp = self.evaluate_raw("window.location.href").await?;
|
||||
|
||||
Ok(json!({
|
||||
"navigated": true,
|
||||
"url": page_url_resp,
|
||||
"title": title_resp,
|
||||
"frame_id": resp.get("result").and_then(|r| r.get("frameId")),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn screenshot(&mut self) -> Result<serde_json::Value, String> {
|
||||
let resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Page.captureScreenshot",
|
||||
json!({ "format": "png", "quality": 80 }),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
let b64 = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("data"))
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let size_kb = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.map(|b| b.len() / 1024)
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(json!({
|
||||
"screenshot_base64": b64,
|
||||
"size_kb": size_kb,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn click(&mut self, selector: &str, wait_ms: u64) -> Result<serde_json::Value, String> {
|
||||
// Use JS to find element and get its bounding box, then click
|
||||
let js = format!(
|
||||
r#"(function() {{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return JSON.stringify({{error: "Element not found: {raw}"}});
|
||||
var rect = el.getBoundingClientRect();
|
||||
el.click();
|
||||
return JSON.stringify({{
|
||||
clicked: true,
|
||||
tag: el.tagName,
|
||||
text: el.textContent.substring(0, 100),
|
||||
x: rect.x + rect.width/2,
|
||||
y: rect.y + rect.height/2
|
||||
}});
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap_or_default(),
|
||||
raw = selector.replace('"', r#"\""#),
|
||||
);
|
||||
|
||||
let result = self.evaluate_raw(&js).await?;
|
||||
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(&result)
|
||||
.unwrap_or_else(|_| json!({ "result": result }));
|
||||
Ok(serde_json::from_str(&result).unwrap_or(json!({ "result": result })))
|
||||
}
|
||||
|
||||
async fn fill(
|
||||
&mut self,
|
||||
selector: &str,
|
||||
value: &str,
|
||||
wait_ms: u64,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let js = format!(
|
||||
r#"(function() {{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return JSON.stringify({{error: "Element not found: {raw}"}});
|
||||
el.focus();
|
||||
el.value = {val};
|
||||
el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
||||
el.dispatchEvent(new Event('change', {{bubbles: true}}));
|
||||
return JSON.stringify({{filled: true, tag: el.tagName, selector: {sel}}});
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap_or_default(),
|
||||
raw = selector.replace('"', r#"\""#),
|
||||
val = serde_json::to_string(value).unwrap_or_default(),
|
||||
);
|
||||
|
||||
let result = self.evaluate_raw(&js).await?;
|
||||
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
|
||||
|
||||
Ok(serde_json::from_str(&result).unwrap_or(json!({ "result": result })))
|
||||
}
|
||||
|
||||
async fn get_content(&mut self) -> Result<serde_json::Value, String> {
|
||||
let resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"DOM.getDocument",
|
||||
json!({ "depth": 0 }),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
let root_id = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("root"))
|
||||
.and_then(|n| n.get("nodeId"))
|
||||
.and_then(|n| n.as_i64())
|
||||
.unwrap_or(1);
|
||||
|
||||
let html_resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"DOM.getOuterHTML",
|
||||
json!({ "nodeId": root_id }),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
let html = html_resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("outerHTML"))
|
||||
.and_then(|h| h.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Also get page title and URL for context
|
||||
let title = self
|
||||
.evaluate_raw("document.title")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let url = self
|
||||
.evaluate_raw("window.location.href")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Truncate HTML to avoid massive payloads to the LLM
|
||||
let truncated = if html.len() > 50_000 {
|
||||
format!(
|
||||
"{}... [truncated, {} total chars]",
|
||||
&html[..50_000],
|
||||
html.len()
|
||||
)
|
||||
} else {
|
||||
html.to_string()
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"url": url,
|
||||
"title": title,
|
||||
"html": truncated,
|
||||
"html_length": html.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn evaluate(&mut self, expression: &str) -> Result<serde_json::Value, String> {
|
||||
let result = self.evaluate_raw(expression).await?;
|
||||
Ok(json!({
|
||||
"result": result,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Execute JS and return the string result.
|
||||
async fn evaluate_raw(&mut self, expression: &str) -> Result<String, String> {
|
||||
let resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Runtime.evaluate",
|
||||
json!({
|
||||
"expression": expression,
|
||||
"returnByValue": true,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
let result = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("result"))
|
||||
.and_then(|r| r.get("value"));
|
||||
|
||||
match result {
|
||||
Some(serde_json::Value::String(s)) => Ok(s.clone()),
|
||||
Some(v) => Ok(v.to_string()),
|
||||
None => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> Result<(), String> {
|
||||
let _ = cdp_send(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
"Target.closeTarget",
|
||||
json!({ "targetId": self.target_id }),
|
||||
)
|
||||
.await;
|
||||
let _ = self.ws.close(None).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── CDP helpers (same pattern as compliance-agent/src/pentest/report/pdf.rs) ──
|
||||
|
||||
async fn cdp_send(
|
||||
ws: &mut WsStream,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let msg = json!({ "id": id, "method": method, "params": params });
|
||||
ws.send(Message::Text(msg.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| format!("WS send failed: {e}"))?;
|
||||
read_until_result(ws, id).await
|
||||
}
|
||||
|
||||
async fn cdp_send_session(
|
||||
ws: &mut WsStream,
|
||||
id: u64,
|
||||
session_id: &str,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let msg = json!({
|
||||
"id": id,
|
||||
"sessionId": session_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
ws.send(Message::Text(msg.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| format!("WS send failed: {e}"))?;
|
||||
read_until_result(ws, id).await
|
||||
}
|
||||
|
||||
async fn read_until_result(ws: &mut WsStream, id: u64) -> Result<serde_json::Value, String> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let msg = tokio::time::timeout_at(deadline, ws.next())
|
||||
.await
|
||||
.map_err(|_| format!("Timeout waiting for CDP response id={id}"))?
|
||||
.ok_or_else(|| "WebSocket closed unexpectedly".to_string())?
|
||||
.map_err(|e| format!("WebSocket read error: {e}"))?;
|
||||
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if val.get("id").and_then(|i| i.as_u64()) == Some(id) {
|
||||
if let Some(err) = val.get("error") {
|
||||
return Err(format!("CDP error: {err}"));
|
||||
}
|
||||
return Ok(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod api_fuzzer;
|
||||
pub mod auth_bypass;
|
||||
pub mod browser;
|
||||
pub mod console_log_detector;
|
||||
pub mod cookie_analyzer;
|
||||
pub mod cors_checker;
|
||||
@@ -114,6 +115,7 @@ impl ToolRegistry {
|
||||
Box::new(openapi_parser::OpenApiParserTool::new(http.clone())),
|
||||
);
|
||||
register(&mut tools, Box::new(recon::ReconTool::new(http)));
|
||||
register(&mut tools, Box::<browser::BrowserTool>::default());
|
||||
|
||||
Self { tools }
|
||||
}
|
||||
|
||||
273
docs/features/pentest-architecture.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Pentest Orchestration Architecture
|
||||
|
||||
This document explains how the AI pentest orchestrator works under the hood — which steps use the LLM, what context is passed at each stage, and how findings are correlated back to source code.
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Wizard["Onboarding Wizard (Dashboard)"]
|
||||
W1[Step 1: Target & Scope] --> W2[Step 2: Authentication]
|
||||
W2 --> W3[Step 3: Strategy & Instructions]
|
||||
W3 --> W4[Step 4: Disclaimer & Confirm]
|
||||
end
|
||||
|
||||
W4 -->|POST /sessions| API["Agent API"]
|
||||
API -->|Encrypt credentials| CRYPTO["AES-256-GCM<br/>Credentials at Rest"]
|
||||
API -->|Acquire semaphore| SEM["Concurrency Limiter<br/>(max 5 sessions)"]
|
||||
SEM --> SPAWN["Spawn Orchestrator Task"]
|
||||
|
||||
SPAWN --> GATHER["Gather Repo Context"]
|
||||
|
||||
subgraph Context["Context Gathering (DB Queries)"]
|
||||
GATHER --> SAST["SAST Findings<br/>(open/triaged, top 100)"]
|
||||
GATHER --> SBOM["SBOM Entries<br/>(with known CVEs)"]
|
||||
GATHER --> GRAPH["Code Knowledge Graph<br/>(entry points → source files)"]
|
||||
end
|
||||
|
||||
SAST & SBOM & GRAPH --> PROMPT["Build System Prompt"]
|
||||
|
||||
subgraph LLMLoop["LLM Orchestration Loop (max 50 iterations)"]
|
||||
PROMPT --> PAUSE{"Paused?"}
|
||||
PAUSE -->|Yes| WAIT["Wait for resume signal"]
|
||||
WAIT --> PAUSE
|
||||
PAUSE -->|No| LLM["LLM Call<br/>(LiteLLM → Claude/GPT)"]
|
||||
LLM -->|Content response| DONE["Session Complete"]
|
||||
LLM -->|Tool calls| EXEC["Execute Tools"]
|
||||
EXEC --> STORE["Store attack chain nodes<br/>+ findings in MongoDB"]
|
||||
STORE --> SSE["Broadcast SSE events"]
|
||||
SSE --> LLM
|
||||
end
|
||||
|
||||
STORE --> REPORT["Report Generation"]
|
||||
|
||||
subgraph Report["Report Export"]
|
||||
REPORT --> HTML["HTML Report Builder"]
|
||||
HTML --> CORRELATE["Code-Level Correlation"]
|
||||
CORRELATE --> PDF["Chrome CDP → PDF"]
|
||||
PDF --> ZIP["AES-256 ZIP Archive"]
|
||||
end
|
||||
|
||||
style LLMLoop fill:#1e293b,stroke:#3b82f6,color:#f8fafc
|
||||
style Context fill:#0f172a,stroke:#16a34a,color:#f8fafc
|
||||
style Report fill:#0f172a,stroke:#d97706,color:#f8fafc
|
||||
```
|
||||
|
||||
## What the LLM Sees
|
||||
|
||||
The orchestrator constructs a system prompt containing all available context. Here is exactly what is passed to the LLM at the start of each session:
|
||||
|
||||
### System Prompt Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ SYSTEM PROMPT │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ## Target │
|
||||
│ Name, URL, type, rate limit, destructive flag, repo ID │
|
||||
│ │
|
||||
│ ## Strategy │
|
||||
│ Guidance text based on selected strategy │
|
||||
│ │
|
||||
│ ## SAST Findings (Static Analysis) │
|
||||
│ Up to 20 findings with severity, file:line, CWE │
|
||||
│ ← From linked repository's SAST scan │
|
||||
│ │
|
||||
│ ## Vulnerable Dependencies (SBOM) │
|
||||
│ Up to 15 entries with package, version, CVE IDs │
|
||||
│ ← From linked repository's SBOM scan │
|
||||
│ │
|
||||
│ ## Code Entry Points (Knowledge Graph) │
|
||||
│ Up to 20 entry points with endpoint → file mapping │
|
||||
│ Each linked to SAST findings in the same file │
|
||||
│ ← From code knowledge graph build │
|
||||
│ │
|
||||
│ ## Authentication (if configured) │
|
||||
│ Mode, credentials (decrypted), registration URL │
|
||||
│ Verification email for plus-addressing │
|
||||
│ │
|
||||
│ ## Custom HTTP Headers │
|
||||
│ Key-value pairs to include in all requests │
|
||||
│ │
|
||||
│ ## Scope Exclusions │
|
||||
│ Paths the LLM must not test │
|
||||
│ │
|
||||
│ ## Available Tools │
|
||||
│ List of all registered tool names │
|
||||
│ │
|
||||
│ ## Instructions │
|
||||
│ Step-by-step testing methodology │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Per-Iteration Messages
|
||||
|
||||
After the system prompt, each LLM call includes the full conversation history:
|
||||
|
||||
| Role | Content |
|
||||
|------|---------|
|
||||
| `system` | System prompt (above) |
|
||||
| `user` | Initial instructions or user message |
|
||||
| `assistant` | LLM reasoning + tool call requests |
|
||||
| `tool` | Tool execution results (one per tool call) |
|
||||
| `assistant` | Next reasoning + tool calls |
|
||||
| ... | Continues until LLM says "testing complete" or max 50 iterations |
|
||||
|
||||
## Tool Registry
|
||||
|
||||
The LLM can invoke any of these tools. Each tool is registered with a JSON Schema that the LLM uses for structured tool calling:
|
||||
|
||||
| Tool | Category | What it does |
|
||||
|------|----------|-------------|
|
||||
| `recon` | Recon | HTTP fingerprinting, technology detection |
|
||||
| `openapi_parser` | API | Discover endpoints from OpenAPI/Swagger specs |
|
||||
| `security_headers` | Headers | Check for missing security headers |
|
||||
| `cookie_analyzer` | Cookies | Analyze cookie flags (Secure, HttpOnly, SameSite) |
|
||||
| `csp_analyzer` | CSP | Evaluate Content-Security-Policy directives |
|
||||
| `cors_checker` | CORS | Test CORS misconfiguration |
|
||||
| `tls_analyzer` | TLS | Inspect TLS certificate and cipher suites |
|
||||
| `dns_checker` | DNS | DNS record enumeration |
|
||||
| `dmarc_checker` | Email | DMARC/SPF/DKIM verification |
|
||||
| `rate_limit_tester` | Rate Limit | Test rate limiting on endpoints |
|
||||
| `console_log_detector` | Logs | Find console.log leakage in JavaScript |
|
||||
| `sql_injection` | SQLi | SQL injection testing with payloads |
|
||||
| `xss` | XSS | Cross-site scripting testing |
|
||||
| `ssrf` | SSRF | Server-side request forgery testing |
|
||||
| `auth_bypass` | Auth | Authentication bypass testing |
|
||||
| `api_fuzzer` | Fuzzer | API endpoint fuzzing |
|
||||
| `browser` | Browser | Headless Chrome automation (navigate, click, fill, screenshot, evaluate JS) |
|
||||
|
||||
### Browser Tool
|
||||
|
||||
The `browser` tool gives the LLM full control of a headless Chrome instance via CDP (Chrome DevTools Protocol). It supports:
|
||||
|
||||
- **navigate** — Go to a URL, return title
|
||||
- **screenshot** — Capture PNG screenshot (base64)
|
||||
- **click** — Click a CSS-selected element
|
||||
- **fill** — Fill a form field with a value
|
||||
- **get_content** — Read full page HTML
|
||||
- **evaluate** — Execute arbitrary JavaScript
|
||||
|
||||
This is used for registration page discovery, form filling, and visual inspection.
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Running : POST /sessions
|
||||
Running --> Paused : POST /sessions/{id}/pause
|
||||
Paused --> Running : POST /sessions/{id}/resume
|
||||
Running --> Completed : LLM says "testing complete"
|
||||
Running --> Failed : Error or timeout
|
||||
Paused --> Failed : POST /sessions/{id}/stop
|
||||
Running --> Failed : POST /sessions/{id}/stop
|
||||
Completed --> [*]
|
||||
Failed --> [*]
|
||||
```
|
||||
|
||||
## SSE Streaming
|
||||
|
||||
Each session has a dedicated broadcast channel. The `/sessions/{id}/stream` endpoint:
|
||||
|
||||
1. **Replays** stored messages and attack chain nodes as an initial burst
|
||||
2. **Subscribes** to the live broadcast for real-time events
|
||||
3. **Keepalive** comments every 15 seconds
|
||||
|
||||
Event types:
|
||||
|
||||
| Event | When |
|
||||
|-------|------|
|
||||
| `tool_start` | LLM requests a tool execution |
|
||||
| `tool_complete` | Tool finishes with summary + finding count |
|
||||
| `finding` | New vulnerability discovered |
|
||||
| `message` | LLM sends a text message |
|
||||
| `paused` | Session paused |
|
||||
| `resumed` | Session resumed |
|
||||
| `complete` | Session finished |
|
||||
| `error` | Session failed |
|
||||
|
||||
## Code-Level Correlation in Reports
|
||||
|
||||
When a DAST finding is linked to source code, the report includes a **Code-Level Remediation** section showing exactly what to fix:
|
||||
|
||||
### Correlation Channels
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
DAST["DAST Finding"]
|
||||
|
||||
DAST -->|linked_sast_finding_id| SAST["SAST Finding"]
|
||||
SAST --> CODE["file:line + code snippet<br/>+ suggested fix"]
|
||||
|
||||
DAST -->|endpoint match| GRAPH["Code Knowledge Graph"]
|
||||
GRAPH --> ENTRY["Handler function + file<br/>+ known vulns in file"]
|
||||
|
||||
DAST -->|linked CVE| SBOM["SBOM Entry"]
|
||||
SBOM --> DEP["Package + version<br/>+ upgrade recommendation"]
|
||||
|
||||
style CODE fill:#dc2626,color:#fff
|
||||
style ENTRY fill:#3b82f6,color:#fff
|
||||
style DEP fill:#d97706,color:#fff
|
||||
```
|
||||
|
||||
| Channel | Priority | What it shows |
|
||||
|---------|----------|---------------|
|
||||
| **SAST Correlation** | 1 (direct link) | Exact file:line, vulnerable code snippet (red), suggested fix (green), scanner rule, CWE |
|
||||
| **Code Entry Point** | 2 (endpoint match) | Handler function, source file, all SAST issues in that file |
|
||||
| **Vulnerable Dependency** | 3 (CVE match) | Package name + version, CVE IDs, PURL, upgrade guidance |
|
||||
|
||||
### Example Report Finding
|
||||
|
||||
A finding like "Reflected XSS in /api/search" would show:
|
||||
|
||||
1. The DAST evidence (request, response, payload)
|
||||
2. **SAST Correlation**: `src/routes/search.rs:42` — semgrep found unescaped user input
|
||||
3. **Code snippet**: The vulnerable line highlighted in red
|
||||
4. **Suggested fix**: The patched code in green
|
||||
5. **Recommendation**: Framework-specific guidance
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Pentest Dashboard
|
||||
|
||||

|
||||
|
||||
The dashboard shows aggregate statistics, severity distribution, and recent sessions with status badges. Running sessions can be paused, resumed, or stopped.
|
||||
|
||||
### Onboarding Wizard
|
||||
|
||||
**Step 1 — Target & Scope** (with dropdown showing existing DAST targets):
|
||||
|
||||

|
||||
|
||||
**Step 2 — Authentication** (Auto-Register mode with optional registration URL, verification email, IMAP settings):
|
||||
|
||||

|
||||
|
||||
**Step 3 — Strategy & Instructions** (strategy selection, scope exclusions, duration, tester info):
|
||||
|
||||

|
||||
|
||||
**Step 4 — Review & Confirm** (summary + authorization disclaimer):
|
||||
|
||||

|
||||
|
||||
### Session — Findings
|
||||
|
||||

|
||||
|
||||
Each finding shows severity, CWE, endpoint, description, and remediation. Exploitable findings are flagged. SAST correlations are shown when available.
|
||||
|
||||
### Session — Attack Chain
|
||||
|
||||

|
||||
|
||||
The attack chain visualizes the DAG of tool executions grouped into phases (Reconnaissance, Analysis, Boundary Testing, Exploitation). Each node shows tool name, category, duration, findings count, and risk score. Running nodes pulse with an animation.
|
||||
|
||||
## Concurrency & Security
|
||||
|
||||
- **Max 5 concurrent sessions** via `tokio::Semaphore` — returns HTTP 429 when exhausted
|
||||
- **Credentials encrypted at rest** with AES-256-GCM (key from `PENTEST_ENCRYPTION_KEY` env var)
|
||||
- **Credentials redacted** in all API responses (replaced with `********`)
|
||||
- **Credentials decrypted only** when building the LLM prompt (in-memory, never logged)
|
||||
- **Report archives** are AES-256 encrypted ZIPs with SHA-256 integrity checksums
|
||||
@@ -15,17 +15,47 @@ The dashboard shows:
|
||||
|
||||
## 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**:
|
||||
Click **New Pentest** on the dashboard to open the 4-step onboarding wizard:
|
||||
|
||||
### Step 1 — Target & Scope
|
||||
|
||||
- **App URL** — enter manually or select from existing DAST targets (dropdown)
|
||||
- **Git Repository URL** — enter manually or select from tracked repositories (dropdown). If an SSH URL is selected, the deploy key is displayed for easy copy
|
||||
- **Branch / Commit** — auto-populated when you click **Lookup** for a tracked repo
|
||||
- **App Type** and **Rate Limit**
|
||||
|
||||
### Step 2 — Authentication
|
||||
|
||||
- **None** — unauthenticated testing
|
||||
- **Manual Credentials** — provide username/password (encrypted at rest with AES-256-GCM)
|
||||
- **Auto-Register** — the orchestrator uses the browser tool (headless Chrome) to discover the registration page and create a test account:
|
||||
- **Registration URL** (optional) — auto-discovered via Playwright if omitted
|
||||
- **Verification Email** (optional) — override the agent's default mailbox. Uses plus-addressing (`base+sessionid@domain`) and polls IMAP for verification links
|
||||
- **IMAP Settings** — collapsible section to override host/port/credentials
|
||||
|
||||
### Step 3 — Strategy & Instructions
|
||||
|
||||
| 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 |
|
||||
| **Quick** | Focus on common/high-impact vulnerabilities with minimal tool invocations |
|
||||
| **Targeted** | SAST-guided — prioritize areas where static analysis found issues |
|
||||
| **Aggressive** | Maximum payloads, attempt full exploitation |
|
||||
| **Stealth** | Minimal noise, passive analysis, targeted probes |
|
||||
|
||||
4. Optionally provide an initial **message** to guide the AI's focus
|
||||
5. Click **Start** to begin the session
|
||||
- **Initial Instructions** — free-text guidance for the AI
|
||||
- **Scope Exclusions** — paths to skip
|
||||
- **Max Duration**, **Tester Name/Email**, **Destructive Tests** toggle
|
||||
|
||||
### Step 4 — Disclaimer & Confirm
|
||||
|
||||
Review the configuration summary and accept the authorization disclaimer.
|
||||
|
||||
The wizard can be closed at any time via the **X** button (top-right corner) or by clicking outside the modal.
|
||||
|
||||
::: tip Architecture Deep-Dive
|
||||
See [Pentest Orchestration Architecture](./pentest-architecture.md) for details on how the LLM loop works, what context is passed, and how findings are correlated to source code.
|
||||
:::
|
||||
|
||||
The AI orchestrator will autonomously select and execute security tools in phases, using the output of each phase to inform the next.
|
||||
|
||||
@@ -65,9 +95,11 @@ A visual DAG (directed acyclic graph) showing the sequence of tools executed dur
|
||||
- **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
|
||||
### Pausing, Resuming & Stopping
|
||||
|
||||
Running sessions can be stopped from the dashboard by clicking the **Stop** button on the session card. This immediately halts all tool execution.
|
||||
- **Pause** — click the **Pause** button on a running session to suspend the orchestrator loop. The session status changes to `paused` and the LLM stops iterating. SSE clients receive a `paused` event.
|
||||
- **Resume** — click **Resume** on a paused session to continue from where it left off. The status returns to `running` and a `resumed` event is broadcast.
|
||||
- **Stop** — click **Stop** to permanently halt the session. This marks it as `failed` with reason "Stopped by user".
|
||||
|
||||
## Exporting Reports
|
||||
|
||||
|
||||
BIN
docs/public/screenshots/pentest-attack-chain.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
docs/public/screenshots/pentest-dashboard.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
docs/public/screenshots/pentest-session-findings.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
docs/public/screenshots/pentest-wizard-step1-dropdown.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/public/screenshots/pentest-wizard-step1.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/public/screenshots/pentest-wizard-step2-auth.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
docs/public/screenshots/pentest-wizard-step3-strategy.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/public/screenshots/pentest-wizard-step4-confirm.png
Normal file
|
After Width: | Height: | Size: 105 KiB |