From abd6f65d5578966055271b46c06e6893d779f730 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Sun, 8 Mar 2026 21:30:59 +0100 Subject: [PATCH] feat: add MCP server for exposing compliance data to LLMs New `compliance-mcp` crate providing a Model Context Protocol server with 7 tools: list/get/summarize findings, list SBOM packages, SBOM vulnerability report, list DAST findings, and DAST scan summary. Supports stdio (local dev) and Streamable HTTP (deployment via MCP_PORT). Includes Dockerfile, CI clippy check, and Coolify deploy job. Co-Authored-By: Claude Opus 4.6 --- .gitea/workflows/ci.yml | 24 +++ Cargo.lock | 235 ++++++++++++++++++++++----- Cargo.toml | 1 + Dockerfile.mcp | 16 ++ compliance-mcp/Cargo.toml | 21 +++ compliance-mcp/src/database.rs | 34 ++++ compliance-mcp/src/main.rs | 58 +++++++ compliance-mcp/src/server.rs | 109 +++++++++++++ compliance-mcp/src/tools/dast.rs | 154 ++++++++++++++++++ compliance-mcp/src/tools/findings.rs | 163 +++++++++++++++++++ compliance-mcp/src/tools/mod.rs | 3 + compliance-mcp/src/tools/sbom.rs | 129 +++++++++++++++ 12 files changed, 907 insertions(+), 40 deletions(-) create mode 100644 Dockerfile.mcp create mode 100644 compliance-mcp/Cargo.toml create mode 100644 compliance-mcp/src/database.rs create mode 100644 compliance-mcp/src/main.rs create mode 100644 compliance-mcp/src/server.rs create mode 100644 compliance-mcp/src/tools/dast.rs create mode 100644 compliance-mcp/src/tools/findings.rs create mode 100644 compliance-mcp/src/tools/mod.rs create mode 100644 compliance-mcp/src/tools/sbom.rs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d36811f..c7de790 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -70,6 +70,8 @@ jobs: run: cargo clippy -p compliance-dashboard --features server --no-default-features -- -D warnings - name: Clippy (dashboard web) run: cargo clippy -p compliance-dashboard --features web --no-default-features -- -D warnings + - name: Clippy (mcp) + run: cargo clippy -p compliance-mcp -- -D warnings - name: Show sccache stats run: sccache --show-stats if: always() @@ -140,6 +142,7 @@ jobs: agent: ${{ steps.changes.outputs.agent }} dashboard: ${{ steps.changes.outputs.dashboard }} docs: ${{ steps.changes.outputs.docs }} + mcp: ${{ steps.changes.outputs.mcp }} steps: - name: Install git run: apk add --no-cache git @@ -177,6 +180,13 @@ jobs: echo "docs=false" >> "$GITHUB_OUTPUT" fi + # MCP: core libs, mcp code, mcp Dockerfile + if echo "$CHANGED" | grep -qE '^(compliance-core/|compliance-mcp/|Dockerfile\.mcp|Cargo\.(toml|lock))'; then + echo "mcp=true" >> "$GITHUB_OUTPUT" + else + echo "mcp=false" >> "$GITHUB_OUTPUT" + fi + deploy-agent: name: Deploy Agent runs-on: docker @@ -218,3 +228,17 @@ jobs: apk add --no-cache curl curl -sf "${{ secrets.COOLIFY_WEBHOOK_DOCS }}" \ -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" + + deploy-mcp: + name: Deploy MCP + runs-on: docker + needs: [detect-changes] + if: needs.detect-changes.outputs.mcp == 'true' + container: + image: alpine:latest + steps: + - name: Trigger Coolify deploy + run: | + apk add --no-cache curl + curl -sf "${{ secrets.COOLIFY_WEBHOOK_MCP }}" \ + -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" diff --git a/Cargo.lock b/Cargo.lock index c85a119..db5f8df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,7 +167,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-tungstenite 0.28.0", - "tower 0.5.3", + "tower", "tower-layer", "tower-service", "tracing", @@ -413,6 +413,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "charset" version = "0.1.5" @@ -675,6 +686,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "compliance-mcp" +version = "0.1.0" +dependencies = [ + "axum", + "bson", + "chrono", + "compliance-core", + "dotenvy", + "mongodb", + "rmcp", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -869,6 +901,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -972,8 +1013,18 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -990,13 +1041,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -1335,7 +1410,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.27.0", "tokio-util", - "tower 0.5.3", + "tower", "tower-http", "tower-layer", "tracing", @@ -1626,7 +1701,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.27.0", "tokio-util", - "tower 0.5.3", + "tower", "tower-http", "tracing", "tracing-futures", @@ -1827,7 +1902,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn", @@ -2104,6 +2179,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -3497,7 +3573,7 @@ dependencies = [ "serde_urlencoded", "snafu", "tokio", - "tower 0.5.3", + "tower", "tower-http", "tracing", "url", @@ -3599,8 +3675,6 @@ dependencies = [ "prost", "reqwest", "thiserror 2.0.18", - "tokio", - "tonic", "tracing", ] @@ -3668,6 +3742,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -4005,6 +4085,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4043,6 +4134,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rand_distr" version = "0.4.3" @@ -4163,6 +4260,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -4171,7 +4269,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower 0.5.3", + "tower", "tower-http", "tower-service", "url", @@ -4202,6 +4300,50 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.0", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c23c8f26cae4da838fbc3eadfaecf2d549d97c04b558e7bd90526a9c28b42a" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + [[package]] name = "rust-stemmers" version = "1.2.0" @@ -4365,12 +4507,26 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4510,6 +4666,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -4594,7 +4761,7 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn", @@ -4616,7 +4783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4627,7 +4794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4781,6 +4948,19 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5364,35 +5544,10 @@ dependencies = [ "http", "http-body", "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", "percent-encoding", "pin-project", "prost", - "tokio", "tokio-stream", - "tower 0.4.13", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand 0.8.5", - "slab", - "tokio", - "tokio-util", "tower-layer", "tower-service", "tracing", @@ -5453,7 +5608,7 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower 0.5.3", + "tower", "tower-layer", "tower-service", "tracing", diff --git a/Cargo.toml b/Cargo.toml index b2dcf1b..bc72548 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "compliance-dashboard", "compliance-graph", "compliance-dast", + "compliance-mcp", ] resolver = "2" diff --git a/Dockerfile.mcp b/Dockerfile.mcp new file mode 100644 index 0000000..e4d2b4c --- /dev/null +++ b/Dockerfile.mcp @@ -0,0 +1,16 @@ +FROM rust:1.89-bookworm AS builder + +WORKDIR /app +COPY . . +RUN cargo build --release -p compliance-mcp + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/compliance-mcp /usr/local/bin/compliance-mcp + +EXPOSE 8090 + +ENV MCP_PORT=8090 + +ENTRYPOINT ["compliance-mcp"] diff --git a/compliance-mcp/Cargo.toml b/compliance-mcp/Cargo.toml new file mode 100644 index 0000000..723a902 --- /dev/null +++ b/compliance-mcp/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "compliance-mcp" +version = "0.1.0" +edition = "2021" + +[dependencies] +compliance-core = { workspace = true, features = ["mongodb"] } +rmcp = { version = "0.16", features = ["server", "macros", "transport-io", "transport-streamable-http-server"] } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +mongodb = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenvy = "0.15" +thiserror = { workspace = true } +chrono = { workspace = true } +bson = { version = "2", features = ["chrono-0_4"] } +schemars = "1.0" +axum = "0.8" +tower-http = { version = "0.6", features = ["cors"] } diff --git a/compliance-mcp/src/database.rs b/compliance-mcp/src/database.rs new file mode 100644 index 0000000..2d4e6c9 --- /dev/null +++ b/compliance-mcp/src/database.rs @@ -0,0 +1,34 @@ +use mongodb::{Client, Collection}; + +use compliance_core::models::*; + +#[derive(Clone, Debug)] +pub struct Database { + inner: mongodb::Database, +} + +impl Database { + pub async fn connect(uri: &str, db_name: &str) -> Result { + let client = Client::with_uri_str(uri).await?; + let db = client.database(db_name); + db.run_command(mongodb::bson::doc! { "ping": 1 }).await?; + tracing::info!("MCP server connected to MongoDB '{db_name}'"); + Ok(Self { inner: db }) + } + + pub fn findings(&self) -> Collection { + self.inner.collection("findings") + } + + pub fn sbom_entries(&self) -> Collection { + self.inner.collection("sbom_entries") + } + + pub fn dast_findings(&self) -> Collection { + self.inner.collection("dast_findings") + } + + pub fn dast_scan_runs(&self) -> Collection { + self.inner.collection("dast_scan_runs") + } +} diff --git a/compliance-mcp/src/main.rs b/compliance-mcp/src/main.rs new file mode 100644 index 0000000..df152c7 --- /dev/null +++ b/compliance-mcp/src/main.rs @@ -0,0 +1,58 @@ +mod database; +mod server; +mod tools; + +use std::sync::Arc; + +use database::Database; +use rmcp::transport::{ + streamable_http_server::session::local::LocalSessionManager, StreamableHttpServerConfig, + StreamableHttpService, +}; +use server::ComplianceMcpServer; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("compliance_mcp=info".parse()?), + ) + .init(); + + let mongo_uri = + std::env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".to_string()); + let db_name = + std::env::var("MONGODB_DATABASE").unwrap_or_else(|_| "compliance_scanner".to_string()); + + let db = Database::connect(&mongo_uri, &db_name).await?; + + // If MCP_PORT is set, run as Streamable HTTP server; otherwise use stdio. + if let Ok(port_str) = std::env::var("MCP_PORT") { + let port: u16 = port_str.parse()?; + tracing::info!("Starting MCP server on HTTP port {port}"); + + let db_clone = db.clone(); + let service = StreamableHttpService::new( + move || Ok(ComplianceMcpServer::new(db_clone.clone())), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await?; + tracing::info!("MCP HTTP server listening on 0.0.0.0:{port}"); + axum::serve(listener, router).await?; + } else { + tracing::info!("Starting MCP server on stdio"); + let server = ComplianceMcpServer::new(db); + let transport = rmcp::transport::stdio(); + use rmcp::ServiceExt; + let handle = server.serve(transport).await?; + handle.waiting().await?; + } + + Ok(()) +} diff --git a/compliance-mcp/src/server.rs b/compliance-mcp/src/server.rs new file mode 100644 index 0000000..93ee55c --- /dev/null +++ b/compliance-mcp/src/server.rs @@ -0,0 +1,109 @@ +use rmcp::{ + handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router, ServerHandler, +}; + +use crate::database::Database; +use crate::tools::{dast, findings, sbom}; + +pub struct ComplianceMcpServer { + db: Database, + #[allow(dead_code)] + tool_router: rmcp::handler::server::router::tool::ToolRouter, +} + +#[tool_router] +impl ComplianceMcpServer { + pub fn new(db: Database) -> Self { + Self { + db, + tool_router: Self::tool_router(), + } + } + + // ── Findings ────────────────────────────────────────── + + #[tool( + description = "List security findings with optional filters for repo, severity, status, and scan type" + )] + async fn list_findings( + &self, + Parameters(params): Parameters, + ) -> Result { + findings::list_findings(&self.db, params).await + } + + #[tool(description = "Get a single finding by its ID")] + async fn get_finding( + &self, + Parameters(params): Parameters, + ) -> Result { + findings::get_finding(&self.db, params).await + } + + #[tool(description = "Get a summary of findings counts grouped by severity and status")] + async fn findings_summary( + &self, + Parameters(params): Parameters, + ) -> Result { + findings::findings_summary(&self.db, params).await + } + + // ── SBOM ────────────────────────────────────────────── + + #[tool( + description = "List SBOM packages with optional filters for repo, vulnerabilities, package manager, and license" + )] + async fn list_sbom_packages( + &self, + Parameters(params): Parameters, + ) -> Result { + sbom::list_sbom_packages(&self.db, params).await + } + + #[tool( + description = "Generate a vulnerability report for a repository showing all packages with known CVEs" + )] + async fn sbom_vuln_report( + &self, + Parameters(params): Parameters, + ) -> Result { + sbom::sbom_vuln_report(&self.db, params).await + } + + // ── DAST ────────────────────────────────────────────── + + #[tool( + description = "List DAST findings with optional filters for target, scan run, severity, exploitability, and vulnerability type" + )] + async fn list_dast_findings( + &self, + Parameters(params): Parameters, + ) -> Result { + dast::list_dast_findings(&self.db, params).await + } + + #[tool(description = "Get a summary of recent DAST scan runs and finding counts")] + async fn dast_scan_summary( + &self, + Parameters(params): Parameters, + ) -> Result { + dast::dast_scan_summary(&self.db, params).await + } +} + +#[tool_handler] +impl ServerHandler for ComplianceMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2024_11_05, + capabilities: ServerCapabilities::builder() + .enable_tools() + .build(), + server_info: Implementation::from_build_env(), + instructions: Some( + "Compliance Scanner MCP server. Query security findings, SBOM data, and DAST results." + .to_string(), + ), + } + } +} diff --git a/compliance-mcp/src/tools/dast.rs b/compliance-mcp/src/tools/dast.rs new file mode 100644 index 0000000..bc5b5b9 --- /dev/null +++ b/compliance-mcp/src/tools/dast.rs @@ -0,0 +1,154 @@ +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) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListDastFindingsParams { + /// Filter by DAST target ID + pub target_id: Option, + /// Filter by scan run ID + pub scan_run_id: Option, + /// Filter by severity: info, low, medium, high, critical + pub severity: Option, + /// Only show confirmed exploitable findings + pub exploitable: Option, + /// Filter by vulnerability type (e.g. sql_injection, xss, ssrf) + pub vuln_type: Option, + /// Maximum number of results (default 50, max 200) + pub limit: Option, +} + +pub async fn list_dast_findings( + db: &Database, + params: ListDastFindingsParams, +) -> Result { + let mut filter = doc! {}; + if let Some(ref target_id) = params.target_id { + filter.insert("target_id", target_id); + } + if let Some(ref scan_run_id) = params.scan_run_id { + filter.insert("scan_run_id", scan_run_id); + } + if let Some(ref severity) = params.severity { + filter.insert("severity", severity); + } + if let Some(exploitable) = params.exploitable { + filter.insert("exploitable", exploitable); + } + if let Some(ref vuln_type) = params.vuln_type { + filter.insert("vuln_type", vuln_type); + } + + let limit = cap_limit(params.limit); + + let mut cursor = db + .dast_findings() + .find(filter) + .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 finding = cursor + .deserialize_current() + .map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?; + results.push(finding); + } + + 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)])) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DastScanSummaryParams { + /// Filter by DAST target ID + pub target_id: Option, +} + +pub async fn dast_scan_summary( + db: &Database, + params: DastScanSummaryParams, +) -> Result { + let mut filter = doc! {}; + if let Some(ref target_id) = params.target_id { + filter.insert("target_id", target_id); + } + + // Get recent scan runs + let mut cursor = db + .dast_scan_runs() + .find(filter.clone()) + .sort(doc! { "started_at": -1 }) + .limit(10) + .await + .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; + + let mut scan_runs = Vec::new(); + while cursor + .advance() + .await + .map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))? + { + let run = cursor + .deserialize_current() + .map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?; + scan_runs.push(serde_json::json!({ + "id": run.id.map(|id| id.to_hex()), + "target_id": run.target_id, + "status": run.status, + "findings_count": run.findings_count, + "exploitable_count": run.exploitable_count, + "endpoints_discovered": run.endpoints_discovered, + "started_at": run.started_at.to_rfc3339(), + "completed_at": run.completed_at.map(|t| t.to_rfc3339()), + })); + } + + // Count findings by severity + let mut findings_filter = doc! {}; + 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_count = db + .dast_findings() + .count_documents(exploitable_filter) + .await + .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; + + let summary = serde_json::json!({ + "total_findings": total_findings, + "exploitable_findings": exploitable_count, + "recent_scan_runs": scan_runs, + }); + + 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)])) +} diff --git a/compliance-mcp/src/tools/findings.rs b/compliance-mcp/src/tools/findings.rs new file mode 100644 index 0000000..14929aa --- /dev/null +++ b/compliance-mcp/src/tools/findings.rs @@ -0,0 +1,163 @@ +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) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListFindingsParams { + /// Filter by repository ID + pub repo_id: Option, + /// Filter by severity: info, low, medium, high, critical + pub severity: Option, + /// Filter by status: open, triaged, false_positive, resolved, ignored + pub status: Option, + /// Filter by scan type: sast, sbom, cve, gdpr, oauth + pub scan_type: Option, + /// Maximum number of results (default 50, max 200) + pub limit: Option, +} + +pub async fn list_findings( + db: &Database, + params: ListFindingsParams, +) -> Result { + let mut filter = doc! {}; + if let Some(ref repo_id) = params.repo_id { + filter.insert("repo_id", repo_id); + } + if let Some(ref severity) = params.severity { + filter.insert("severity", severity); + } + if let Some(ref status) = params.status { + filter.insert("status", status); + } + if let Some(ref scan_type) = params.scan_type { + filter.insert("scan_type", scan_type); + } + + let limit = cap_limit(params.limit); + + let mut cursor = db + .findings() + .find(filter) + .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 finding = cursor + .deserialize_current() + .map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?; + results.push(finding); + } + + 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)])) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetFindingParams { + /// Finding ID (MongoDB ObjectId hex string) + pub id: String, +} + +pub async fn get_finding( + db: &Database, + params: GetFindingParams, +) -> Result { + let oid = bson::oid::ObjectId::parse_str(¶ms.id) + .map_err(|e| McpError::invalid_params(format!("invalid ObjectId: {e}"), None))?; + + let finding = db + .findings() + .find_one(doc! { "_id": oid }) + .await + .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))? + .ok_or_else(|| McpError::invalid_params("finding not found", None))?; + + let json = serde_json::to_string_pretty(&finding) + .map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?; + + Ok(CallToolResult::success(vec![Content::text(json)])) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct FindingsSummaryParams { + /// Filter by repository ID + pub repo_id: Option, +} + +#[derive(serde::Serialize)] +struct SeverityCount { + severity: String, + count: u64, +} + +pub async fn findings_summary( + db: &Database, + params: FindingsSummaryParams, +) -> Result { + let mut base_filter = doc! {}; + if let Some(ref repo_id) = params.repo_id { + base_filter.insert("repo_id", repo_id); + } + + let severities = ["critical", "high", "medium", "low", "info"]; + let mut counts = Vec::new(); + + for sev in &severities { + let mut filter = base_filter.clone(); + filter.insert("severity", sev); + let count = db + .findings() + .count_documents(filter) + .await + .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; + counts.push(SeverityCount { + severity: sev.to_string(), + count, + }); + } + + let total: u64 = counts.iter().map(|c| c.count).sum(); + + let mut status_counts = Vec::new(); + for status in &["open", "triaged", "false_positive", "resolved", "ignored"] { + let mut filter = base_filter.clone(); + filter.insert("status", status); + let count = db + .findings() + .count_documents(filter) + .await + .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; + status_counts.push(serde_json::json!({ "status": status, "count": count })); + } + + let summary = serde_json::json!({ + "total": total, + "by_severity": counts, + "by_status": status_counts, + }); + + 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)])) +} diff --git a/compliance-mcp/src/tools/mod.rs b/compliance-mcp/src/tools/mod.rs new file mode 100644 index 0000000..cf383fc --- /dev/null +++ b/compliance-mcp/src/tools/mod.rs @@ -0,0 +1,3 @@ +pub mod dast; +pub mod findings; +pub mod sbom; diff --git a/compliance-mcp/src/tools/sbom.rs b/compliance-mcp/src/tools/sbom.rs new file mode 100644 index 0000000..78c3648 --- /dev/null +++ b/compliance-mcp/src/tools/sbom.rs @@ -0,0 +1,129 @@ +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) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListSbomPackagesParams { + /// Filter by repository ID + pub repo_id: Option, + /// Only show packages with known vulnerabilities + pub has_vulns: Option, + /// Filter by package manager (e.g. npm, cargo, pip) + pub package_manager: Option, + /// Filter by license (e.g. MIT, Apache-2.0) + pub license: Option, + /// Maximum number of results (default 50, max 200) + pub limit: Option, +} + +pub async fn list_sbom_packages( + db: &Database, + params: ListSbomPackagesParams, +) -> Result { + let mut filter = doc! {}; + if let Some(ref repo_id) = params.repo_id { + filter.insert("repo_id", repo_id); + } + if let Some(ref pm) = params.package_manager { + filter.insert("package_manager", pm); + } + if let Some(ref license) = params.license { + filter.insert("license", license); + } + if params.has_vulns == Some(true) { + filter.insert("known_vulnerabilities.0", doc! { "$exists": true }); + } + + let limit = cap_limit(params.limit); + + let mut cursor = db + .sbom_entries() + .find(filter) + .sort(doc! { "name": 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 entry = cursor + .deserialize_current() + .map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?; + results.push(entry); + } + + 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)])) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SbomVulnReportParams { + /// Repository ID to generate vulnerability report for + pub repo_id: String, +} + +pub async fn sbom_vuln_report( + db: &Database, + params: SbomVulnReportParams, +) -> Result { + let filter = doc! { + "repo_id": ¶ms.repo_id, + "known_vulnerabilities.0": { "$exists": true }, + }; + + let mut cursor = db + .sbom_entries() + .find(filter) + .sort(doc! { "name": 1 }) + .await + .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; + + let mut vulnerable_packages = Vec::new(); + let mut total_vulns = 0u64; + + while cursor + .advance() + .await + .map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))? + { + let entry = cursor + .deserialize_current() + .map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?; + total_vulns += entry.known_vulnerabilities.len() as u64; + vulnerable_packages.push(serde_json::json!({ + "name": entry.name, + "version": entry.version, + "package_manager": entry.package_manager, + "license": entry.license, + "vulnerabilities": entry.known_vulnerabilities, + })); + } + + let report = serde_json::json!({ + "repo_id": params.repo_id, + "vulnerable_packages_count": vulnerable_packages.len(), + "total_vulnerabilities": total_vulns, + "packages": vulnerable_packages, + }); + + let json = serde_json::to_string_pretty(&report) + .map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?; + + Ok(CallToolResult::success(vec![Content::text(json)])) +}