use mongodb::bson::doc; use rmcp::{model::*, ErrorData as McpError}; use schemars::JsonSchema; use serde::Deserialize; use crate::database::Database; const MAX_LIMIT: i64 = 200; const DEFAULT_LIMIT: i64 = 50; fn cap_limit(limit: Option) -> i64 { limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) } #[cfg(test)] mod tests { use super::*; #[test] fn cap_limit_default() { assert_eq!(cap_limit(None), DEFAULT_LIMIT); } #[test] fn cap_limit_clamps_high() { assert_eq!(cap_limit(Some(1000)), MAX_LIMIT); } #[test] fn cap_limit_clamps_low() { assert_eq!(cap_limit(Some(-100)), 1); assert_eq!(cap_limit(Some(0)), 1); } #[test] fn cap_limit_normal() { assert_eq!(cap_limit(Some(42)), 42); } #[test] fn list_pentest_sessions_params_deserialize() { let json = serde_json::json!({ "target_id": "tgt", "status": "running", "strategy": "aggressive", "limit": 5 }); let params: ListPentestSessionsParams = serde_json::from_value(json).unwrap(); assert_eq!(params.target_id.as_deref(), Some("tgt")); assert_eq!(params.status.as_deref(), Some("running")); assert_eq!(params.strategy.as_deref(), Some("aggressive")); assert_eq!(params.limit, Some(5)); } #[test] fn list_pentest_sessions_params_all_optional() { let params: ListPentestSessionsParams = serde_json::from_value(serde_json::json!({})).unwrap(); assert!(params.target_id.is_none()); assert!(params.status.is_none()); assert!(params.strategy.is_none()); assert!(params.limit.is_none()); } #[test] fn get_pentest_session_params_deserialize() { let params: GetPentestSessionParams = serde_json::from_value(serde_json::json!({ "id": "abc123" })).unwrap(); assert_eq!(params.id, "abc123"); } #[test] fn get_attack_chain_params_deserialize() { let params: GetAttackChainParams = serde_json::from_value(serde_json::json!({ "session_id": "s1", "limit": 20 })).unwrap(); assert_eq!(params.session_id, "s1"); assert_eq!(params.limit, Some(20)); } #[test] fn get_pentest_messages_params_deserialize() { let params: GetPentestMessagesParams = serde_json::from_value(serde_json::json!({ "session_id": "s2" })).unwrap(); assert_eq!(params.session_id, "s2"); assert!(params.limit.is_none()); } #[test] fn pentest_stats_params_deserialize() { let params: PentestStatsParams = serde_json::from_value(serde_json::json!({ "target_id": "t1" })).unwrap(); assert_eq!(params.target_id.as_deref(), Some("t1")); let params2: PentestStatsParams = serde_json::from_value(serde_json::json!({})).unwrap(); assert!(params2.target_id.is_none()); } } // ── List Pentest Sessions ────────────────────────────────────── #[derive(Debug, Deserialize, JsonSchema)] pub struct ListPentestSessionsParams { /// Filter by target ID pub target_id: Option, /// Filter by status: running, paused, completed, failed pub status: Option, /// Filter by strategy: quick, comprehensive, targeted, aggressive, stealth pub strategy: Option, /// Maximum number of results (default 50, max 200) pub limit: Option, } pub async fn list_pentest_sessions( db: &Database, params: ListPentestSessionsParams, ) -> Result { let mut filter = doc! {}; if let Some(ref target_id) = params.target_id { filter.insert("target_id", target_id); } if let Some(ref status) = params.status { filter.insert("status", status); } if let Some(ref strategy) = params.strategy { filter.insert("strategy", strategy); } let limit = cap_limit(params.limit); let mut cursor = db .pentest_sessions() .find(filter) .sort(doc! { "started_at": -1 }) .limit(limit) .await .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; let mut results = Vec::new(); while cursor .advance() .await .map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))? { let session = cursor .deserialize_current() .map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?; results.push(session); } let json = serde_json::to_string_pretty(&results) .map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?; Ok(CallToolResult::success(vec![Content::text(json)])) } // ── Get Pentest Session ──────────────────────────────────────── #[derive(Debug, Deserialize, JsonSchema)] pub struct GetPentestSessionParams { /// Pentest session ID (MongoDB ObjectId hex string) pub id: String, } pub async fn get_pentest_session( db: &Database, params: GetPentestSessionParams, ) -> Result { let oid = bson::oid::ObjectId::parse_str(¶ms.id) .map_err(|e| McpError::invalid_params(format!("invalid id: {e}"), None))?; let session = db .pentest_sessions() .find_one(doc! { "_id": oid }) .await .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))? .ok_or_else(|| McpError::invalid_params("session not found", None))?; let json = serde_json::to_string_pretty(&session) .map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?; Ok(CallToolResult::success(vec![Content::text(json)])) } // ── Get Attack Chain ─────────────────────────────────────────── #[derive(Debug, Deserialize, JsonSchema)] pub struct GetAttackChainParams { /// Pentest session ID to get the attack chain for pub session_id: String, /// Maximum number of nodes (default 50, max 200) pub limit: Option, } pub async fn get_attack_chain( db: &Database, params: GetAttackChainParams, ) -> Result { let limit = cap_limit(params.limit); let mut cursor = db .attack_chain_nodes() .find(doc! { "session_id": ¶ms.session_id }) .sort(doc! { "started_at": 1 }) .limit(limit) .await .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; let mut results = Vec::new(); while cursor .advance() .await .map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))? { let node = cursor .deserialize_current() .map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?; results.push(node); } let json = serde_json::to_string_pretty(&results) .map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?; Ok(CallToolResult::success(vec![Content::text(json)])) } // ── Get Pentest Messages ─────────────────────────────────────── #[derive(Debug, Deserialize, JsonSchema)] pub struct GetPentestMessagesParams { /// Pentest session ID pub session_id: String, /// Maximum number of messages (default 50, max 200) pub limit: Option, } pub async fn get_pentest_messages( db: &Database, params: GetPentestMessagesParams, ) -> Result { let limit = cap_limit(params.limit); let mut cursor = db .pentest_messages() .find(doc! { "session_id": ¶ms.session_id }) .sort(doc! { "created_at": 1 }) .limit(limit) .await .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; let mut results = Vec::new(); while cursor .advance() .await .map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))? { let msg = cursor .deserialize_current() .map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?; results.push(msg); } let json = serde_json::to_string_pretty(&results) .map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?; Ok(CallToolResult::success(vec![Content::text(json)])) } // ── Pentest Stats ────────────────────────────────────────────── #[derive(Debug, Deserialize, JsonSchema)] pub struct PentestStatsParams { /// Filter stats by target ID pub target_id: Option, } pub async fn pentest_stats( db: &Database, params: PentestStatsParams, ) -> Result { let mut base_filter = doc! {}; if let Some(ref target_id) = params.target_id { base_filter.insert("target_id", target_id); } // Count running sessions let mut running_filter = base_filter.clone(); running_filter.insert("status", "running"); let running = db .pentest_sessions() .count_documents(running_filter) .await .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; // Count total sessions let total_sessions = db .pentest_sessions() .count_documents(base_filter.clone()) .await .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; // Get findings for these sessions — query DAST findings with session_id set let mut findings_filter = doc! { "session_id": { "$ne": null } }; if let Some(ref target_id) = params.target_id { findings_filter.insert("target_id", target_id); } let total_findings = db .dast_findings() .count_documents(findings_filter.clone()) .await .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; let mut exploitable_filter = findings_filter.clone(); exploitable_filter.insert("exploitable", true); let exploitable = db .dast_findings() .count_documents(exploitable_filter) .await .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; // Severity counts let mut severity = serde_json::Map::new(); for sev in ["critical", "high", "medium", "low", "info"] { let mut sf = findings_filter.clone(); sf.insert("severity", sev); let count = db .dast_findings() .count_documents(sf) .await .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; severity.insert(sev.to_string(), serde_json::json!(count)); } let summary = serde_json::json!({ "running_sessions": running, "total_sessions": total_sessions, "total_findings": total_findings, "exploitable_findings": exploitable, "severity_distribution": severity, }); let json = serde_json::to_string_pretty(&summary) .map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?; Ok(CallToolResult::success(vec![Content::text(json)])) }