CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m15s
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
346 lines
11 KiB
Rust
346 lines
11 KiB
Rust
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>) -> 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<String>,
|
|
/// Filter by status: running, paused, completed, failed
|
|
pub status: Option<String>,
|
|
/// Filter by strategy: quick, comprehensive, targeted, aggressive, stealth
|
|
pub strategy: Option<String>,
|
|
/// Maximum number of results (default 50, max 200)
|
|
pub limit: Option<i64>,
|
|
}
|
|
|
|
pub async fn list_pentest_sessions(
|
|
db: &Database,
|
|
params: ListPentestSessionsParams,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<CallToolResult, McpError> {
|
|
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<i64>,
|
|
}
|
|
|
|
pub async fn get_attack_chain(
|
|
db: &Database,
|
|
params: GetAttackChainParams,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<i64>,
|
|
}
|
|
|
|
pub async fn get_pentest_messages(
|
|
db: &Database,
|
|
params: GetPentestMessagesParams,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<String>,
|
|
}
|
|
|
|
pub async fn pentest_stats(
|
|
db: &Database,
|
|
params: PentestStatsParams,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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)]))
|
|
}
|