pub mod chat; pub mod dast; pub mod graph; use std::sync::Arc; #[allow(unused_imports)] use axum::extract::{Extension, Path, Query}; use axum::http::{header, StatusCode}; use axum::response::IntoResponse; use axum::Json; use mongodb::bson::doc; use serde::{Deserialize, Serialize}; use compliance_core::models::*; use crate::agent::ComplianceAgent; #[derive(Deserialize)] pub struct PaginationParams { #[serde(default = "default_page")] pub page: u64, #[serde(default = "default_limit")] pub limit: i64, } fn default_page() -> u64 { 1 } fn default_limit() -> i64 { 50 } #[derive(Deserialize)] pub struct FindingsFilter { #[serde(default)] pub repo_id: Option, #[serde(default)] pub severity: Option, #[serde(default)] pub scan_type: Option, #[serde(default)] pub status: Option, #[serde(default)] pub q: Option, #[serde(default)] pub sort_by: Option, #[serde(default)] pub sort_order: Option, #[serde(default = "default_page")] pub page: u64, #[serde(default = "default_limit")] pub limit: i64, } #[derive(Serialize)] pub struct ApiResponse { pub data: T, #[serde(skip_serializing_if = "Option::is_none")] pub total: Option, #[serde(skip_serializing_if = "Option::is_none")] pub page: Option, } #[derive(Serialize)] pub struct OverviewStats { pub total_repositories: u64, pub total_findings: u64, pub critical_findings: u64, pub high_findings: u64, pub medium_findings: u64, pub low_findings: u64, pub total_sbom_entries: u64, pub total_cve_alerts: u64, pub total_issues: u64, pub recent_scans: Vec, } #[derive(Deserialize)] pub struct AddRepositoryRequest { pub name: String, pub git_url: String, #[serde(default = "default_branch")] pub default_branch: String, pub auth_token: Option, pub auth_username: Option, pub tracker_type: Option, pub tracker_owner: Option, pub tracker_repo: Option, pub scan_schedule: Option, } fn default_branch() -> String { "main".to_string() } #[derive(Deserialize)] pub struct UpdateStatusRequest { pub status: String, } #[derive(Deserialize)] pub struct BulkUpdateStatusRequest { pub ids: Vec, pub status: String, } #[derive(Deserialize)] pub struct UpdateFeedbackRequest { pub feedback: String, } #[derive(Deserialize)] pub struct SbomFilter { #[serde(default)] pub repo_id: Option, #[serde(default)] pub package_manager: Option, #[serde(default)] pub q: Option, #[serde(default)] pub has_vulns: Option, #[serde(default)] pub license: Option, #[serde(default = "default_page")] pub page: u64, #[serde(default = "default_limit")] pub limit: i64, } #[derive(Deserialize)] pub struct SbomExportParams { pub repo_id: String, #[serde(default = "default_export_format")] pub format: String, } fn default_export_format() -> String { "cyclonedx".to_string() } #[derive(Deserialize)] pub struct SbomDiffParams { pub repo_a: String, pub repo_b: String, } #[derive(Serialize)] pub struct LicenseSummary { pub license: String, pub count: u64, pub is_copyleft: bool, pub packages: Vec, } #[derive(Serialize)] pub struct SbomDiffResult { pub only_in_a: Vec, pub only_in_b: Vec, pub version_changed: Vec, pub common_count: u64, } #[derive(Serialize)] pub struct SbomDiffEntry { pub name: String, pub version: String, pub package_manager: String, } #[derive(Serialize)] pub struct SbomVersionDiff { pub name: String, pub package_manager: String, pub version_a: String, pub version_b: String, } type AgentExt = Extension>; type ApiResult = Result>, StatusCode>; pub async fn health() -> Json { Json(serde_json::json!({ "status": "ok" })) } pub async fn stats_overview(Extension(agent): AgentExt) -> ApiResult { let db = &agent.db; let total_repositories = db .repositories() .count_documents(doc! {}) .await .unwrap_or(0); let total_findings = db.findings().count_documents(doc! {}).await.unwrap_or(0); let critical_findings = db .findings() .count_documents(doc! { "severity": "critical" }) .await .unwrap_or(0); let high_findings = db .findings() .count_documents(doc! { "severity": "high" }) .await .unwrap_or(0); let medium_findings = db .findings() .count_documents(doc! { "severity": "medium" }) .await .unwrap_or(0); let low_findings = db .findings() .count_documents(doc! { "severity": "low" }) .await .unwrap_or(0); let total_sbom_entries = db .sbom_entries() .count_documents(doc! {}) .await .unwrap_or(0); let total_cve_alerts = db.cve_alerts().count_documents(doc! {}).await.unwrap_or(0); let total_issues = db .tracker_issues() .count_documents(doc! {}) .await .unwrap_or(0); let recent_scans: Vec = match db .scan_runs() .find(doc! {}) .sort(doc! { "started_at": -1 }) .limit(10) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; Ok(Json(ApiResponse { data: OverviewStats { total_repositories, total_findings, critical_findings, high_findings, medium_findings, low_findings, total_sbom_entries, total_cve_alerts, total_issues, recent_scans, }, total: None, page: None, })) } pub async fn list_repositories( Extension(agent): AgentExt, Query(params): Query, ) -> ApiResult> { let db = &agent.db; let skip = (params.page.saturating_sub(1)) * params.limit as u64; let total = db .repositories() .count_documents(doc! {}) .await .unwrap_or(0); let repos = match db .repositories() .find(doc! {}) .skip(skip) .limit(params.limit) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; Ok(Json(ApiResponse { data: repos, total: Some(total), page: Some(params.page), })) } pub async fn add_repository( Extension(agent): AgentExt, Json(req): Json, ) -> Result>, (StatusCode, String)> { // Validate repository access before saving let creds = crate::pipeline::git::RepoCredentials { ssh_key_path: Some(agent.config.ssh_key_path.clone()), auth_token: req.auth_token.clone(), auth_username: req.auth_username.clone(), }; if let Err(e) = crate::pipeline::git::GitOps::test_access(&req.git_url, &creds) { return Err(( StatusCode::BAD_REQUEST, format!("Cannot access repository: {e}"), )); } let mut repo = TrackedRepository::new(req.name, req.git_url); repo.default_branch = req.default_branch; repo.auth_token = req.auth_token; repo.auth_username = req.auth_username; repo.tracker_type = req.tracker_type; repo.tracker_owner = req.tracker_owner; repo.tracker_repo = req.tracker_repo; repo.scan_schedule = req.scan_schedule; agent .db .repositories() .insert_one(&repo) .await .map_err(|_| { ( StatusCode::CONFLICT, "Repository already exists".to_string(), ) })?; Ok(Json(ApiResponse { data: repo, total: None, page: None, })) } pub async fn get_ssh_public_key( Extension(agent): AgentExt, ) -> Result, StatusCode> { let public_path = format!("{}.pub", agent.config.ssh_key_path); let public_key = std::fs::read_to_string(&public_path).map_err(|_| StatusCode::NOT_FOUND)?; Ok(Json(serde_json::json!({ "public_key": public_key.trim() }))) } pub async fn trigger_scan( Extension(agent): AgentExt, Path(id): Path, ) -> Result, StatusCode> { let agent_clone = (*agent).clone(); tokio::spawn(async move { if let Err(e) = agent_clone.run_scan(&id, ScanTrigger::Manual).await { tracing::error!("Manual scan failed for {id}: {e}"); } }); Ok(Json(serde_json::json!({ "status": "scan_triggered" }))) } pub async fn delete_repository( Extension(agent): AgentExt, Path(id): Path, ) -> Result, StatusCode> { let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; let db = &agent.db; // Delete the repository let result = db .repositories() .delete_one(doc! { "_id": oid }) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if result.deleted_count == 0 { return Err(StatusCode::NOT_FOUND); } // Cascade delete all related data let _ = db.findings().delete_many(doc! { "repo_id": &id }).await; let _ = db.sbom_entries().delete_many(doc! { "repo_id": &id }).await; let _ = db.scan_runs().delete_many(doc! { "repo_id": &id }).await; let _ = db.cve_alerts().delete_many(doc! { "repo_id": &id }).await; let _ = db .tracker_issues() .delete_many(doc! { "repo_id": &id }) .await; let _ = db.graph_nodes().delete_many(doc! { "repo_id": &id }).await; let _ = db.graph_edges().delete_many(doc! { "repo_id": &id }).await; let _ = db.graph_builds().delete_many(doc! { "repo_id": &id }).await; let _ = db .impact_analyses() .delete_many(doc! { "repo_id": &id }) .await; let _ = db .code_embeddings() .delete_many(doc! { "repo_id": &id }) .await; let _ = db .embedding_builds() .delete_many(doc! { "repo_id": &id }) .await; Ok(Json(serde_json::json!({ "status": "deleted" }))) } pub async fn list_findings( Extension(agent): AgentExt, Query(filter): Query, ) -> ApiResult> { let db = &agent.db; let mut query = doc! {}; if let Some(repo_id) = &filter.repo_id { query.insert("repo_id", repo_id); } if let Some(severity) = &filter.severity { query.insert("severity", severity); } if let Some(scan_type) = &filter.scan_type { query.insert("scan_type", scan_type); } if let Some(status) = &filter.status { query.insert("status", status); } // Text search across title, description, file_path, rule_id if let Some(q) = &filter.q { if !q.is_empty() { let regex = doc! { "$regex": q, "$options": "i" }; query.insert( "$or", mongodb::bson::bson!([ { "title": regex.clone() }, { "description": regex.clone() }, { "file_path": regex.clone() }, { "rule_id": regex }, ]), ); } } // Dynamic sort let sort_field = filter.sort_by.as_deref().unwrap_or("created_at"); let sort_dir: i32 = match filter.sort_order.as_deref() { Some("asc") => 1, _ => -1, }; let sort_doc = doc! { sort_field: sort_dir }; let skip = (filter.page.saturating_sub(1)) * filter.limit as u64; let total = db .findings() .count_documents(query.clone()) .await .unwrap_or(0); let findings = match db .findings() .find(query) .sort(sort_doc) .skip(skip) .limit(filter.limit) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; Ok(Json(ApiResponse { data: findings, total: Some(total), page: Some(filter.page), })) } pub async fn get_finding( Extension(agent): AgentExt, Path(id): Path, ) -> Result>, StatusCode> { let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; let finding = agent .db .findings() .find_one(doc! { "_id": oid }) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; Ok(Json(ApiResponse { data: finding, total: None, page: None, })) } pub async fn update_finding_status( Extension(agent): AgentExt, Path(id): Path, Json(req): Json, ) -> Result, StatusCode> { let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; agent .db .findings() .update_one( doc! { "_id": oid }, doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } }, ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(serde_json::json!({ "status": "updated" }))) } pub async fn bulk_update_finding_status( Extension(agent): AgentExt, Json(req): Json, ) -> Result, StatusCode> { let oids: Vec = req .ids .iter() .filter_map(|id| mongodb::bson::oid::ObjectId::parse_str(id).ok()) .collect(); if oids.is_empty() { return Err(StatusCode::BAD_REQUEST); } let result = agent .db .findings() .update_many( doc! { "_id": { "$in": oids } }, doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } }, ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json( serde_json::json!({ "status": "updated", "modified_count": result.modified_count }), )) } pub async fn update_finding_feedback( Extension(agent): AgentExt, Path(id): Path, Json(req): Json, ) -> Result, StatusCode> { let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; agent .db .findings() .update_one( doc! { "_id": oid }, doc! { "$set": { "developer_feedback": &req.feedback, "updated_at": mongodb::bson::DateTime::now() } }, ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(serde_json::json!({ "status": "updated" }))) } pub async fn list_sbom( Extension(agent): AgentExt, Query(filter): Query, ) -> ApiResult> { let db = &agent.db; let mut query = doc! {}; if let Some(repo_id) = &filter.repo_id { query.insert("repo_id", repo_id); } if let Some(pm) = &filter.package_manager { query.insert("package_manager", pm); } if let Some(q) = &filter.q { if !q.is_empty() { query.insert("name", doc! { "$regex": q, "$options": "i" }); } } if let Some(has_vulns) = filter.has_vulns { if has_vulns { query.insert("known_vulnerabilities", doc! { "$exists": true, "$ne": [] }); } else { query.insert("known_vulnerabilities", doc! { "$size": 0 }); } } if let Some(license) = &filter.license { query.insert("license", license); } let skip = (filter.page.saturating_sub(1)) * filter.limit as u64; let total = db .sbom_entries() .count_documents(query.clone()) .await .unwrap_or(0); let entries = match db .sbom_entries() .find(query) .sort(doc! { "name": 1 }) .skip(skip) .limit(filter.limit) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; Ok(Json(ApiResponse { data: entries, total: Some(total), page: Some(filter.page), })) } pub async fn export_sbom( Extension(agent): AgentExt, Query(params): Query, ) -> Result { let db = &agent.db; let entries: Vec = match db .sbom_entries() .find(doc! { "repo_id": ¶ms.repo_id }) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; let body = if params.format == "spdx" { // SPDX 2.3 format let packages: Vec = entries .iter() .enumerate() .map(|(i, e)| { serde_json::json!({ "SPDXID": format!("SPDXRef-Package-{i}"), "name": e.name, "versionInfo": e.version, "downloadLocation": "NOASSERTION", "licenseConcluded": e.license.as_deref().unwrap_or("NOASSERTION"), "externalRefs": e.purl.as_ref().map(|p| vec![serde_json::json!({ "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": p, })]).unwrap_or_default(), }) }) .collect(); serde_json::json!({ "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", "name": format!("sbom-{}", params.repo_id), "documentNamespace": format!("https://compliance-scanner/sbom/{}", params.repo_id), "packages": packages, }) } else { // CycloneDX 1.5 format let components: Vec = entries .iter() .map(|e| { let mut comp = serde_json::json!({ "type": "library", "name": e.name, "version": e.version, "group": e.package_manager, }); if let Some(purl) = &e.purl { comp["purl"] = serde_json::Value::String(purl.clone()); } if let Some(license) = &e.license { comp["licenses"] = serde_json::json!([{ "license": { "id": license } }]); } if !e.known_vulnerabilities.is_empty() { comp["vulnerabilities"] = serde_json::json!( e.known_vulnerabilities.iter().map(|v| serde_json::json!({ "id": v.id, "source": { "name": v.source }, "ratings": v.severity.as_ref().map(|s| vec![serde_json::json!({"severity": s})]).unwrap_or_default(), })).collect::>() ); } comp }) .collect(); serde_json::json!({ "bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1, "metadata": { "component": { "type": "application", "name": format!("repo-{}", params.repo_id), } }, "components": components, }) }; let json_str = serde_json::to_string_pretty(&body).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let filename = if params.format == "spdx" { format!("sbom-{}-spdx.json", params.repo_id) } else { format!("sbom-{}-cyclonedx.json", params.repo_id) }; let disposition = format!("attachment; filename=\"{filename}\""); Ok(( [ ( header::CONTENT_TYPE, header::HeaderValue::from_static("application/json"), ), ( header::CONTENT_DISPOSITION, header::HeaderValue::from_str(&disposition) .unwrap_or_else(|_| header::HeaderValue::from_static("attachment")), ), ], json_str, )) } const COPYLEFT_LICENSES: &[&str] = &[ "GPL-2.0", "GPL-2.0-only", "GPL-2.0-or-later", "GPL-3.0", "GPL-3.0-only", "GPL-3.0-or-later", "AGPL-3.0", "AGPL-3.0-only", "AGPL-3.0-or-later", "LGPL-2.1", "LGPL-2.1-only", "LGPL-2.1-or-later", "LGPL-3.0", "LGPL-3.0-only", "LGPL-3.0-or-later", "MPL-2.0", ]; pub async fn license_summary( Extension(agent): AgentExt, Query(params): Query, ) -> ApiResult> { let db = &agent.db; let mut query = doc! {}; if let Some(repo_id) = ¶ms.repo_id { query.insert("repo_id", repo_id); } let entries: Vec = match db.sbom_entries().find(query).await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; let mut license_map: std::collections::HashMap> = std::collections::HashMap::new(); for entry in &entries { let lic = entry.license.as_deref().unwrap_or("Unknown").to_string(); license_map.entry(lic).or_default().push(entry.name.clone()); } let mut summaries: Vec = license_map .into_iter() .map(|(license, packages)| { let is_copyleft = COPYLEFT_LICENSES .iter() .any(|c| license.to_uppercase().contains(&c.to_uppercase())); LicenseSummary { license, count: packages.len() as u64, is_copyleft, packages, } }) .collect(); summaries.sort_by(|a, b| b.count.cmp(&a.count)); Ok(Json(ApiResponse { data: summaries, total: None, page: None, })) } pub async fn sbom_diff( Extension(agent): AgentExt, Query(params): Query, ) -> ApiResult { let db = &agent.db; let entries_a: Vec = match db .sbom_entries() .find(doc! { "repo_id": ¶ms.repo_a }) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; let entries_b: Vec = match db .sbom_entries() .find(doc! { "repo_id": ¶ms.repo_b }) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; // Build maps by (name, package_manager) -> version let map_a: std::collections::HashMap<(String, String), String> = entries_a .iter() .map(|e| { ( (e.name.clone(), e.package_manager.clone()), e.version.clone(), ) }) .collect(); let map_b: std::collections::HashMap<(String, String), String> = entries_b .iter() .map(|e| { ( (e.name.clone(), e.package_manager.clone()), e.version.clone(), ) }) .collect(); let mut only_in_a = Vec::new(); let mut version_changed = Vec::new(); let mut common_count: u64 = 0; for (key, ver_a) in &map_a { match map_b.get(key) { None => only_in_a.push(SbomDiffEntry { name: key.0.clone(), version: ver_a.clone(), package_manager: key.1.clone(), }), Some(ver_b) if ver_a != ver_b => { version_changed.push(SbomVersionDiff { name: key.0.clone(), package_manager: key.1.clone(), version_a: ver_a.clone(), version_b: ver_b.clone(), }); } Some(_) => common_count += 1, } } let only_in_b: Vec = map_b .iter() .filter(|(key, _)| !map_a.contains_key(key)) .map(|(key, ver)| SbomDiffEntry { name: key.0.clone(), version: ver.clone(), package_manager: key.1.clone(), }) .collect(); Ok(Json(ApiResponse { data: SbomDiffResult { only_in_a, only_in_b, version_changed, common_count, }, total: None, page: None, })) } pub async fn list_issues( Extension(agent): AgentExt, Query(params): Query, ) -> ApiResult> { let db = &agent.db; let skip = (params.page.saturating_sub(1)) * params.limit as u64; let total = db .tracker_issues() .count_documents(doc! {}) .await .unwrap_or(0); let issues = match db .tracker_issues() .find(doc! {}) .sort(doc! { "created_at": -1 }) .skip(skip) .limit(params.limit) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; Ok(Json(ApiResponse { data: issues, total: Some(total), page: Some(params.page), })) } pub async fn list_scan_runs( Extension(agent): AgentExt, Query(params): Query, ) -> ApiResult> { let db = &agent.db; let skip = (params.page.saturating_sub(1)) * params.limit as u64; let total = db.scan_runs().count_documents(doc! {}).await.unwrap_or(0); let scans = match db .scan_runs() .find(doc! {}) .sort(doc! { "started_at": -1 }) .skip(skip) .limit(params.limit) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; Ok(Json(ApiResponse { data: scans, total: Some(total), page: Some(params.page), })) } async fn collect_cursor_async( mut cursor: mongodb::Cursor, ) -> Vec { use futures_util::StreamExt; let mut items = Vec::new(); while let Some(result) = cursor.next().await { match result { Ok(item) => items.push(item), Err(e) => tracing::warn!("Failed to deserialize document: {e}"), } } items }