feat: findings refinement, new scanners, and deployment tooling (#6)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 4m3s
CI / Security Audit (push) Successful in 1m38s
CI / Tests (push) Successful in 4m44s
CI / Detect Changes (push) Successful in 2s
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) Failing after 2s
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 4m3s
CI / Security Audit (push) Successful in 1m38s
CI / Tests (push) Successful in 4m44s
CI / Detect Changes (push) Successful in 2s
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) Failing after 2s
This commit was merged in pull request #6.
This commit is contained in:
@@ -14,5 +14,8 @@ pub fn load_config() -> Result<DashboardConfig, DashboardError> {
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8080),
|
||||
mcp_endpoint_url: std::env::var("MCP_ENDPOINT_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,32 +10,50 @@ pub struct FindingsListResponse {
|
||||
pub page: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct FindingsQuery {
|
||||
pub page: u64,
|
||||
pub severity: String,
|
||||
pub scan_type: String,
|
||||
pub status: String,
|
||||
pub repo_id: String,
|
||||
pub q: String,
|
||||
pub sort_by: String,
|
||||
pub sort_order: String,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_findings(
|
||||
page: u64,
|
||||
severity: String,
|
||||
scan_type: String,
|
||||
status: String,
|
||||
repo_id: String,
|
||||
) -> Result<FindingsListResponse, ServerFnError> {
|
||||
pub async fn fetch_findings(query: FindingsQuery) -> Result<FindingsListResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let mut url = format!(
|
||||
"{}/api/v1/findings?page={page}&limit=20",
|
||||
state.agent_api_url
|
||||
"{}/api/v1/findings?page={}&limit=20",
|
||||
state.agent_api_url, query.page
|
||||
);
|
||||
if !severity.is_empty() {
|
||||
url.push_str(&format!("&severity={severity}"));
|
||||
if !query.severity.is_empty() {
|
||||
url.push_str(&format!("&severity={}", query.severity));
|
||||
}
|
||||
if !scan_type.is_empty() {
|
||||
url.push_str(&format!("&scan_type={scan_type}"));
|
||||
if !query.scan_type.is_empty() {
|
||||
url.push_str(&format!("&scan_type={}", query.scan_type));
|
||||
}
|
||||
if !status.is_empty() {
|
||||
url.push_str(&format!("&status={status}"));
|
||||
if !query.status.is_empty() {
|
||||
url.push_str(&format!("&status={}", query.status));
|
||||
}
|
||||
if !repo_id.is_empty() {
|
||||
url.push_str(&format!("&repo_id={repo_id}"));
|
||||
if !query.repo_id.is_empty() {
|
||||
url.push_str(&format!("&repo_id={}", query.repo_id));
|
||||
}
|
||||
if !query.q.is_empty() {
|
||||
url.push_str(&format!(
|
||||
"&q={}",
|
||||
url::form_urlencoded::byte_serialize(query.q.as_bytes()).collect::<String>()
|
||||
));
|
||||
}
|
||||
if !query.sort_by.is_empty() {
|
||||
url.push_str(&format!("&sort_by={}", query.sort_by));
|
||||
}
|
||||
if !query.sort_order.is_empty() {
|
||||
url.push_str(&format!("&sort_order={}", query.sort_order));
|
||||
}
|
||||
|
||||
let resp = reqwest::get(&url)
|
||||
@@ -82,3 +100,40 @@ pub async fn update_finding_status(id: String, status: String) -> Result<(), Ser
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn bulk_update_finding_status(
|
||||
ids: Vec<String>,
|
||||
status: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/findings/bulk-status", state.agent_api_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.patch(&url)
|
||||
.json(&serde_json::json!({ "ids": ids, "status": status }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn update_finding_feedback(id: String, feedback: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/findings/{id}/feedback", state.agent_api_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.patch(&url)
|
||||
.json(&serde_json::json!({ "feedback": feedback }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -34,19 +34,29 @@ pub async fn add_repository(
|
||||
name: String,
|
||||
git_url: String,
|
||||
default_branch: String,
|
||||
auth_token: Option<String>,
|
||||
auth_username: Option<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/repositories", state.agent_api_url);
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"name": name,
|
||||
"git_url": git_url,
|
||||
"default_branch": default_branch,
|
||||
});
|
||||
if let Some(token) = auth_token.filter(|t| !t.is_empty()) {
|
||||
body["auth_token"] = serde_json::Value::String(token);
|
||||
}
|
||||
if let Some(username) = auth_username.filter(|u| !u.is_empty()) {
|
||||
body["auth_username"] = serde_json::Value::String(username);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"name": name,
|
||||
"git_url": git_url,
|
||||
"default_branch": default_branch,
|
||||
}))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
@@ -61,6 +71,32 @@ pub async fn add_repository(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/settings/ssh-public-key", state.agent_api_url);
|
||||
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(ServerFnError::new("SSH key not available".to_string()));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(body
|
||||
.get("public_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
@@ -99,3 +135,32 @@ pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a repository has any running scans
|
||||
#[server]
|
||||
pub async fn check_repo_scanning(repo_id: String) -> Result<bool, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/scan-runs?page=1&limit=1", state.agent_api_url);
|
||||
|
||||
let resp = reqwest::get(&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()))?;
|
||||
|
||||
// Check if the most recent scan for this repo is still running
|
||||
if let Some(scans) = body.get("data").and_then(|d| d.as_array()) {
|
||||
for scan in scans {
|
||||
let scan_repo = scan.get("repo_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status = scan.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if scan_repo == repo_id && status == "running" {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ use dioxus::prelude::*;
|
||||
use time::Duration;
|
||||
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
|
||||
|
||||
use compliance_core::models::{McpServerConfig, McpServerStatus, McpTransport};
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use super::config;
|
||||
use super::database::Database;
|
||||
use super::error::DashboardError;
|
||||
@@ -22,6 +25,9 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||
KeycloakConfig::from_env().map(|kc| &*Box::leak(Box::new(kc)));
|
||||
let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||
|
||||
// Seed default MCP server configs
|
||||
seed_default_mcp_servers(&db, config.mcp_endpoint_url.as_deref()).await;
|
||||
|
||||
if let Some(kc) = keycloak {
|
||||
tracing::info!("Keycloak configured for realm '{}'", kc.realm);
|
||||
} else {
|
||||
@@ -70,3 +76,66 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Seed three default MCP server configs (Findings, SBOM, DAST) if they don't already exist.
|
||||
async fn seed_default_mcp_servers(db: &Database, mcp_endpoint_url: Option<&str>) {
|
||||
let endpoint = mcp_endpoint_url.unwrap_or("http://localhost:8090");
|
||||
|
||||
let defaults = [
|
||||
(
|
||||
"Findings MCP",
|
||||
"Exposes security findings, triage data, and finding summaries to LLM agents",
|
||||
vec!["list_findings", "get_finding", "findings_summary"],
|
||||
),
|
||||
(
|
||||
"SBOM MCP",
|
||||
"Exposes software bill of materials and vulnerability reports to LLM agents",
|
||||
vec!["list_sbom_packages", "sbom_vuln_report"],
|
||||
),
|
||||
(
|
||||
"DAST MCP",
|
||||
"Exposes DAST scan findings and scan summaries to LLM agents",
|
||||
vec!["list_dast_findings", "dast_scan_summary"],
|
||||
),
|
||||
];
|
||||
|
||||
let collection = db.mcp_servers();
|
||||
|
||||
for (name, description, tools) in defaults {
|
||||
// Skip if already exists
|
||||
let exists = collection
|
||||
.find_one(doc! { "name": name })
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let token = format!("mcp_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
let server = McpServerConfig {
|
||||
id: None,
|
||||
name: name.to_string(),
|
||||
endpoint_url: format!("{endpoint}/mcp"),
|
||||
transport: McpTransport::Http,
|
||||
port: Some(8090),
|
||||
status: McpServerStatus::Stopped,
|
||||
access_token: token,
|
||||
tools_enabled: tools.into_iter().map(|s| s.to_string()).collect(),
|
||||
description: Some(description.to_string()),
|
||||
mongodb_uri: None,
|
||||
mongodb_database: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
match collection.insert_one(server).await {
|
||||
Ok(_) => tracing::info!("Seeded default MCP server: {name}"),
|
||||
Err(e) => tracing::warn!("Failed to seed MCP server '{name}': {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user