From 42cabf05821df328c2ff6aedf5fb8cbf35d8b673 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Fri, 6 Mar 2026 21:54:15 +0000 Subject: [PATCH] feat: rag-embedding-ai-chat (#1) Co-authored-by: Sharang Parnerkar Reviewed-on: https://gitea.meghsakha.com/sharang/compliance-scanner-agent/pulls/1 --- Dockerfile.agent | 5 +- compliance-agent/src/agent.rs | 1 + compliance-agent/src/api/handlers/chat.rs | 238 ++++++ compliance-agent/src/api/handlers/dast.rs | 6 +- compliance-agent/src/api/handlers/graph.rs | 20 +- compliance-agent/src/api/handlers/mod.rs | 418 +++++++++- compliance-agent/src/api/routes.rs | 39 +- compliance-agent/src/config.rs | 2 + compliance-agent/src/database.rs | 35 +- compliance-agent/src/llm/client.rs | 78 +- compliance-agent/src/main.rs | 1 + compliance-agent/src/pipeline/orchestrator.rs | 30 +- compliance-agent/src/rag/mod.rs | 1 + compliance-agent/src/rag/pipeline.rs | 164 ++++ compliance-core/src/config.rs | 1 + compliance-core/src/models/chat.rs | 35 + compliance-core/src/models/dast.rs | 1 + compliance-core/src/models/embedding.rs | 100 +++ compliance-core/src/models/mod.rs | 7 +- compliance-core/src/models/repository.rs | 14 +- compliance-dashboard/assets/graph-viz.js | 37 +- compliance-dashboard/assets/main.css | 727 ++++++++++++++++++ compliance-dashboard/src/app.rs | 4 + .../src/components/file_tree.rs | 24 +- .../src/components/sidebar.rs | 12 +- compliance-dashboard/src/components/toast.rs | 16 +- .../src/infrastructure/chat.rs | 126 +++ .../src/infrastructure/dast.rs | 5 +- .../src/infrastructure/graph.rs | 5 +- .../src/infrastructure/mod.rs | 1 + .../src/infrastructure/repositories.rs | 23 + .../src/infrastructure/sbom.rs | 187 ++++- compliance-dashboard/src/pages/chat.rs | 288 +++++++ compliance-dashboard/src/pages/chat_index.rs | 70 ++ .../src/pages/dast_findings.rs | 2 +- compliance-dashboard/src/pages/findings.rs | 4 +- .../src/pages/graph_explorer.rs | 12 +- compliance-dashboard/src/pages/mod.rs | 4 + .../src/pages/repositories.rs | 56 +- compliance-dashboard/src/pages/sbom.rs | 669 ++++++++++++++-- compliance-dast/src/agents/api_fuzzer.rs | 14 +- compliance-dast/src/agents/auth_bypass.rs | 5 +- compliance-dast/src/agents/ssrf.rs | 15 +- compliance-dast/src/agents/xss.rs | 30 +- compliance-dast/src/crawler/mod.rs | 43 +- .../src/orchestrator/state_machine.rs | 25 +- compliance-dast/src/recon/mod.rs | 15 +- compliance-graph/src/graph/chunking.rs | 96 +++ compliance-graph/src/graph/community.rs | 11 +- compliance-graph/src/graph/embedding_store.rs | 236 ++++++ compliance-graph/src/graph/engine.rs | 4 +- compliance-graph/src/graph/impact.rs | 25 +- compliance-graph/src/graph/mod.rs | 2 + compliance-graph/src/graph/persistence.rs | 2 - compliance-graph/src/lib.rs | 3 + compliance-graph/src/parsers/javascript.rs | 60 +- compliance-graph/src/parsers/python.rs | 6 + compliance-graph/src/parsers/registry.rs | 18 +- compliance-graph/src/parsers/rust_parser.rs | 15 +- compliance-graph/src/parsers/typescript.rs | 76 +- compliance-graph/src/search/index.rs | 6 +- 61 files changed, 3868 insertions(+), 307 deletions(-) create mode 100644 compliance-agent/src/api/handlers/chat.rs create mode 100644 compliance-agent/src/rag/mod.rs create mode 100644 compliance-agent/src/rag/pipeline.rs create mode 100644 compliance-core/src/models/chat.rs create mode 100644 compliance-core/src/models/embedding.rs create mode 100644 compliance-dashboard/src/infrastructure/chat.rs create mode 100644 compliance-dashboard/src/pages/chat.rs create mode 100644 compliance-dashboard/src/pages/chat_index.rs create mode 100644 compliance-graph/src/graph/chunking.rs create mode 100644 compliance-graph/src/graph/embedding_store.rs diff --git a/Dockerfile.agent b/Dockerfile.agent index d8f54b6..f42e05a 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -5,7 +5,10 @@ COPY . . RUN cargo build --release -p compliance-agent FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y ca-certificates libssl3 git && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl && rm -rf /var/lib/apt/lists/* + +# Install syft for SBOM generation +RUN curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent diff --git a/compliance-agent/src/agent.rs b/compliance-agent/src/agent.rs index 1577acb..e13271d 100644 --- a/compliance-agent/src/agent.rs +++ b/compliance-agent/src/agent.rs @@ -20,6 +20,7 @@ impl ComplianceAgent { config.litellm_url.clone(), config.litellm_api_key.clone(), config.litellm_model.clone(), + config.litellm_embed_model.clone(), )); Self { config, diff --git a/compliance-agent/src/api/handlers/chat.rs b/compliance-agent/src/api/handlers/chat.rs new file mode 100644 index 0000000..aafe290 --- /dev/null +++ b/compliance-agent/src/api/handlers/chat.rs @@ -0,0 +1,238 @@ +use std::sync::Arc; + +use axum::extract::{Extension, Path}; +use axum::http::StatusCode; +use axum::Json; +use mongodb::bson::doc; + +use compliance_core::models::chat::{ChatRequest, ChatResponse, SourceReference}; +use compliance_core::models::embedding::EmbeddingBuildRun; +use compliance_graph::graph::embedding_store::EmbeddingStore; + +use crate::agent::ComplianceAgent; +use crate::rag::pipeline::RagPipeline; + +use super::ApiResponse; + +type AgentExt = Extension>; + +/// POST /api/v1/chat/:repo_id — Send a chat message with RAG context +pub async fn chat( + Extension(agent): AgentExt, + Path(repo_id): Path, + Json(req): Json, +) -> Result>, StatusCode> { + let pipeline = RagPipeline::new(agent.llm.clone(), agent.db.inner()); + + // Step 1: Embed the user's message + let query_vectors = agent + .llm + .embed(vec![req.message.clone()]) + .await + .map_err(|e| { + tracing::error!("Failed to embed query: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let query_embedding = query_vectors.into_iter().next().ok_or_else(|| { + tracing::error!("Empty embedding response"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Step 2: Vector search — retrieve top 8 chunks + let search_results = pipeline + .store() + .vector_search(&repo_id, query_embedding, 8, 0.5) + .await + .map_err(|e| { + tracing::error!("Vector search failed: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Step 3: Build system prompt with code context + let mut context_parts = Vec::new(); + let mut sources = Vec::new(); + + for (embedding, score) in &search_results { + context_parts.push(format!( + "--- {} ({}, {}:L{}-L{}) ---\n{}", + embedding.qualified_name, + embedding.kind, + embedding.file_path, + embedding.start_line, + embedding.end_line, + embedding.content, + )); + + // Truncate snippet for the response + let snippet: String = embedding + .content + .lines() + .take(10) + .collect::>() + .join("\n"); + sources.push(SourceReference { + file_path: embedding.file_path.clone(), + qualified_name: embedding.qualified_name.clone(), + start_line: embedding.start_line, + end_line: embedding.end_line, + language: embedding.language.clone(), + snippet, + score: *score, + }); + } + + let code_context = if context_parts.is_empty() { + "No relevant code context found.".to_string() + } else { + context_parts.join("\n\n") + }; + + let system_prompt = format!( + "You are an expert code assistant for a software repository. \ + Answer the user's question based on the code context below. \ + Reference specific files and functions when relevant. \ + If the context doesn't contain enough information, say so.\n\n\ + ## Code Context\n\n{code_context}" + ); + + // Step 4: Build messages array with history + let mut messages: Vec<(String, String)> = Vec::new(); + messages.push(("system".to_string(), system_prompt)); + + for msg in &req.history { + messages.push((msg.role.clone(), msg.content.clone())); + } + messages.push(("user".to_string(), req.message)); + + // Step 5: Call LLM + let response_text = agent + .llm + .chat_with_messages(messages, Some(0.3)) + .await + .map_err(|e| { + tracing::error!("LLM chat failed: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(ApiResponse { + data: ChatResponse { + message: response_text, + sources, + }, + total: None, + page: None, + })) +} + +/// POST /api/v1/chat/:repo_id/build-embeddings — Trigger embedding build +pub async fn build_embeddings( + Extension(agent): AgentExt, + Path(repo_id): Path, +) -> Result, StatusCode> { + let agent_clone = (*agent).clone(); + tokio::spawn(async move { + let repo = match agent_clone + .db + .repositories() + .find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(&repo_id).ok() }) + .await + { + Ok(Some(r)) => r, + _ => { + tracing::error!("Repository {repo_id} not found for embedding build"); + return; + } + }; + + // Get latest graph build + let build = match agent_clone + .db + .graph_builds() + .find_one(doc! { "repo_id": &repo_id }) + .sort(doc! { "started_at": -1 }) + .await + { + Ok(Some(b)) => b, + _ => { + tracing::error!("[{repo_id}] No graph build found — build graph first"); + return; + } + }; + + let graph_build_id = build + .id + .map(|id| id.to_hex()) + .unwrap_or_else(|| "unknown".to_string()); + + // Get nodes + let nodes: Vec = match agent_clone + .db + .graph_nodes() + .find(doc! { "repo_id": &repo_id }) + .await + { + Ok(cursor) => { + use futures_util::StreamExt; + let mut items = Vec::new(); + let mut cursor = cursor; + while let Some(Ok(item)) = cursor.next().await { + items.push(item); + } + items + } + Err(e) => { + tracing::error!("[{repo_id}] Failed to fetch nodes: {e}"); + return; + } + }; + + let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path); + let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) { + Ok(p) => p, + Err(e) => { + tracing::error!("Failed to clone repo for embedding build: {e}"); + return; + } + }; + + let pipeline = RagPipeline::new(agent_clone.llm.clone(), agent_clone.db.inner()); + match pipeline + .build_embeddings(&repo_id, &repo_path, &graph_build_id, &nodes) + .await + { + Ok(run) => { + tracing::info!( + "[{repo_id}] Embedding build complete: {}/{} chunks", + run.embedded_chunks, + run.total_chunks + ); + } + Err(e) => { + tracing::error!("[{repo_id}] Embedding build failed: {e}"); + } + } + }); + + Ok(Json( + serde_json::json!({ "status": "embedding_build_triggered" }), + )) +} + +/// GET /api/v1/chat/:repo_id/status — Get latest embedding build status +pub async fn embedding_status( + Extension(agent): AgentExt, + Path(repo_id): Path, +) -> Result>>, StatusCode> { + let store = EmbeddingStore::new(agent.db.inner()); + let build = store.get_latest_build(&repo_id).await.map_err(|e| { + tracing::error!("Failed to get embedding status: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(ApiResponse { + data: build, + total: None, + page: None, + })) +} diff --git a/compliance-agent/src/api/handlers/dast.rs b/compliance-agent/src/api/handlers/dast.rs index 9046770..e8e5839 100644 --- a/compliance-agent/src/api/handlers/dast.rs +++ b/compliance-agent/src/api/handlers/dast.rs @@ -103,8 +103,7 @@ pub async fn trigger_scan( Extension(agent): AgentExt, Path(id): Path, ) -> Result, StatusCode> { - let oid = - mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; + let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; let target = agent .db @@ -207,8 +206,7 @@ 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 oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; let finding = agent .db diff --git a/compliance-agent/src/api/handlers/graph.rs b/compliance-agent/src/api/handlers/graph.rs index 75082d3..ea12acd 100644 --- a/compliance-agent/src/api/handlers/graph.rs +++ b/compliance-agent/src/api/handlers/graph.rs @@ -52,7 +52,7 @@ pub async fn get_graph( // so there is only one set of nodes/edges per repo. let filter = doc! { "repo_id": &repo_id }; - let nodes: Vec = match db.graph_nodes().find(filter.clone()).await { + let all_nodes: Vec = match db.graph_nodes().find(filter.clone()).await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; @@ -60,6 +60,17 @@ pub async fn get_graph( Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; + + // Remove disconnected nodes (no edges) to keep the graph clean + let connected: std::collections::HashSet<&str> = edges + .iter() + .flat_map(|e| [e.source.as_str(), e.target.as_str()]) + .collect(); + let nodes = all_nodes + .into_iter() + .filter(|n| connected.contains(n.qualified_name.as_str())) + .collect(); + (nodes, edges) } else { (Vec::new(), Vec::new()) @@ -235,12 +246,7 @@ pub async fn get_file_content( // Cap at 10,000 lines let truncated: String = content.lines().take(10_000).collect::>().join("\n"); - let language = params - .path - .rsplit('.') - .next() - .unwrap_or("") - .to_string(); + let language = params.path.rsplit('.').next().unwrap_or("").to_string(); Ok(Json(ApiResponse { data: FileContent { diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index 39af052..a3b7909 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod chat; pub mod dast; pub mod graph; @@ -5,7 +6,8 @@ use std::sync::Arc; #[allow(unused_imports)] use axum::extract::{Extension, Path, Query}; -use axum::http::StatusCode; +use axum::http::{header, StatusCode}; +use axum::response::IntoResponse; use axum::Json; use mongodb::bson::doc; use serde::{Deserialize, Serialize}; @@ -89,6 +91,72 @@ pub struct UpdateStatusRequest { pub status: 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>; @@ -235,6 +303,52 @@ pub async fn trigger_scan( 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, @@ -322,21 +436,46 @@ pub async fn update_finding_status( pub async fn list_sbom( Extension(agent): AgentExt, - Query(params): Query, + Query(filter): Query, ) -> ApiResult> { let db = &agent.db; - let skip = (params.page.saturating_sub(1)) * params.limit as u64; + 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(doc! {}) + .count_documents(query.clone()) .await .unwrap_or(0); let entries = match db .sbom_entries() - .find(doc! {}) + .find(query) + .sort(doc! { "name": 1 }) .skip(skip) - .limit(params.limit) + .limit(filter.limit) .await { Ok(cursor) => collect_cursor_async(cursor).await, @@ -346,7 +485,272 @@ pub async fn list_sbom( Ok(Json(ApiResponse { data: entries, total: Some(total), - page: Some(params.page), + 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, })) } diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index dd2794c..78fdc3d 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -1,4 +1,4 @@ -use axum::routing::{get, patch, post}; +use axum::routing::{delete, get, patch, post}; use axum::Router; use crate::api::handlers; @@ -13,6 +13,10 @@ pub fn build_router() -> Router { "/api/v1/repositories/{id}/scan", post(handlers::trigger_scan), ) + .route( + "/api/v1/repositories/{id}", + delete(handlers::delete_repository), + ) .route("/api/v1/findings", get(handlers::list_findings)) .route("/api/v1/findings/{id}", get(handlers::get_finding)) .route( @@ -20,13 +24,13 @@ pub fn build_router() -> Router { patch(handlers::update_finding_status), ) .route("/api/v1/sbom", get(handlers::list_sbom)) + .route("/api/v1/sbom/export", get(handlers::export_sbom)) + .route("/api/v1/sbom/licenses", get(handlers::license_summary)) + .route("/api/v1/sbom/diff", get(handlers::sbom_diff)) .route("/api/v1/issues", get(handlers::list_issues)) .route("/api/v1/scan-runs", get(handlers::list_scan_runs)) // Graph API endpoints - .route( - "/api/v1/graph/{repo_id}", - get(handlers::graph::get_graph), - ) + .route("/api/v1/graph/{repo_id}", get(handlers::graph::get_graph)) .route( "/api/v1/graph/{repo_id}/nodes", get(handlers::graph::get_nodes), @@ -52,14 +56,8 @@ pub fn build_router() -> Router { post(handlers::graph::trigger_build), ) // DAST API endpoints - .route( - "/api/v1/dast/targets", - get(handlers::dast::list_targets), - ) - .route( - "/api/v1/dast/targets", - post(handlers::dast::add_target), - ) + .route("/api/v1/dast/targets", get(handlers::dast::list_targets)) + .route("/api/v1/dast/targets", post(handlers::dast::add_target)) .route( "/api/v1/dast/targets/{id}/scan", post(handlers::dast::trigger_scan), @@ -68,12 +66,19 @@ pub fn build_router() -> Router { "/api/v1/dast/scan-runs", get(handlers::dast::list_scan_runs), ) - .route( - "/api/v1/dast/findings", - get(handlers::dast::list_findings), - ) + .route("/api/v1/dast/findings", get(handlers::dast::list_findings)) .route( "/api/v1/dast/findings/{id}", get(handlers::dast::get_finding), ) + // Chat / RAG API endpoints + .route("/api/v1/chat/{repo_id}", post(handlers::chat::chat)) + .route( + "/api/v1/chat/{repo_id}/build-embeddings", + post(handlers::chat::build_embeddings), + ) + .route( + "/api/v1/chat/{repo_id}/status", + get(handlers::chat::embedding_status), + ) } diff --git a/compliance-agent/src/config.rs b/compliance-agent/src/config.rs index 03ede73..06bf03d 100644 --- a/compliance-agent/src/config.rs +++ b/compliance-agent/src/config.rs @@ -24,6 +24,8 @@ pub fn load_config() -> Result { .unwrap_or_else(|| "http://localhost:4000".to_string()), litellm_api_key: SecretString::from(env_var_opt("LITELLM_API_KEY").unwrap_or_default()), litellm_model: env_var_opt("LITELLM_MODEL").unwrap_or_else(|| "gpt-4o".to_string()), + litellm_embed_model: env_var_opt("LITELLM_EMBED_MODEL") + .unwrap_or_else(|| "text-embedding-3-small".to_string()), github_token: env_secret_opt("GITHUB_TOKEN"), github_webhook_secret: env_secret_opt("GITHUB_WEBHOOK_SECRET"), gitlab_url: env_var_opt("GITLAB_URL"), diff --git a/compliance-agent/src/database.rs b/compliance-agent/src/database.rs index 3f32df5..c2b0740 100644 --- a/compliance-agent/src/database.rs +++ b/compliance-agent/src/database.rs @@ -127,11 +127,7 @@ impl Database { // dast_targets: index on repo_id self.dast_targets() - .create_index( - IndexModel::builder() - .keys(doc! { "repo_id": 1 }) - .build(), - ) + .create_index(IndexModel::builder().keys(doc! { "repo_id": 1 }).build()) .await?; // dast_scan_runs: compound (target_id, started_at DESC) @@ -152,6 +148,24 @@ impl Database { ) .await?; + // code_embeddings: compound (repo_id, graph_build_id) + self.code_embeddings() + .create_index( + IndexModel::builder() + .keys(doc! { "repo_id": 1, "graph_build_id": 1 }) + .build(), + ) + .await?; + + // embedding_builds: compound (repo_id, started_at DESC) + self.embedding_builds() + .create_index( + IndexModel::builder() + .keys(doc! { "repo_id": 1, "started_at": -1 }) + .build(), + ) + .await?; + tracing::info!("Database indexes ensured"); Ok(()) } @@ -210,6 +224,17 @@ impl Database { self.inner.collection("dast_findings") } + // Embedding collections + pub fn code_embeddings(&self) -> Collection { + self.inner.collection("code_embeddings") + } + + pub fn embedding_builds( + &self, + ) -> Collection { + self.inner.collection("embedding_builds") + } + #[allow(dead_code)] pub fn raw_collection(&self, name: &str) -> Collection { self.inner.collection(name) diff --git a/compliance-agent/src/llm/client.rs b/compliance-agent/src/llm/client.rs index f9a1653..c7a571d 100644 --- a/compliance-agent/src/llm/client.rs +++ b/compliance-agent/src/llm/client.rs @@ -8,6 +8,7 @@ pub struct LlmClient { base_url: String, api_key: SecretString, model: String, + embed_model: String, http: reqwest::Client, } @@ -42,16 +43,46 @@ struct ChatResponseMessage { content: String, } +/// Request body for the embeddings API +#[derive(Serialize)] +struct EmbeddingRequest { + model: String, + input: Vec, +} + +/// Response from the embeddings API +#[derive(Deserialize)] +struct EmbeddingResponse { + data: Vec, +} + +/// A single embedding result +#[derive(Deserialize)] +struct EmbeddingData { + embedding: Vec, + index: usize, +} + impl LlmClient { - pub fn new(base_url: String, api_key: SecretString, model: String) -> Self { + pub fn new( + base_url: String, + api_key: SecretString, + model: String, + embed_model: String, + ) -> Self { Self { base_url, api_key, model, + embed_model, http: reqwest::Client::new(), } } + pub fn embed_model(&self) -> &str { + &self.embed_model + } + pub async fn chat( &self, system_prompt: &str, @@ -169,4 +200,49 @@ impl LlmClient { .map(|c| c.message.content.clone()) .ok_or_else(|| AgentError::Other("Empty response from LiteLLM".to_string())) } + + /// Generate embeddings for a batch of texts + pub async fn embed(&self, texts: Vec) -> Result>, AgentError> { + let url = format!("{}/v1/embeddings", self.base_url.trim_end_matches('/')); + + let request_body = EmbeddingRequest { + model: self.embed_model.clone(), + input: texts, + }; + + let mut req = self + .http + .post(&url) + .header("content-type", "application/json") + .json(&request_body); + + let key = self.api_key.expose_secret(); + if !key.is_empty() { + req = req.header("Authorization", format!("Bearer {key}")); + } + + let resp = req + .send() + .await + .map_err(|e| AgentError::Other(format!("Embedding request failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(AgentError::Other(format!( + "Embedding API returned {status}: {body}" + ))); + } + + let body: EmbeddingResponse = resp + .json() + .await + .map_err(|e| AgentError::Other(format!("Failed to parse embedding response: {e}")))?; + + // Sort by index to maintain input order + let mut data = body.data; + data.sort_by_key(|d| d.index); + + Ok(data.into_iter().map(|d| d.embedding).collect()) + } } diff --git a/compliance-agent/src/main.rs b/compliance-agent/src/main.rs index 98cf902..f8518bb 100644 --- a/compliance-agent/src/main.rs +++ b/compliance-agent/src/main.rs @@ -7,6 +7,7 @@ mod database; mod error; mod llm; mod pipeline; +mod rag; mod scheduler; #[allow(dead_code)] mod trackers; diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index 3abcecc..39923ca 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -185,7 +185,9 @@ impl PipelineOrchestrator { // Stage 4.5: Graph Building tracing::info!("[{repo_id}] Stage 4.5: Graph Building"); self.update_phase(scan_run_id, "graph_building").await; - let graph_context = match self.build_code_graph(&repo_path, &repo_id, &all_findings).await + let graph_context = match self + .build_code_graph(&repo_path, &repo_id, &all_findings) + .await { Ok(ctx) => Some(ctx), Err(e) => { @@ -296,9 +298,10 @@ impl PipelineOrchestrator { let graph_build_id = uuid::Uuid::new_v4().to_string(); let engine = compliance_graph::GraphEngine::new(50_000); - let (mut code_graph, build_run) = engine - .build_graph(repo_path, repo_id, &graph_build_id) - .map_err(|e| AgentError::Other(format!("Graph build error: {e}")))?; + let (mut code_graph, build_run) = + engine + .build_graph(repo_path, repo_id, &graph_build_id) + .map_err(|e| AgentError::Other(format!("Graph build error: {e}")))?; // Apply community detection compliance_graph::graph::community::apply_communities(&mut code_graph); @@ -348,15 +351,11 @@ impl PipelineOrchestrator { use futures_util::TryStreamExt; let filter = mongodb::bson::doc! { "repo_id": repo_id }; - let targets: Vec = match self - .db - .dast_targets() - .find(filter) - .await - { - Ok(cursor) => cursor.try_collect().await.unwrap_or_default(), - Err(_) => return, - }; + let targets: Vec = + match self.db.dast_targets().find(filter).await { + Ok(cursor) => cursor.try_collect().await.unwrap_or_default(), + Err(_) => return, + }; if targets.is_empty() { tracing::info!("[{repo_id}] No DAST targets configured, skipping"); @@ -379,10 +378,7 @@ impl PipelineOrchestrator { tracing::error!("Failed to store DAST finding: {e}"); } } - tracing::info!( - "DAST scan complete: {} findings", - findings.len() - ); + tracing::info!("DAST scan complete: {} findings", findings.len()); } Err(e) => { tracing::error!("DAST scan failed: {e}"); diff --git a/compliance-agent/src/rag/mod.rs b/compliance-agent/src/rag/mod.rs new file mode 100644 index 0000000..626c2e4 --- /dev/null +++ b/compliance-agent/src/rag/mod.rs @@ -0,0 +1 @@ +pub mod pipeline; diff --git a/compliance-agent/src/rag/pipeline.rs b/compliance-agent/src/rag/pipeline.rs new file mode 100644 index 0000000..19d5949 --- /dev/null +++ b/compliance-agent/src/rag/pipeline.rs @@ -0,0 +1,164 @@ +use std::path::Path; +use std::sync::Arc; + +use chrono::Utc; +use compliance_core::models::embedding::{CodeEmbedding, EmbeddingBuildRun, EmbeddingBuildStatus}; +use compliance_core::models::graph::CodeNode; +use compliance_graph::graph::chunking::extract_chunks; +use compliance_graph::graph::embedding_store::EmbeddingStore; +use tracing::{error, info}; + +use crate::error::AgentError; +use crate::llm::LlmClient; + +/// RAG pipeline for building embeddings and performing retrieval +pub struct RagPipeline { + llm: Arc, + embedding_store: EmbeddingStore, +} + +impl RagPipeline { + pub fn new(llm: Arc, db: &mongodb::Database) -> Self { + Self { + llm, + embedding_store: EmbeddingStore::new(db), + } + } + + pub fn store(&self) -> &EmbeddingStore { + &self.embedding_store + } + + /// Build embeddings for all code nodes in a repository + pub async fn build_embeddings( + &self, + repo_id: &str, + repo_path: &Path, + graph_build_id: &str, + nodes: &[CodeNode], + ) -> Result { + let embed_model = self.llm.embed_model().to_string(); + let mut build = + EmbeddingBuildRun::new(repo_id.to_string(), graph_build_id.to_string(), embed_model); + + // Step 1: Extract chunks + let chunks = extract_chunks(repo_path, nodes, 2048); + build.total_chunks = chunks.len() as u32; + info!( + "[{repo_id}] Extracted {} chunks for embedding", + chunks.len() + ); + + // Store the initial build record + self.embedding_store + .store_build(&build) + .await + .map_err(|e| AgentError::Other(format!("Failed to store build: {e}")))?; + + if chunks.is_empty() { + build.status = EmbeddingBuildStatus::Completed; + build.completed_at = Some(Utc::now()); + self.embedding_store + .update_build( + repo_id, + graph_build_id, + EmbeddingBuildStatus::Completed, + 0, + None, + ) + .await + .map_err(|e| AgentError::Other(format!("Failed to update build: {e}")))?; + return Ok(build); + } + + // Step 2: Delete old embeddings for this repo + self.embedding_store + .delete_repo_embeddings(repo_id) + .await + .map_err(|e| AgentError::Other(format!("Failed to delete old embeddings: {e}")))?; + + // Step 3: Batch embed (small batches to stay within model limits) + let batch_size = 20; + let mut all_embeddings = Vec::new(); + let mut embedded_count = 0u32; + + for batch_start in (0..chunks.len()).step_by(batch_size) { + let batch_end = (batch_start + batch_size).min(chunks.len()); + let batch_chunks = &chunks[batch_start..batch_end]; + + // Prepare texts: context_header + content + let texts: Vec = batch_chunks + .iter() + .map(|c| format!("{}\n{}", c.context_header, c.content)) + .collect(); + + match self.llm.embed(texts).await { + Ok(vectors) => { + for (chunk, embedding) in batch_chunks.iter().zip(vectors) { + all_embeddings.push(CodeEmbedding { + id: None, + repo_id: repo_id.to_string(), + graph_build_id: graph_build_id.to_string(), + qualified_name: chunk.qualified_name.clone(), + kind: chunk.kind.clone(), + file_path: chunk.file_path.clone(), + start_line: chunk.start_line, + end_line: chunk.end_line, + language: chunk.language.clone(), + content: chunk.content.clone(), + context_header: chunk.context_header.clone(), + embedding, + token_estimate: chunk.token_estimate, + created_at: Utc::now(), + }); + } + embedded_count += batch_chunks.len() as u32; + } + Err(e) => { + error!("[{repo_id}] Embedding batch failed: {e}"); + build.status = EmbeddingBuildStatus::Failed; + build.error_message = Some(e.to_string()); + build.completed_at = Some(Utc::now()); + let _ = self + .embedding_store + .update_build( + repo_id, + graph_build_id, + EmbeddingBuildStatus::Failed, + embedded_count, + Some(e.to_string()), + ) + .await; + return Ok(build); + } + } + } + + // Step 4: Store all embeddings + self.embedding_store + .store_embeddings(&all_embeddings) + .await + .map_err(|e| AgentError::Other(format!("Failed to store embeddings: {e}")))?; + + // Step 5: Update build status + build.status = EmbeddingBuildStatus::Completed; + build.embedded_chunks = embedded_count; + build.completed_at = Some(Utc::now()); + self.embedding_store + .update_build( + repo_id, + graph_build_id, + EmbeddingBuildStatus::Completed, + embedded_count, + None, + ) + .await + .map_err(|e| AgentError::Other(format!("Failed to update build: {e}")))?; + + info!( + "[{repo_id}] Embedding build complete: {embedded_count}/{} chunks", + build.total_chunks + ); + Ok(build) + } +} diff --git a/compliance-core/src/config.rs b/compliance-core/src/config.rs index 1c2ffae..3f38740 100644 --- a/compliance-core/src/config.rs +++ b/compliance-core/src/config.rs @@ -8,6 +8,7 @@ pub struct AgentConfig { pub litellm_url: String, pub litellm_api_key: SecretString, pub litellm_model: String, + pub litellm_embed_model: String, pub github_token: Option, pub github_webhook_secret: Option, pub gitlab_url: Option, diff --git a/compliance-core/src/models/chat.rs b/compliance-core/src/models/chat.rs new file mode 100644 index 0000000..a243c92 --- /dev/null +++ b/compliance-core/src/models/chat.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +/// A message in the chat history +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +/// Request body for the chat endpoint +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatRequest { + pub message: String, + #[serde(default)] + pub history: Vec, +} + +/// A source reference from the RAG retrieval +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceReference { + pub file_path: String, + pub qualified_name: String, + pub start_line: u32, + pub end_line: u32, + pub language: String, + pub snippet: String, + pub score: f64, +} + +/// Response from the chat endpoint +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatResponse { + pub message: String, + pub sources: Vec, +} diff --git a/compliance-core/src/models/dast.rs b/compliance-core/src/models/dast.rs index 521e513..d2755a0 100644 --- a/compliance-core/src/models/dast.rs +++ b/compliance-core/src/models/dast.rs @@ -244,6 +244,7 @@ pub struct DastFinding { } impl DastFinding { + #[allow(clippy::too_many_arguments)] pub fn new( scan_run_id: String, target_id: String, diff --git a/compliance-core/src/models/embedding.rs b/compliance-core/src/models/embedding.rs new file mode 100644 index 0000000..60f1f1a --- /dev/null +++ b/compliance-core/src/models/embedding.rs @@ -0,0 +1,100 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Status of an embedding build operation +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EmbeddingBuildStatus { + Running, + Completed, + Failed, +} + +/// A code embedding stored in MongoDB Atlas Vector Search +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodeEmbedding { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + pub repo_id: String, + pub graph_build_id: String, + pub qualified_name: String, + pub kind: String, + pub file_path: String, + pub start_line: u32, + pub end_line: u32, + pub language: String, + pub content: String, + pub context_header: String, + pub embedding: Vec, + pub token_estimate: u32, + #[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")] + pub created_at: DateTime, +} + +/// Tracks an embedding build operation for a repository +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbeddingBuildRun { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + pub repo_id: String, + pub graph_build_id: String, + pub status: EmbeddingBuildStatus, + pub total_chunks: u32, + pub embedded_chunks: u32, + pub embedding_model: String, + pub error_message: Option, + #[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")] + pub started_at: DateTime, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "opt_chrono_as_bson" + )] + pub completed_at: Option>, +} + +impl EmbeddingBuildRun { + pub fn new(repo_id: String, graph_build_id: String, embedding_model: String) -> Self { + Self { + id: None, + repo_id, + graph_build_id, + status: EmbeddingBuildStatus::Running, + total_chunks: 0, + embedded_chunks: 0, + embedding_model, + error_message: None, + started_at: Utc::now(), + completed_at: None, + } + } +} + +/// Serde helper for Option> as BSON DateTime +mod opt_chrono_as_bson { + use chrono::{DateTime, Utc}; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + #[derive(Serialize, Deserialize)] + struct BsonDt( + #[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")] DateTime, + ); + + pub fn serialize(value: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(dt) => BsonDt(*dt).serialize(serializer), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let opt: Option = Option::deserialize(deserializer)?; + Ok(opt.map(|d| d.0)) + } +} diff --git a/compliance-core/src/models/mod.rs b/compliance-core/src/models/mod.rs index 1a210a5..c695d78 100644 --- a/compliance-core/src/models/mod.rs +++ b/compliance-core/src/models/mod.rs @@ -1,5 +1,7 @@ +pub mod chat; pub mod cve; pub mod dast; +pub mod embedding; pub mod finding; pub mod graph; pub mod issue; @@ -7,15 +9,16 @@ pub mod repository; pub mod sbom; pub mod scan; +pub use chat::{ChatMessage, ChatRequest, ChatResponse, SourceReference}; pub use cve::{CveAlert, CveSource}; pub use dast::{ DastAuthConfig, DastEvidence, DastFinding, DastScanPhase, DastScanRun, DastScanStatus, DastTarget, DastTargetType, DastVulnType, }; +pub use embedding::{CodeEmbedding, EmbeddingBuildRun, EmbeddingBuildStatus}; pub use finding::{Finding, FindingStatus, Severity}; pub use graph::{ - CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind, GraphBuildRun, GraphBuildStatus, - ImpactAnalysis, + CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind, GraphBuildRun, GraphBuildStatus, ImpactAnalysis, }; pub use issue::{IssueStatus, TrackerIssue, TrackerType}; pub use repository::{ScanTrigger, TrackedRepository}; diff --git a/compliance-core/src/models/repository.rs b/compliance-core/src/models/repository.rs index e283afe..569a139 100644 --- a/compliance-core/src/models/repository.rs +++ b/compliance-core/src/models/repository.rs @@ -31,9 +31,15 @@ pub struct TrackedRepository { pub last_scanned_commit: Option, #[serde(default, deserialize_with = "deserialize_findings_count")] pub findings_count: u32, - #[serde(default = "chrono::Utc::now", deserialize_with = "deserialize_datetime")] + #[serde( + default = "chrono::Utc::now", + deserialize_with = "deserialize_datetime" + )] pub created_at: DateTime, - #[serde(default = "chrono::Utc::now", deserialize_with = "deserialize_datetime")] + #[serde( + default = "chrono::Utc::now", + deserialize_with = "deserialize_datetime" + )] pub updated_at: DateTime, } @@ -51,9 +57,7 @@ where let bson = bson::Bson::deserialize(deserializer)?; match bson { bson::Bson::DateTime(dt) => Ok(dt.into()), - bson::Bson::String(s) => s - .parse::>() - .map_err(serde::de::Error::custom), + bson::Bson::String(s) => s.parse::>().map_err(serde::de::Error::custom), other => Err(serde::de::Error::custom(format!( "expected DateTime or string, got: {other:?}" ))), diff --git a/compliance-dashboard/assets/graph-viz.js b/compliance-dashboard/assets/graph-viz.js index 982b95c..ef9337d 100644 --- a/compliance-dashboard/assets/graph-viz.js +++ b/compliance-dashboard/assets/graph-viz.js @@ -169,20 +169,20 @@ enabled: true, solver: "forceAtlas2Based", forceAtlas2Based: { - gravitationalConstant: -60, - centralGravity: 0.012, - springLength: 80, - springConstant: 0.06, - damping: 0.4, - avoidOverlap: 0.5, + gravitationalConstant: -80, + centralGravity: 0.005, + springLength: 120, + springConstant: 0.04, + damping: 0.5, + avoidOverlap: 0.6, }, stabilization: { enabled: true, - iterations: 1000, + iterations: 1500, updateInterval: 25, }, - maxVelocity: 40, - minVelocity: 0.1, + maxVelocity: 50, + minVelocity: 0.75, }, interaction: { hover: true, @@ -252,7 +252,24 @@ overlay.style.display = "none"; }, 900); } - network.setOptions({ physics: { enabled: false } }); + // Keep physics running so nodes float and respond to dragging, + // but reduce forces for a calm, settled feel + network.setOptions({ + physics: { + enabled: true, + solver: "forceAtlas2Based", + forceAtlas2Based: { + gravitationalConstant: -40, + centralGravity: 0.003, + springLength: 120, + springConstant: 0.03, + damping: 0.7, + avoidOverlap: 0.6, + }, + maxVelocity: 20, + minVelocity: 0.75, + }, + }); }); console.log( diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index 37b1fbc..93b80fb 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -603,6 +603,76 @@ tbody tr:last-child td { background: var(--accent-muted); } +.btn-ghost-danger:hover { + color: var(--danger); + border-color: var(--danger); + background: var(--danger-bg); +} + +.btn-danger { + background: var(--danger); + color: #fff; + border: 1px solid var(--danger); +} + +.btn-danger:hover { + background: #e0334f; + box-shadow: 0 0 12px rgba(255, 59, 92, 0.3); +} + +/* ── Modal ── */ + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; +} + +.modal-dialog { + background: var(--bg-card-solid); + border: 1px solid var(--border-bright); + border-radius: var(--radius-lg); + padding: 24px 28px; + max-width: 460px; + width: 90%; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); +} + +.modal-dialog h3 { + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; + margin-bottom: 12px; +} + +.modal-dialog p { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 8px; +} + +.modal-warning { + color: var(--danger) !important; + font-size: 13px !important; + background: var(--danger-bg); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin-top: 4px; +} + +.modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 20px; +} + .btn-secondary { background: transparent; color: var(--accent); @@ -1710,3 +1780,660 @@ tbody tr:last-child td { white-space: nowrap; margin-left: auto; } + +/* ── AI Chat ── */ + +.chat-embedding-banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 16px; + font-size: 13px; + color: var(--text-secondary); +} + +.chat-embedding-building { + border-color: var(--border-accent); + background: rgba(0, 200, 255, 0.04); +} + +.chat-embedding-status { + display: flex; + align-items: center; + gap: 10px; + flex: 1; +} + +.chat-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-bright); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.chat-progress-bar { + width: 120px; + height: 6px; + background: var(--bg-secondary); + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; +} + +.chat-progress-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.5s var(--ease-out); + min-width: 2%; +} + +.chat-embedding-banner .btn-sm { + padding: 6px 14px; + font-size: 12px; + background: var(--accent-muted); + color: var(--accent); + border: 1px solid var(--border-accent); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.2s var(--ease-out); +} + +.chat-embedding-banner .btn-sm:hover:not(:disabled) { + background: var(--accent); + color: var(--bg-primary); +} + +.chat-embedding-banner .btn-sm:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-container { + display: flex; + flex-direction: column; + height: calc(100vh - 240px); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.chat-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-tertiary); + text-align: center; +} + +.chat-empty h3 { + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.chat-empty p { + font-size: 13px; + max-width: 400px; +} + +.chat-message { + max-width: 80%; + padding: 12px 16px; + border-radius: var(--radius); + font-size: 14px; + line-height: 1.6; +} + +.chat-message-user { + align-self: flex-end; + background: var(--accent-muted); + border: 1px solid var(--border-accent); + color: var(--text-primary); +} + +.chat-message-assistant { + align-self: flex-start; + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text-primary); +} + +.chat-message-role { + font-family: var(--font-display); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary); + margin-bottom: 6px; +} + +.chat-message-content { + white-space: pre-wrap; + word-break: break-word; +} + +.chat-typing { + color: var(--text-tertiary); + font-style: italic; +} + +.chat-sources { + margin-top: 12px; + border-top: 1px solid var(--border); + padding-top: 10px; +} + +.chat-sources-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary); + display: block; + margin-bottom: 8px; +} + +.chat-source-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin-bottom: 6px; +} + +.chat-source-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.chat-source-name { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + color: var(--accent); +} + +.chat-source-location { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-tertiary); +} + +.chat-source-snippet { + margin: 0; + padding: 8px; + background: var(--bg-primary); + border-radius: 4px; + overflow-x: auto; + max-height: 120px; +} + +.chat-source-snippet code { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + white-space: pre; +} + +.chat-input-area { + display: flex; + gap: 10px; + padding: 16px 20px; + border-top: 1px solid var(--border); + background: var(--bg-secondary); +} + +.chat-input { + flex: 1; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 14px; + padding: 10px 14px; + resize: none; + min-height: 42px; + max-height: 120px; + outline: none; + transition: border-color 0.2s var(--ease-out); +} + +.chat-input:focus { + border-color: var(--accent); +} + +.chat-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-send-btn { + padding: 10px 20px; + background: var(--accent); + color: var(--bg-primary); + border: none; + border-radius: var(--radius-sm); + font-family: var(--font-display); + font-weight: 600; + font-size: 13px; + cursor: pointer; + transition: all 0.2s var(--ease-out); + align-self: flex-end; +} + +.chat-send-btn:hover:not(:disabled) { + background: var(--accent-hover); + box-shadow: var(--accent-glow); +} + +.chat-send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── SBOM Enhancements ── */ + +.sbom-tab-bar { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.sbom-tab { + padding: 10px 20px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + font-family: var(--font-display); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s var(--ease-out); +} + +.sbom-tab:hover { + color: var(--text-primary); +} + +.sbom-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.sbom-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 16px; + align-items: center; +} + +.sbom-filter-select { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 13px; + padding: 8px 12px; + outline: none; + transition: border-color 0.2s var(--ease-out); + min-width: 140px; +} + +.sbom-filter-select:focus { + border-color: var(--accent); +} + +.sbom-filter-input { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 13px; + padding: 8px 14px; + outline: none; + min-width: 200px; + transition: border-color 0.2s var(--ease-out); +} + +.sbom-filter-input:focus { + border-color: var(--accent); +} + +.sbom-filter-input::placeholder { + color: var(--text-tertiary); +} + +.sbom-result-count { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 12px; +} + +/* Export */ +.sbom-export-wrapper { + position: relative; + margin-left: auto; +} + +.sbom-export-btn { + font-size: 13px; +} + +.sbom-export-dropdown { + position: absolute; + top: 100%; + right: 0; + z-index: 50; + background: var(--bg-elevated); + border: 1px solid var(--border-bright); + border-radius: var(--radius); + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + margin-top: 4px; +} + +.sbom-export-hint { + font-size: 11px; + color: var(--text-tertiary); +} + +.sbom-export-result { + margin-bottom: 16px; +} + +.sbom-export-result-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +/* Vulnerability drill-down */ +.sbom-vuln-toggle { + cursor: pointer; + user-select: none; +} + +.sbom-vuln-detail-row td { + padding: 0 !important; + background: var(--bg-secondary); +} + +.sbom-vuln-detail { + padding: 12px 16px; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.sbom-vuln-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 14px; + min-width: 240px; + flex: 1; + max-width: 400px; +} + +.sbom-vuln-card-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + flex-wrap: wrap; +} + +.sbom-vuln-id { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.sbom-vuln-source { + font-size: 11px; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.sbom-vuln-link { + font-size: 12px; + color: var(--accent); + text-decoration: none; + transition: color 0.15s; +} + +.sbom-vuln-link:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +/* License compliance */ +.sbom-license-badge { + font-size: 12px; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-weight: 500; + white-space: nowrap; +} + +.license-permissive { + background: var(--success-bg); + color: var(--success); + border: 1px solid rgba(0, 230, 118, 0.2); +} + +.license-weak-copyleft { + background: var(--warning-bg); + color: var(--warning); + border: 1px solid rgba(255, 176, 32, 0.2); +} + +.license-copyleft { + background: var(--danger-bg); + color: var(--danger); + border: 1px solid rgba(255, 59, 92, 0.2); +} + +.license-copyleft-warning { + background: var(--danger-bg); + border: 1px solid rgba(255, 59, 92, 0.3); + border-radius: var(--radius); + padding: 16px 20px; + margin-bottom: 16px; +} + +.license-copyleft-warning strong { + color: var(--danger); + font-size: 15px; + display: block; + margin-bottom: 6px; +} + +.license-copyleft-warning p { + color: var(--text-secondary); + font-size: 13px; + margin-bottom: 10px; +} + +.license-copyleft-item { + padding: 6px 0; + font-size: 13px; + color: var(--text-secondary); +} + +.license-pkg-list { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-tertiary); +} + +.license-bar-chart { + display: flex; + flex-direction: column; + gap: 8px; +} + +.license-bar-row { + display: flex; + align-items: center; + gap: 12px; +} + +.license-bar-label { + font-size: 13px; + color: var(--text-secondary); + min-width: 120px; + text-align: right; + flex-shrink: 0; +} + +.license-bar-track { + flex: 1; + height: 20px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.license-bar { + height: 100%; + border-radius: var(--radius-sm); + transition: width 0.3s var(--ease-out); +} + +.license-bar.license-permissive { + background: var(--success); + border: none; +} + +.license-bar.license-copyleft { + background: var(--danger); + border: none; +} + +.license-bar-count { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-tertiary); + min-width: 32px; +} + +/* SBOM Diff */ +.sbom-diff-controls { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.sbom-diff-select-group { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 200px; +} + +.sbom-diff-select-group label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.sbom-diff-summary { + display: flex; + gap: 12px; + margin: 16px 0; + flex-wrap: wrap; +} + +.sbom-diff-stat { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + flex: 1; + min-width: 100px; + text-align: center; + font-size: 12px; + color: var(--text-secondary); +} + +.sbom-diff-stat-num { + font-family: var(--font-display); + font-size: 24px; + font-weight: 700; + color: var(--text-primary); +} + +.sbom-diff-added .sbom-diff-stat-num { + color: var(--success); +} + +.sbom-diff-removed .sbom-diff-stat-num { + color: var(--danger); +} + +.sbom-diff-changed .sbom-diff-stat-num { + color: var(--warning); +} + +.sbom-diff-row-added { + border-left: 3px solid var(--success); +} + +.sbom-diff-row-removed { + border-left: 3px solid var(--danger); +} + +.sbom-diff-row-changed { + border-left: 3px solid var(--warning); +} diff --git a/compliance-dashboard/src/app.rs b/compliance-dashboard/src/app.rs index 19bad87..08724cf 100644 --- a/compliance-dashboard/src/app.rs +++ b/compliance-dashboard/src/app.rs @@ -26,6 +26,10 @@ pub enum Route { GraphExplorerPage { repo_id: String }, #[route("/graph/:repo_id/impact/:finding_id")] ImpactAnalysisPage { repo_id: String, finding_id: String }, + #[route("/chat")] + ChatIndexPage {}, + #[route("/chat/:repo_id")] + ChatPage { repo_id: String }, #[route("/dast")] DastOverviewPage {}, #[route("/dast/targets")] diff --git a/compliance-dashboard/src/components/file_tree.rs b/compliance-dashboard/src/components/file_tree.rs index f1a57a1..b62c11d 100644 --- a/compliance-dashboard/src/components/file_tree.rs +++ b/compliance-dashboard/src/components/file_tree.rs @@ -47,17 +47,19 @@ fn insert_path( let name = parts[0].to_string(); let is_leaf = parts.len() == 1; - let entry = children.entry(name.clone()).or_insert_with(|| FileTreeNode { - name: name.clone(), - path: if is_leaf { - full_path.to_string() - } else { - String::new() - }, - is_dir: !is_leaf, - node_count: 0, - children: Vec::new(), - }); + let entry = children + .entry(name.clone()) + .or_insert_with(|| FileTreeNode { + name: name.clone(), + path: if is_leaf { + full_path.to_string() + } else { + String::new() + }, + is_dir: !is_leaf, + node_count: 0, + children: Vec::new(), + }); if is_leaf { entry.node_count = node_count; diff --git a/compliance-dashboard/src/components/sidebar.rs b/compliance-dashboard/src/components/sidebar.rs index 8617190..bb9ab69 100644 --- a/compliance-dashboard/src/components/sidebar.rs +++ b/compliance-dashboard/src/components/sidebar.rs @@ -46,6 +46,11 @@ pub fn Sidebar() -> Element { route: Route::GraphIndexPage {}, icon: rsx! { Icon { icon: BsDiagram3, width: 18, height: 18 } }, }, + NavItem { + label: "AI Chat", + route: Route::ChatIndexPage {}, + icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } }, + }, NavItem { label: "DAST", route: Route::DastOverviewPage {}, @@ -58,7 +63,11 @@ pub fn Sidebar() -> Element { }, ]; - let sidebar_class = if collapsed() { "sidebar collapsed" } else { "sidebar" }; + let sidebar_class = if collapsed() { + "sidebar collapsed" + } else { + "sidebar" + }; rsx! { nav { class: "{sidebar_class}", @@ -76,6 +85,7 @@ pub fn Sidebar() -> Element { (Route::GraphIndexPage {}, Route::GraphIndexPage {}) => true, (Route::GraphExplorerPage { .. }, Route::GraphIndexPage {}) => true, (Route::ImpactAnalysisPage { .. }, Route::GraphIndexPage {}) => true, + (Route::ChatPage { .. }, Route::ChatIndexPage {}) => true, (Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true, (Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true, (Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true, diff --git a/compliance-dashboard/src/components/toast.rs b/compliance-dashboard/src/components/toast.rs index 627ab0d..dec6d96 100644 --- a/compliance-dashboard/src/components/toast.rs +++ b/compliance-dashboard/src/components/toast.rs @@ -20,6 +20,12 @@ pub struct Toasts { next_id: Signal, } +impl Default for Toasts { + fn default() -> Self { + Self::new() + } +} + impl Toasts { pub fn new() -> Self { Self { @@ -39,11 +45,11 @@ impl Toasts { #[cfg(feature = "web")] { - let mut items = self.items; - spawn(async move { - gloo_timers::future::TimeoutFuture::new(4_000).await; - items.write().retain(|t| t.id != id); - }); + let mut items = self.items; + spawn(async move { + gloo_timers::future::TimeoutFuture::new(4_000).await; + items.write().retain(|t| t.id != id); + }); } } diff --git a/compliance-dashboard/src/infrastructure/chat.rs b/compliance-dashboard/src/infrastructure/chat.rs new file mode 100644 index 0000000..6dee347 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/chat.rs @@ -0,0 +1,126 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +// ── Response types ── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ChatApiResponse { + pub data: ChatResponseData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ChatResponseData { + pub message: String, + #[serde(default)] + pub sources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SourceRef { + pub file_path: String, + pub qualified_name: String, + pub start_line: u32, + pub end_line: u32, + pub language: String, + pub snippet: String, + pub score: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EmbeddingStatusResponse { + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EmbeddingBuildData { + pub repo_id: String, + pub status: String, + pub total_chunks: u32, + pub embedded_chunks: u32, + pub embedding_model: String, + pub error_message: Option, + #[serde(default)] + pub started_at: Option, + #[serde(default)] + pub completed_at: Option, +} + +// ── Chat message history type ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatHistoryMessage { + pub role: String, + pub content: String, +} + +// ── Server functions ── + +#[server] +pub async fn send_chat_message( + repo_id: String, + message: String, + history: Vec, +) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!("{}/api/v1/chat/{repo_id}", state.agent_api_url); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| ServerFnError::new(e.to_string()))?; + let resp = client + .post(&url) + .json(&serde_json::json!({ + "message": message, + "history": history, + })) + .send() + .await + .map_err(|e| ServerFnError::new(format!("Request failed: {e}")))?; + + let text = resp + .text() + .await + .map_err(|e| ServerFnError::new(format!("Failed to read response: {e}")))?; + + let body: ChatApiResponse = serde_json::from_str(&text) + .map_err(|e| ServerFnError::new(format!("Failed to parse response: {e} — body: {text}")))?; + Ok(body) +} + +#[server] +pub async fn trigger_embedding_build(repo_id: String) -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!( + "{}/api/v1/chat/{repo_id}/build-embeddings", + state.agent_api_url + ); + let client = reqwest::Client::new(); + client + .post(&url) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(()) +} + +#[server] +pub async fn fetch_embedding_status( + repo_id: String, +) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!("{}/api/v1/chat/{repo_id}/status", state.agent_api_url); + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: EmbeddingStatusResponse = resp + .json() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body) +} diff --git a/compliance-dashboard/src/infrastructure/dast.rs b/compliance-dashboard/src/infrastructure/dast.rs index c042dd7..9b6fe50 100644 --- a/compliance-dashboard/src/infrastructure/dast.rs +++ b/compliance-dashboard/src/infrastructure/dast.rs @@ -87,10 +87,7 @@ pub async fn fetch_dast_finding_detail( } #[server] -pub async fn add_dast_target( - name: String, - base_url: String, -) -> Result<(), ServerFnError> { +pub async fn add_dast_target(name: String, base_url: String) -> Result<(), ServerFnError> { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; let url = format!("{}/api/v1/dast/targets", state.agent_api_url); diff --git a/compliance-dashboard/src/infrastructure/graph.rs b/compliance-dashboard/src/infrastructure/graph.rs index 011c131..68a0d06 100644 --- a/compliance-dashboard/src/infrastructure/graph.rs +++ b/compliance-dashboard/src/infrastructure/graph.rs @@ -121,10 +121,7 @@ pub async fn fetch_file_content( } #[server] -pub async fn search_nodes( - repo_id: String, - query: String, -) -> Result { +pub async fn search_nodes(repo_id: String, query: String) -> Result { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; let url = format!( diff --git a/compliance-dashboard/src/infrastructure/mod.rs b/compliance-dashboard/src/infrastructure/mod.rs index 0862ecf..9ee2706 100644 --- a/compliance-dashboard/src/infrastructure/mod.rs +++ b/compliance-dashboard/src/infrastructure/mod.rs @@ -1,5 +1,6 @@ // Server function modules (compiled for both web and server; // the #[server] macro generates client stubs for the web target) +pub mod chat; pub mod dast; pub mod findings; pub mod graph; diff --git a/compliance-dashboard/src/infrastructure/repositories.rs b/compliance-dashboard/src/infrastructure/repositories.rs index eb2a18b..f4f740d 100644 --- a/compliance-dashboard/src/infrastructure/repositories.rs +++ b/compliance-dashboard/src/infrastructure/repositories.rs @@ -61,6 +61,29 @@ pub async fn add_repository( Ok(()) } +#[server] +pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/repositories/{repo_id}", state.agent_api_url); + + let client = reqwest::Client::new(); + let resp = client + .delete(&url) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(ServerFnError::new(format!( + "Failed to delete repository: {body}" + ))); + } + + Ok(()) +} + #[server] pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> { let state: super::server_state::ServerState = diff --git a/compliance-dashboard/src/infrastructure/sbom.rs b/compliance-dashboard/src/infrastructure/sbom.rs index a456f0d..c5fb0a2 100644 --- a/compliance-dashboard/src/infrastructure/sbom.rs +++ b/compliance-dashboard/src/infrastructure/sbom.rs @@ -1,27 +1,202 @@ use dioxus::prelude::*; use serde::{Deserialize, Serialize}; -use compliance_core::models::SbomEntry; +// ── Local types (no bson dependency, WASM-safe) ── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VulnRefData { + pub id: String, + pub source: String, + pub severity: Option, + pub url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomEntryData { + #[serde(rename = "_id", default)] + pub id: Option, + pub repo_id: String, + pub name: String, + pub version: String, + pub package_manager: String, + pub license: Option, + pub purl: Option, + #[serde(default)] + pub known_vulnerabilities: Vec, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, +} #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SbomListResponse { - pub data: Vec, + pub data: Vec, pub total: Option, pub page: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LicenseSummaryData { + pub license: String, + pub count: u64, + pub is_copyleft: bool, + pub packages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LicenseSummaryResponse { + pub data: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomDiffEntryData { + pub name: String, + pub version: String, + pub package_manager: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomVersionDiffData { + pub name: String, + pub package_manager: String, + pub version_a: String, + pub version_b: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomDiffResultData { + pub only_in_a: Vec, + pub only_in_b: Vec, + pub version_changed: Vec, + pub common_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomDiffResponse { + pub data: SbomDiffResultData, +} + +// ── Server functions ── + #[server] -pub async fn fetch_sbom(page: u64) -> Result { +pub async fn fetch_sbom_filtered( + repo_id: Option, + package_manager: Option, + q: Option, + has_vulns: Option, + license: Option, + page: u64, +) -> Result { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; - let url = format!("{}/api/v1/sbom?page={page}&limit=50", state.agent_api_url); + + let mut params = vec![format!("page={page}"), "limit=50".to_string()]; + if let Some(r) = &repo_id { + if !r.is_empty() { + params.push(format!("repo_id={r}")); + } + } + if let Some(pm) = &package_manager { + if !pm.is_empty() { + params.push(format!("package_manager={pm}")); + } + } + if let Some(q) = &q { + if !q.is_empty() { + params.push(format!("q={}", q.replace(' ', "%20"))); + } + } + if let Some(hv) = has_vulns { + params.push(format!("has_vulns={hv}")); + } + if let Some(l) = &license { + if !l.is_empty() { + params.push(format!("license={}", l.replace(' ', "%20"))); + } + } + + let url = format!("{}/api/v1/sbom?{}", state.agent_api_url, params.join("&")); let resp = reqwest::get(&url) .await .map_err(|e| ServerFnError::new(e.to_string()))?; - let body: SbomListResponse = resp - .json() + let text = resp + .text() .await .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: SbomListResponse = serde_json::from_str(&text) + .map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?; + Ok(body) +} + +#[server] +pub async fn fetch_sbom_export(repo_id: String, format: String) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!( + "{}/api/v1/sbom/export?repo_id={}&format={}", + state.agent_api_url, repo_id, format + ); + + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let text = resp + .text() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(text) +} + +#[server] +pub async fn fetch_license_summary( + repo_id: Option, +) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let mut url = format!("{}/api/v1/sbom/licenses", state.agent_api_url); + if let Some(r) = &repo_id { + if !r.is_empty() { + url = format!("{url}?repo_id={r}"); + } + } + + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let text = resp + .text() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: LicenseSummaryResponse = serde_json::from_str(&text) + .map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?; + Ok(body) +} + +#[server] +pub async fn fetch_sbom_diff( + repo_a: String, + repo_b: String, +) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!( + "{}/api/v1/sbom/diff?repo_a={}&repo_b={}", + state.agent_api_url, repo_a, repo_b + ); + + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let text = resp + .text() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: SbomDiffResponse = serde_json::from_str(&text) + .map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?; Ok(body) } diff --git a/compliance-dashboard/src/pages/chat.rs b/compliance-dashboard/src/pages/chat.rs new file mode 100644 index 0000000..e7cd02a --- /dev/null +++ b/compliance-dashboard/src/pages/chat.rs @@ -0,0 +1,288 @@ +use dioxus::prelude::*; + +use crate::components::page_header::PageHeader; +use crate::infrastructure::chat::{ + fetch_embedding_status, send_chat_message, trigger_embedding_build, ChatHistoryMessage, + SourceRef, +}; + +/// A UI-level chat message +#[derive(Clone, Debug)] +struct UiChatMessage { + role: String, + content: String, + sources: Vec, +} + +#[component] +pub fn ChatPage(repo_id: String) -> Element { + let mut messages: Signal> = use_signal(Vec::new); + let mut input_text = use_signal(String::new); + let mut loading = use_signal(|| false); + let mut building = use_signal(|| false); + + let repo_id_for_status = repo_id.clone(); + let mut embedding_status = use_resource(move || { + let rid = repo_id_for_status.clone(); + async move { fetch_embedding_status(rid).await.ok() } + }); + + let has_embeddings = { + let status = embedding_status.read(); + match &*status { + Some(Some(resp)) => resp + .data + .as_ref() + .map(|d| d.status == "completed") + .unwrap_or(false), + _ => false, + } + }; + + let is_running = { + let status = embedding_status.read(); + match &*status { + Some(Some(resp)) => resp + .data + .as_ref() + .map(|d| d.status == "running") + .unwrap_or(false), + _ => false, + } + }; + + let embed_progress = { + let status = embedding_status.read(); + match &*status { + Some(Some(resp)) => resp + .data + .as_ref() + .map(|d| { + if d.total_chunks > 0 { + (d.embedded_chunks as f64 / d.total_chunks as f64 * 100.0) as u32 + } else { + 0 + } + }) + .unwrap_or(0), + _ => 0, + } + }; + + let embedding_status_text = { + let status = embedding_status.read(); + match &*status { + Some(Some(resp)) => match &resp.data { + Some(d) => match d.status.as_str() { + "completed" => format!( + "Embeddings ready: {}/{} chunks", + d.embedded_chunks, d.total_chunks + ), + "running" => format!( + "Building embeddings: {}/{} chunks ({}%)", + d.embedded_chunks, d.total_chunks, embed_progress + ), + "failed" => format!( + "Embedding build failed: {}", + d.error_message.as_deref().unwrap_or("unknown error") + ), + s => format!("Status: {s}"), + }, + None => "No embeddings built yet".to_string(), + }, + Some(None) => "Failed to check embedding status".to_string(), + None => "Checking embedding status...".to_string(), + } + }; + + // Auto-poll embedding status every 3s while building/running + use_effect(move || { + if is_running || *building.read() { + spawn(async move { + #[cfg(feature = "web")] + gloo_timers::future::TimeoutFuture::new(3_000).await; + #[cfg(not(feature = "web"))] + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + embedding_status.restart(); + }); + } + }); + + let repo_id_for_build = repo_id.clone(); + let on_build = move |_| { + let rid = repo_id_for_build.clone(); + building.set(true); + spawn(async move { + let _ = trigger_embedding_build(rid).await; + building.set(false); + embedding_status.restart(); + }); + }; + + let repo_id_for_send = repo_id.clone(); + let mut do_send = move || { + let text = input_text.read().trim().to_string(); + if text.is_empty() || *loading.read() { + return; + } + + let rid = repo_id_for_send.clone(); + let user_msg = text.clone(); + + // Add user message to UI + messages.write().push(UiChatMessage { + role: "user".to_string(), + content: user_msg.clone(), + sources: Vec::new(), + }); + input_text.set(String::new()); + loading.set(true); + + spawn(async move { + // Build history from existing messages + let history: Vec = messages + .read() + .iter() + .filter(|m| m.role == "user" || m.role == "assistant") + .rev() + .skip(1) // skip the message we just added + .take(10) // limit history + .collect::>() + .into_iter() + .rev() + .map(|m| ChatHistoryMessage { + role: m.role.clone(), + content: m.content.clone(), + }) + .collect(); + + match send_chat_message(rid, user_msg, history).await { + Ok(resp) => { + messages.write().push(UiChatMessage { + role: "assistant".to_string(), + content: resp.data.message, + sources: resp.data.sources, + }); + } + Err(e) => { + messages.write().push(UiChatMessage { + role: "assistant".to_string(), + content: format!("Error: {e}"), + sources: Vec::new(), + }); + } + } + loading.set(false); + }); + }; + + let mut do_send_click = do_send.clone(); + + rsx! { + PageHeader { title: "AI Chat" } + + // Embedding status banner + div { class: if is_running || *building.read() { "chat-embedding-banner chat-embedding-building" } else { "chat-embedding-banner" }, + div { class: "chat-embedding-status", + if is_running || *building.read() { + span { class: "chat-spinner" } + } + span { "{embedding_status_text}" } + } + if is_running || *building.read() { + div { class: "chat-progress-bar", + div { + class: "chat-progress-fill", + style: "width: {embed_progress}%;", + } + } + } + button { + class: "btn btn-sm", + disabled: *building.read() || is_running, + onclick: on_build, + if *building.read() || is_running { "Building..." } else { "Build Embeddings" } + } + } + + div { class: "chat-container", + // Message list + div { class: "chat-messages", + if messages.read().is_empty() && !*loading.read() { + div { class: "chat-empty", + h3 { "Ask anything about your codebase" } + p { "Build embeddings first, then ask questions about functions, architecture, patterns, and more." } + } + } + for (i, msg) in messages.read().iter().enumerate() { + { + let class = if msg.role == "user" { + "chat-message chat-message-user" + } else { + "chat-message chat-message-assistant" + }; + let content = msg.content.clone(); + let sources = msg.sources.clone(); + rsx! { + div { class: class, key: "{i}", + div { class: "chat-message-role", + if msg.role == "user" { "You" } else { "Assistant" } + } + div { class: "chat-message-content", "{content}" } + if !sources.is_empty() { + div { class: "chat-sources", + span { class: "chat-sources-label", "Sources:" } + for src in sources { + div { class: "chat-source-card", + div { class: "chat-source-header", + span { class: "chat-source-name", + "{src.qualified_name}" + } + span { class: "chat-source-location", + "{src.file_path}:{src.start_line}-{src.end_line}" + } + } + pre { class: "chat-source-snippet", + code { "{src.snippet}" } + } + } + } + } + } + } + } + } + } + if *loading.read() { + div { class: "chat-message chat-message-assistant", + div { class: "chat-message-role", "Assistant" } + div { class: "chat-message-content chat-typing", "Thinking..." } + } + } + } + + // Input area + div { class: "chat-input-area", + textarea { + class: "chat-input", + placeholder: "Ask about your codebase...", + value: "{input_text}", + disabled: !has_embeddings, + oninput: move |e| input_text.set(e.value()), + onkeydown: move |e: Event| { + if e.key() == Key::Enter && !e.modifiers().shift() { + e.prevent_default(); + do_send(); + } + }, + } + button { + class: "btn chat-send-btn", + disabled: *loading.read() || !has_embeddings, + onclick: move |_| do_send_click(), + "Send" + } + } + } + } +} diff --git a/compliance-dashboard/src/pages/chat_index.rs b/compliance-dashboard/src/pages/chat_index.rs new file mode 100644 index 0000000..5d56141 --- /dev/null +++ b/compliance-dashboard/src/pages/chat_index.rs @@ -0,0 +1,70 @@ +use dioxus::prelude::*; + +use crate::app::Route; +use crate::components::page_header::PageHeader; +use crate::infrastructure::repositories::fetch_repositories; + +#[component] +pub fn ChatIndexPage() -> Element { + let repos = use_resource(|| async { fetch_repositories(1).await.ok() }); + + rsx! { + PageHeader { + title: "AI Chat", + description: "Ask questions about your codebase using RAG-augmented AI", + } + + match &*repos.read() { + Some(Some(data)) => { + let repo_list = &data.data; + if repo_list.is_empty() { + rsx! { + div { class: "card", + p { "No repositories found. Add a repository first." } + } + } + } else { + rsx! { + div { class: "graph-index-grid", + for repo in repo_list { + { + let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); + let name = repo.name.clone(); + let url = repo.git_url.clone(); + let branch = repo.default_branch.clone(); + rsx! { + Link { + to: Route::ChatPage { repo_id }, + class: "graph-repo-card", + div { class: "graph-repo-card-header", + div { class: "graph-repo-card-icon", "\u{1F4AC}" } + h3 { class: "graph-repo-card-name", "{name}" } + } + if !url.is_empty() { + p { class: "graph-repo-card-url", "{url}" } + } + div { class: "graph-repo-card-meta", + span { class: "graph-repo-card-tag", + "\u{E0A0} {branch}" + } + span { class: "graph-repo-card-tag", + "AI Chat" + } + } + } + } + } + } + } + } + } + }, + Some(None) => rsx! { + div { class: "card", p { "Failed to load repositories." } } + }, + None => rsx! { + div { class: "loading", "Loading repositories..." } + }, + } + } +} diff --git a/compliance-dashboard/src/pages/dast_findings.rs b/compliance-dashboard/src/pages/dast_findings.rs index 1729c60..9c5b43e 100644 --- a/compliance-dashboard/src/pages/dast_findings.rs +++ b/compliance-dashboard/src/pages/dast_findings.rs @@ -49,7 +49,7 @@ pub fn DastFindingsPage() -> Element { } td { Link { - to: Route::DastFindingDetailPage { id: id }, + to: Route::DastFindingDetailPage { id }, "{finding.get(\"title\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" } } diff --git a/compliance-dashboard/src/pages/findings.rs b/compliance-dashboard/src/pages/findings.rs index 2f06b8e..e6fd390 100644 --- a/compliance-dashboard/src/pages/findings.rs +++ b/compliance-dashboard/src/pages/findings.rs @@ -14,7 +14,9 @@ pub fn FindingsPage() -> Element { let mut repo_filter = use_signal(String::new); let repos = use_resource(|| async { - crate::infrastructure::repositories::fetch_repositories(1).await.ok() + crate::infrastructure::repositories::fetch_repositories(1) + .await + .ok() }); let findings = use_resource(move || { diff --git a/compliance-dashboard/src/pages/graph_explorer.rs b/compliance-dashboard/src/pages/graph_explorer.rs index ce5b400..93c14d3 100644 --- a/compliance-dashboard/src/pages/graph_explorer.rs +++ b/compliance-dashboard/src/pages/graph_explorer.rs @@ -27,13 +27,13 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { let mut inspector_open = use_signal(|| false); // Search state - let mut search_query = use_signal(|| String::new()); - let mut search_results = use_signal(|| Vec::::new()); - let mut file_filter = use_signal(|| String::new()); + let mut search_query = use_signal(String::new); + let mut search_results = use_signal(Vec::::new); + let mut file_filter = use_signal(String::new); // Store serialized graph JSON in signals so use_effect can react to them - let mut nodes_json = use_signal(|| String::new()); - let mut edges_json = use_signal(|| String::new()); + let mut nodes_json = use_signal(String::new); + let mut edges_json = use_signal(String::new); let mut graph_ready = use_signal(|| false); // When resource resolves, serialize the data into signals @@ -404,7 +404,7 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { } else if node_count > 0 { // Data exists but nodes array was empty (shouldn't happen) div { class: "loading", "Loading graph visualization..." } - } else if matches!(&*graph_data.read(), None) { + } else if (*graph_data.read()).is_none() { div { class: "loading", "Loading graph data..." } } else { div { class: "graph-empty-state", diff --git a/compliance-dashboard/src/pages/mod.rs b/compliance-dashboard/src/pages/mod.rs index 16b8803..5d14ed5 100644 --- a/compliance-dashboard/src/pages/mod.rs +++ b/compliance-dashboard/src/pages/mod.rs @@ -1,3 +1,5 @@ +pub mod chat; +pub mod chat_index; pub mod dast_finding_detail; pub mod dast_findings; pub mod dast_overview; @@ -13,6 +15,8 @@ pub mod repositories; pub mod sbom; pub mod settings; +pub use chat::ChatPage; +pub use chat_index::ChatIndexPage; pub use dast_finding_detail::DastFindingDetailPage; pub use dast_findings::DastFindingsPage; pub use dast_overview::DastOverviewPage; diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index 91a6e4e..b1ff63b 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -13,6 +13,7 @@ pub fn RepositoriesPage() -> Element { let mut git_url = use_signal(String::new); let mut branch = use_signal(|| "main".to_string()); let mut toasts = use_context::(); + let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name) let mut repos = use_resource(move || { let p = page(); @@ -91,6 +92,48 @@ pub fn RepositoriesPage() -> Element { } } + // ── Delete confirmation dialog ── + if let Some((del_id, del_name)) = confirm_delete() { + div { class: "modal-overlay", + div { class: "modal-dialog", + h3 { "Delete Repository" } + p { + "Are you sure you want to delete " + strong { "{del_name}" } + "?" + } + p { class: "modal-warning", + "This will permanently remove all associated findings, SBOM entries, scan runs, graph data, embeddings, and CVE alerts." + } + div { class: "modal-actions", + button { + class: "btn btn-secondary", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + button { + class: "btn btn-danger", + onclick: move |_| { + let id = del_id.clone(); + let name = del_name.clone(); + confirm_delete.set(None); + spawn(async move { + match crate::infrastructure::repositories::delete_repository(id).await { + Ok(_) => { + toasts.push(ToastType::Success, format!("{name} deleted")); + repos.restart(); + } + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + }); + }, + "Delete" + } + } + } + } + } + match &*repos.read() { Some(Some(resp)) => { let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1); @@ -112,7 +155,9 @@ pub fn RepositoriesPage() -> Element { for repo in &resp.data { { let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); - let repo_id_clone = repo_id.clone(); + let repo_id_scan = repo_id.clone(); + let repo_id_del = repo_id.clone(); + let repo_name_del = repo.name.clone(); rsx! { tr { td { "{repo.name}" } @@ -149,7 +194,7 @@ pub fn RepositoriesPage() -> Element { button { class: "btn btn-ghost", onclick: move |_| { - let id = repo_id_clone.clone(); + let id = repo_id_scan.clone(); spawn(async move { match crate::infrastructure::repositories::trigger_repo_scan(id).await { Ok(_) => toasts.push(ToastType::Success, "Scan triggered"), @@ -159,6 +204,13 @@ pub fn RepositoriesPage() -> Element { }, "Scan" } + button { + class: "btn btn-ghost btn-ghost-danger", + onclick: move |_| { + confirm_delete.set(Some((repo_id_del.clone(), repo_name_del.clone()))); + }, + "Delete" + } } } } diff --git a/compliance-dashboard/src/pages/sbom.rs b/compliance-dashboard/src/pages/sbom.rs index 0cfcb66..1779334 100644 --- a/compliance-dashboard/src/pages/sbom.rs +++ b/compliance-dashboard/src/pages/sbom.rs @@ -2,60 +2,335 @@ use dioxus::prelude::*; use crate::components::page_header::PageHeader; use crate::components::pagination::Pagination; +use crate::infrastructure::sbom::*; #[component] pub fn SbomPage() -> Element { + // ── Filter signals ── let mut page = use_signal(|| 1u64); + let mut repo_filter = use_signal(String::new); + let mut pm_filter = use_signal(String::new); + let mut search_q = use_signal(String::new); + let mut vuln_toggle = use_signal(|| Option::::None); + let mut license_filter = use_signal(String::new); + // ── Active tab: "packages" | "licenses" | "diff" ── + let mut active_tab = use_signal(|| "packages".to_string()); + + // ── Vuln drill-down: track expanded row by (name, version) ── + let mut expanded_row = use_signal(|| Option::::None); + + // ── Export state ── + let mut show_export = use_signal(|| false); + let mut export_format = use_signal(|| "cyclonedx".to_string()); + let mut export_result = use_signal(|| Option::::None); + + // ── Diff state ── + let mut diff_repo_a = use_signal(String::new); + let mut diff_repo_b = use_signal(String::new); + + // ── Repos for dropdowns ── + let repos = use_resource(|| async { + crate::infrastructure::repositories::fetch_repositories(1) + .await + .ok() + }); + + // ── SBOM list (filtered) ── let sbom = use_resource(move || { let p = page(); - async move { crate::infrastructure::sbom::fetch_sbom(p).await.ok() } + let repo = repo_filter(); + let pm = pm_filter(); + let q = search_q(); + let hv = vuln_toggle(); + let lic = license_filter(); + async move { + fetch_sbom_filtered( + if repo.is_empty() { None } else { Some(repo) }, + if pm.is_empty() { None } else { Some(pm) }, + if q.is_empty() { None } else { Some(q) }, + hv, + if lic.is_empty() { None } else { Some(lic) }, + p, + ) + .await + .ok() + } + }); + + // ── License summary ── + let license_data = use_resource(move || { + let repo = repo_filter(); + async move { + fetch_license_summary(if repo.is_empty() { None } else { Some(repo) }) + .await + .ok() + } + }); + + // ── Diff data ── + let diff_data = use_resource(move || { + let a = diff_repo_a(); + let b = diff_repo_b(); + async move { + if a.is_empty() || b.is_empty() { + return None; + } + fetch_sbom_diff(a, b).await.ok() + } }); rsx! { PageHeader { title: "SBOM", - description: "Software Bill of Materials - dependency inventory across all repositories", + description: "Software Bill of Materials — dependency inventory, license compliance, and vulnerability analysis", } - match &*sbom.read() { - Some(Some(resp)) => { - let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1); - rsx! { - div { class: "card", - div { class: "table-wrapper", - table { - thead { - tr { - th { "Package" } - th { "Version" } - th { "Manager" } - th { "License" } - th { "Vulnerabilities" } + // ── Tab bar ── + div { class: "sbom-tab-bar", + button { + class: if active_tab() == "packages" { "sbom-tab active" } else { "sbom-tab" }, + onclick: move |_| active_tab.set("packages".to_string()), + "Packages" + } + button { + class: if active_tab() == "licenses" { "sbom-tab active" } else { "sbom-tab" }, + onclick: move |_| active_tab.set("licenses".to_string()), + "License Compliance" + } + button { + class: if active_tab() == "diff" { "sbom-tab active" } else { "sbom-tab" }, + onclick: move |_| active_tab.set("diff".to_string()), + "Compare" + } + } + + // ═══════════════ PACKAGES TAB ═══════════════ + if active_tab() == "packages" { + // ── Filter bar ── + div { class: "sbom-filter-bar", + select { + class: "sbom-filter-select", + onchange: move |e| { repo_filter.set(e.value()); page.set(1); }, + option { value: "", "All Repositories" } + { + match &*repos.read() { + Some(Some(resp)) => rsx! { + for repo in &resp.data { + { + let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); + let name = repo.name.clone(); + rsx! { option { value: "{id}", "{name}" } } } } - tbody { - for entry in &resp.data { + }, + _ => rsx! {}, + } + } + } + select { + class: "sbom-filter-select", + onchange: move |e| { pm_filter.set(e.value()); page.set(1); }, + option { value: "", "All Managers" } + option { value: "npm", "npm" } + option { value: "cargo", "Cargo" } + option { value: "pip", "pip" } + option { value: "go", "Go" } + option { value: "maven", "Maven" } + option { value: "nuget", "NuGet" } + option { value: "composer", "Composer" } + option { value: "gem", "RubyGems" } + } + input { + class: "sbom-filter-input", + r#type: "text", + placeholder: "Search packages...", + oninput: move |e| { search_q.set(e.value()); page.set(1); }, + } + select { + class: "sbom-filter-select", + onchange: move |e| { + let val = e.value(); + vuln_toggle.set(match val.as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }); + page.set(1); + }, + option { value: "", "All Packages" } + option { value: "true", "With Vulnerabilities" } + option { value: "false", "No Vulnerabilities" } + } + select { + class: "sbom-filter-select", + onchange: move |e| { license_filter.set(e.value()); page.set(1); }, + option { value: "", "All Licenses" } + option { value: "MIT", "MIT" } + option { value: "Apache-2.0", "Apache 2.0" } + option { value: "BSD-3-Clause", "BSD 3-Clause" } + option { value: "ISC", "ISC" } + option { value: "GPL-3.0", "GPL 3.0" } + option { value: "GPL-2.0", "GPL 2.0" } + option { value: "LGPL-2.1", "LGPL 2.1" } + option { value: "MPL-2.0", "MPL 2.0" } + } + + // ── Export button ── + div { class: "sbom-export-wrapper", + button { + class: "btn btn-secondary sbom-export-btn", + onclick: move |_| show_export.toggle(), + "Export" + } + if show_export() { + div { class: "sbom-export-dropdown", + select { + class: "sbom-filter-select", + value: "{export_format}", + onchange: move |e| export_format.set(e.value()), + option { value: "cyclonedx", "CycloneDX 1.5" } + option { value: "spdx", "SPDX 2.3" } + } + button { + class: "btn btn-primary", + disabled: repo_filter().is_empty(), + onclick: move |_| { + let repo = repo_filter(); + let fmt = export_format(); + spawn(async move { + match fetch_sbom_export(repo, fmt).await { + Ok(json) => export_result.set(Some(json)), + Err(e) => tracing::error!("Export failed: {e}"), + } + }); + }, + "Download" + } + if repo_filter().is_empty() { + span { class: "sbom-export-hint", "Select a repo first" } + } + } + } + } + } + + // ── Export result display ── + if let Some(json) = export_result() { + div { class: "card sbom-export-result", + div { class: "sbom-export-result-header", + strong { "Exported SBOM" } + button { + class: "btn btn-secondary", + onclick: move |_| export_result.set(None), + "Close" + } + } + pre { + style: "max-height: 400px; overflow: auto; font-size: 12px;", + "{json}" + } + } + } + + // ── SBOM table ── + match &*sbom.read() { + Some(Some(resp)) => { + let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1); + rsx! { + if let Some(total) = resp.total { + div { class: "sbom-result-count", + "{total} package(s) found" + } + } + div { class: "card", + div { class: "table-wrapper", + table { + thead { tr { - td { - style: "font-weight: 500;", - "{entry.name}" - } - td { - style: "font-family: monospace; font-size: 13px;", - "{entry.version}" - } - td { "{entry.package_manager}" } - td { "{entry.license.as_deref().unwrap_or(\"-\")}" } - td { - if entry.known_vulnerabilities.is_empty() { - span { - style: "color: var(--success);", - "None" + th { "Package" } + th { "Version" } + th { "Manager" } + th { "License" } + th { "Vulnerabilities" } + } + } + tbody { + for entry in &resp.data { + { + let row_key = format!("{}@{}", entry.name, entry.version); + let is_expanded = expanded_row() == Some(row_key.clone()); + let has_vulns = !entry.known_vulnerabilities.is_empty(); + let license_class = license_css_class(entry.license.as_deref()); + let row_key_click = row_key.clone(); + rsx! { + tr { + td { + style: "font-weight: 500;", + "{entry.name}" + } + td { + style: "font-family: var(--font-mono, monospace); font-size: 13px;", + "{entry.version}" + } + td { "{entry.package_manager}" } + td { + span { class: "sbom-license-badge {license_class}", + "{entry.license.as_deref().unwrap_or(\"-\")}" + } + } + td { + if has_vulns { + span { + class: "badge badge-high sbom-vuln-toggle", + onclick: move |_| { + let key = row_key_click.clone(); + if expanded_row() == Some(key.clone()) { + expanded_row.set(None); + } else { + expanded_row.set(Some(key)); + } + }, + "{entry.known_vulnerabilities.len()} vuln(s) ▾" + } + } else { + span { + style: "color: var(--success);", + "None" + } + } + } } - } else { - span { class: "badge badge-high", - "{entry.known_vulnerabilities.len()} vuln(s)" + // ── Vulnerability drill-down row ── + if is_expanded && has_vulns { + tr { class: "sbom-vuln-detail-row", + td { colspan: "5", + div { class: "sbom-vuln-detail", + for vuln in &entry.known_vulnerabilities { + div { class: "sbom-vuln-card", + div { class: "sbom-vuln-card-header", + span { class: "sbom-vuln-id", "{vuln.id}" } + span { class: "sbom-vuln-source", "{vuln.source}" } + if let Some(sev) = &vuln.severity { + span { + class: "badge badge-{sev}", + "{sev}" + } + } + } + if let Some(url) = &vuln.url { + a { + href: "{url}", + target: "_blank", + class: "sbom-vuln-link", + "View Advisory →" + } + } + } + } + } + } + } } } } @@ -63,21 +338,321 @@ pub fn SbomPage() -> Element { } } } + Pagination { + current_page: page(), + total_pages: total_pages, + on_page_change: move |p| page.set(p), + } } - Pagination { - current_page: page(), - total_pages: total_pages, - on_page_change: move |p| page.set(p), + } + }, + Some(None) => rsx! { + div { class: "card", p { "Failed to load SBOM." } } + }, + None => rsx! { + div { class: "loading", "Loading SBOM..." } + }, + } + } + + // ═══════════════ LICENSE COMPLIANCE TAB ═══════════════ + if active_tab() == "licenses" { + match &*license_data.read() { + Some(Some(resp)) => { + let total_pkgs: u64 = resp.data.iter().map(|l| l.count).sum(); + let has_copyleft = resp.data.iter().any(|l| l.is_copyleft); + let copyleft_items: Vec<_> = resp.data.iter().filter(|l| l.is_copyleft).collect(); + + rsx! { + if has_copyleft { + div { class: "license-copyleft-warning", + strong { "⚠ Copyleft Licenses Detected" } + p { "The following copyleft-licensed packages may impose distribution requirements on your software." } + for item in ©left_items { + div { class: "license-copyleft-item", + span { class: "sbom-license-badge license-copyleft", "{item.license}" } + span { " — {item.count} package(s): " } + span { class: "license-pkg-list", + "{item.packages.join(\", \")}" + } + } + } + } + } + + div { class: "card", + h3 { style: "margin-bottom: 16px;", "License Distribution" } + if total_pkgs > 0 { + div { class: "license-bar-chart", + for item in &resp.data { + { + let pct = (item.count as f64 / total_pkgs as f64 * 100.0).max(2.0); + let bar_class = if item.is_copyleft { "license-bar license-copyleft" } else { "license-bar license-permissive" }; + rsx! { + div { class: "license-bar-row", + span { class: "license-bar-label", "{item.license}" } + div { class: "license-bar-track", + div { + class: "{bar_class}", + style: "width: {pct}%;", + } + } + span { class: "license-bar-count", "{item.count}" } + } + } + } + } + } + } else { + p { "No license data available." } + } + } + + div { class: "card", + h3 { style: "margin-bottom: 16px;", "All Licenses" } + div { class: "table-wrapper", + table { + thead { + tr { + th { "License" } + th { "Type" } + th { "Packages" } + th { "Count" } + } + } + tbody { + for item in &resp.data { + tr { + td { + span { + class: "sbom-license-badge {license_type_class(item.is_copyleft)}", + "{item.license}" + } + } + td { + if item.is_copyleft { + span { class: "badge badge-high", "Copyleft" } + } else { + span { class: "badge badge-info", "Permissive" } + } + } + td { + style: "max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;", + "{item.packages.join(\", \")}" + } + td { "{item.count}" } + } + } + } + } + } + } + } + }, + Some(None) => rsx! { + div { class: "card", p { "Failed to load license summary." } } + }, + None => rsx! { + div { class: "loading", "Loading license data..." } + }, + } + } + + // ═══════════════ DIFF TAB ═══════════════ + if active_tab() == "diff" { + div { class: "card", + h3 { style: "margin-bottom: 16px;", "Compare SBOMs Between Repositories" } + div { class: "sbom-diff-controls", + div { class: "sbom-diff-select-group", + label { "Repository A" } + select { + class: "sbom-filter-select", + onchange: move |e| diff_repo_a.set(e.value()), + option { value: "", "Select repository..." } + { + match &*repos.read() { + Some(Some(resp)) => rsx! { + for repo in &resp.data { + { + let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); + let name = repo.name.clone(); + rsx! { option { value: "{id}", "{name}" } } + } + } + }, + _ => rsx! {}, + } + } + } + } + div { class: "sbom-diff-select-group", + label { "Repository B" } + select { + class: "sbom-filter-select", + onchange: move |e| diff_repo_b.set(e.value()), + option { value: "", "Select repository..." } + { + match &*repos.read() { + Some(Some(resp)) => rsx! { + for repo in &resp.data { + { + let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); + let name = repo.name.clone(); + rsx! { option { value: "{id}", "{name}" } } + } + } + }, + _ => rsx! {}, + } + } } } } - }, - Some(None) => rsx! { - div { class: "card", p { "Failed to load SBOM." } } - }, - None => rsx! { - div { class: "loading", "Loading SBOM..." } - }, + } + + if !diff_repo_a().is_empty() && !diff_repo_b().is_empty() { + match &*diff_data.read() { + Some(Some(resp)) => { + let d = &resp.data; + rsx! { + div { class: "sbom-diff-summary", + div { class: "sbom-diff-stat sbom-diff-added", + span { class: "sbom-diff-stat-num", "{d.only_in_a.len()}" } + span { "Only in A" } + } + div { class: "sbom-diff-stat sbom-diff-removed", + span { class: "sbom-diff-stat-num", "{d.only_in_b.len()}" } + span { "Only in B" } + } + div { class: "sbom-diff-stat sbom-diff-changed", + span { class: "sbom-diff-stat-num", "{d.version_changed.len()}" } + span { "Version Diffs" } + } + div { class: "sbom-diff-stat", + span { class: "sbom-diff-stat-num", "{d.common_count}" } + span { "Common" } + } + } + + if !d.only_in_a.is_empty() { + div { class: "card", + h4 { style: "margin-bottom: 12px; color: var(--success);", "Only in Repository A" } + div { class: "table-wrapper", + table { + thead { + tr { + th { "Package" } + th { "Version" } + th { "Manager" } + } + } + tbody { + for e in &d.only_in_a { + tr { class: "sbom-diff-row-added", + td { "{e.name}" } + td { "{e.version}" } + td { "{e.package_manager}" } + } + } + } + } + } + } + } + + if !d.only_in_b.is_empty() { + div { class: "card", + h4 { style: "margin-bottom: 12px; color: var(--danger);", "Only in Repository B" } + div { class: "table-wrapper", + table { + thead { + tr { + th { "Package" } + th { "Version" } + th { "Manager" } + } + } + tbody { + for e in &d.only_in_b { + tr { class: "sbom-diff-row-removed", + td { "{e.name}" } + td { "{e.version}" } + td { "{e.package_manager}" } + } + } + } + } + } + } + } + + if !d.version_changed.is_empty() { + div { class: "card", + h4 { style: "margin-bottom: 12px; color: var(--warning);", "Version Differences" } + div { class: "table-wrapper", + table { + thead { + tr { + th { "Package" } + th { "Manager" } + th { "Version A" } + th { "Version B" } + } + } + tbody { + for e in &d.version_changed { + tr { class: "sbom-diff-row-changed", + td { "{e.name}" } + td { "{e.package_manager}" } + td { "{e.version_a}" } + td { "{e.version_b}" } + } + } + } + } + } + } + } + + if d.only_in_a.is_empty() && d.only_in_b.is_empty() && d.version_changed.is_empty() { + div { class: "card", + p { "Both repositories have identical SBOM entries." } + } + } + } + }, + Some(None) => rsx! { + div { class: "card", p { "Failed to load diff." } } + }, + None => rsx! { + div { class: "loading", "Computing diff..." } + }, + } + } } } } + +fn license_css_class(license: Option<&str>) -> &'static str { + match license { + Some(l) => { + let upper = l.to_uppercase(); + if upper.contains("GPL") || upper.contains("AGPL") { + "license-copyleft" + } else if upper.contains("LGPL") || upper.contains("MPL") { + "license-weak-copyleft" + } else { + "license-permissive" + } + } + None => "", + } +} + +fn license_type_class(is_copyleft: bool) -> &'static str { + if is_copyleft { + "license-copyleft" + } else { + "license-permissive" + } +} diff --git a/compliance-dast/src/agents/api_fuzzer.rs b/compliance-dast/src/agents/api_fuzzer.rs index e426288..91fa153 100644 --- a/compliance-dast/src/agents/api_fuzzer.rs +++ b/compliance-dast/src/agents/api_fuzzer.rs @@ -234,10 +234,7 @@ impl ApiFuzzerAgent { .ok()?; let headers = response.headers(); - let acao = headers - .get("access-control-allow-origin")? - .to_str() - .ok()?; + let acao = headers.get("access-control-allow-origin")?.to_str().ok()?; if acao == "*" || acao == "https://evil.com" { let acac = headers @@ -265,12 +262,9 @@ impl ApiFuzzerAgent { request_body: None, response_status: response.status().as_u16(), response_headers: Some( - [( - "Access-Control-Allow-Origin".to_string(), - acao.to_string(), - )] - .into_iter() - .collect(), + [("Access-Control-Allow-Origin".to_string(), acao.to_string())] + .into_iter() + .collect(), ), response_snippet: None, screenshot_path: None, diff --git a/compliance-dast/src/agents/auth_bypass.rs b/compliance-dast/src/agents/auth_bypass.rs index ba88fe3..5c48613 100644 --- a/compliance-dast/src/agents/auth_bypass.rs +++ b/compliance-dast/src/agents/auth_bypass.rs @@ -132,7 +132,10 @@ impl DastAgent for AuthBypassAgent { String::new(), target_id.clone(), DastVulnType::AuthBypass, - format!("HTTP method tampering: {} accepted on {}", method, endpoint.url), + format!( + "HTTP method tampering: {} accepted on {}", + method, endpoint.url + ), format!( "Endpoint {} accepts {} requests which may bypass access controls.", endpoint.url, method diff --git a/compliance-dast/src/agents/ssrf.rs b/compliance-dast/src/agents/ssrf.rs index 4cee538..216bca1 100644 --- a/compliance-dast/src/agents/ssrf.rs +++ b/compliance-dast/src/agents/ssrf.rs @@ -20,10 +20,7 @@ impl SsrfAgent { ("http://[::1]", "localhost IPv6"), ("http://0.0.0.0", "zero address"), ("http://169.254.169.254/latest/meta-data/", "AWS metadata"), - ( - "http://metadata.google.internal/", - "GCP metadata", - ), + ("http://metadata.google.internal/", "GCP metadata"), ("http://127.0.0.1:22", "SSH port probe"), ("http://127.0.0.1:3306", "MySQL port probe"), ("http://localhost/admin", "localhost admin"), @@ -91,10 +88,7 @@ impl DastAgent for SsrfAgent { .post(&endpoint.url) .form(&[(param.name.as_str(), payload)]) } else { - let test_url = format!( - "{}?{}={}", - endpoint.url, param.name, payload - ); + let test_url = format!("{}?{}={}", endpoint.url, param.name, payload); self.http.get(&test_url) }; @@ -133,10 +127,7 @@ impl DastAgent for SsrfAgent { String::new(), target_id.clone(), DastVulnType::Ssrf, - format!( - "SSRF ({technique}) via parameter '{}'", - param.name - ), + format!("SSRF ({technique}) via parameter '{}'", param.name), format!( "Server-side request forgery detected in parameter '{}' at {}. \ The application made a request to an internal resource ({}).", diff --git a/compliance-dast/src/agents/xss.rs b/compliance-dast/src/agents/xss.rs index 42e602c..af30649 100644 --- a/compliance-dast/src/agents/xss.rs +++ b/compliance-dast/src/agents/xss.rs @@ -17,26 +17,11 @@ impl XssAgent { fn payloads(&self) -> Vec<(&str, &str)> { vec![ ("", "basic script injection"), - ( - "", - "event handler injection", - ), - ( - "", - "svg event handler", - ), - ( - "javascript:alert(1)", - "javascript protocol", - ), - ( - "'\">", - "attribute breakout", - ), - ( - "", - "body event handler", - ), + ("", "event handler injection"), + ("", "svg event handler"), + ("javascript:alert(1)", "javascript protocol"), + ("'\">", "attribute breakout"), + ("", "body event handler"), ] } } @@ -65,10 +50,7 @@ impl DastAgent for XssAgent { for param in &endpoint.parameters { for (payload, technique) in self.payloads() { let test_url = if endpoint.method == "GET" { - format!( - "{}?{}={}", - endpoint.url, param.name, payload - ) + format!("{}?{}={}", endpoint.url, param.name, payload) } else { endpoint.url.clone() }; diff --git a/compliance-dast/src/crawler/mod.rs b/compliance-dast/src/crawler/mod.rs index 5cbb646..6b7c087 100644 --- a/compliance-dast/src/crawler/mod.rs +++ b/compliance-dast/src/crawler/mod.rs @@ -28,8 +28,8 @@ impl WebCrawler { base_url: &str, excluded_paths: &[String], ) -> Result, CoreError> { - let base = Url::parse(base_url) - .map_err(|e| CoreError::Dast(format!("Invalid base URL: {e}")))?; + let base = + Url::parse(base_url).map_err(|e| CoreError::Dast(format!("Invalid base URL: {e}")))?; let mut visited: HashSet = HashSet::new(); let mut endpoints: Vec = Vec::new(); @@ -95,12 +95,15 @@ impl WebCrawler { let document = Html::parse_document(&body); // Extract links - let link_selector = - Selector::parse("a[href]").unwrap_or_else(|_| Selector::parse("a").expect("valid selector")); + let link_selector = match Selector::parse("a[href]") { + Ok(s) => s, + Err(_) => continue, + }; for element in document.select(&link_selector) { if let Some(href) = element.value().attr("href") { if let Some(absolute_url) = self.resolve_url(&base, &url, href) { - if self.is_same_origin(&base, &absolute_url) && !visited.contains(&absolute_url) + if self.is_same_origin(&base, &absolute_url) + && !visited.contains(&absolute_url) { queue.push((absolute_url, depth + 1)); } @@ -109,18 +112,18 @@ impl WebCrawler { } // Extract forms - let form_selector = Selector::parse("form") - .unwrap_or_else(|_| Selector::parse("form").expect("valid selector")); - let input_selector = Selector::parse("input, select, textarea") - .unwrap_or_else(|_| Selector::parse("input").expect("valid selector")); + let form_selector = match Selector::parse("form") { + Ok(s) => s, + Err(_) => continue, + }; + let input_selector = match Selector::parse("input, select, textarea") { + Ok(s) => s, + Err(_) => continue, + }; for form in document.select(&form_selector) { let action = form.value().attr("action").unwrap_or(""); - let method = form - .value() - .attr("method") - .unwrap_or("GET") - .to_uppercase(); + let method = form.value().attr("method").unwrap_or("GET").to_uppercase(); let form_url = self .resolve_url(&base, &url, action) @@ -128,20 +131,12 @@ impl WebCrawler { let mut params = Vec::new(); for input in form.select(&input_selector) { - let name = input - .value() - .attr("name") - .unwrap_or("") - .to_string(); + let name = input.value().attr("name").unwrap_or("").to_string(); if name.is_empty() { continue; } - let input_type = input - .value() - .attr("type") - .unwrap_or("text") - .to_string(); + let input_type = input.value().attr("type").unwrap_or("text").to_string(); let location = if method == "GET" { "query".to_string() diff --git a/compliance-dast/src/orchestrator/state_machine.rs b/compliance-dast/src/orchestrator/state_machine.rs index d5c569f..63e5540 100644 --- a/compliance-dast/src/orchestrator/state_machine.rs +++ b/compliance-dast/src/orchestrator/state_machine.rs @@ -149,11 +149,8 @@ impl DastOrchestrator { let t2 = target.clone(); let c2 = context.clone(); let h2 = http.clone(); - let xss_handle = tokio::spawn(async move { - crate::agents::xss::XssAgent::new(h2) - .run(&t2, &c2) - .await - }); + let xss_handle = + tokio::spawn(async move { crate::agents::xss::XssAgent::new(h2).run(&t2, &c2).await }); let t3 = target.clone(); let c3 = context.clone(); @@ -167,11 +164,10 @@ impl DastOrchestrator { let t4 = target.clone(); let c4 = context.clone(); let h4 = http.clone(); - let ssrf_handle = tokio::spawn(async move { - crate::agents::ssrf::SsrfAgent::new(h4) - .run(&t4, &c4) - .await - }); + let ssrf_handle = + tokio::spawn( + async move { crate::agents::ssrf::SsrfAgent::new(h4).run(&t4, &c4).await }, + ); let t5 = target.clone(); let c5 = context.clone(); @@ -182,8 +178,13 @@ impl DastOrchestrator { .await }); - let handles: Vec, CoreError>>> = - vec![sqli_handle, xss_handle, auth_handle, ssrf_handle, api_handle]; + let handles: Vec, CoreError>>> = vec![ + sqli_handle, + xss_handle, + auth_handle, + ssrf_handle, + api_handle, + ]; let mut all_findings = Vec::new(); for handle in handles { diff --git a/compliance-dast/src/recon/mod.rs b/compliance-dast/src/recon/mod.rs index 68b7d4f..46d27e8 100644 --- a/compliance-dast/src/recon/mod.rs +++ b/compliance-dast/src/recon/mod.rs @@ -81,10 +81,9 @@ impl ReconAgent { ]; for header in &missing_security { if !headers.contains_key(*header) { - result.interesting_headers.insert( - format!("missing:{header}"), - "Not present".to_string(), - ); + result + .interesting_headers + .insert(format!("missing:{header}"), "Not present".to_string()); } } @@ -122,10 +121,10 @@ impl ReconAgent { let body_lower = body.to_lowercase(); for (tech, pattern) in &patterns { - if body_lower.contains(&pattern.to_lowercase()) { - if !result.technologies.contains(&tech.to_string()) { - result.technologies.push(tech.to_string()); - } + if body_lower.contains(&pattern.to_lowercase()) + && !result.technologies.contains(&tech.to_string()) + { + result.technologies.push(tech.to_string()); } } } diff --git a/compliance-graph/src/graph/chunking.rs b/compliance-graph/src/graph/chunking.rs new file mode 100644 index 0000000..ebbc5a0 --- /dev/null +++ b/compliance-graph/src/graph/chunking.rs @@ -0,0 +1,96 @@ +use std::path::Path; + +use compliance_core::models::graph::CodeNode; + +/// A chunk of code extracted from a source file, ready for embedding +#[derive(Debug, Clone)] +pub struct CodeChunk { + pub qualified_name: String, + pub kind: String, + pub file_path: String, + pub start_line: u32, + pub end_line: u32, + pub language: String, + pub content: String, + pub context_header: String, + pub token_estimate: u32, +} + +/// Extract embeddable code chunks from parsed CodeNodes. +/// +/// For each node, reads the corresponding source lines from disk, +/// builds a context header, and estimates tokens. +pub fn extract_chunks( + repo_path: &Path, + nodes: &[CodeNode], + max_chunk_tokens: u32, +) -> Vec { + let mut chunks = Vec::new(); + + for node in nodes { + let file = repo_path.join(&node.file_path); + let source = match std::fs::read_to_string(&file) { + Ok(s) => s, + Err(_) => continue, + }; + + let lines: Vec<&str> = source.lines().collect(); + let start = node.start_line.saturating_sub(1) as usize; + let end = (node.end_line as usize).min(lines.len()); + if start >= end { + continue; + } + + let content: String = lines[start..end].join("\n"); + + // Skip tiny chunks + if content.len() < 50 { + continue; + } + + // Estimate tokens (~4 chars per token) + let mut token_estimate = (content.len() / 4) as u32; + + // Truncate if too large + let final_content = if token_estimate > max_chunk_tokens { + let max_chars = (max_chunk_tokens as usize) * 4; + token_estimate = max_chunk_tokens; + content.chars().take(max_chars).collect() + } else { + content + }; + + // Build context header: file path + containing scope hint + let context_header = build_context_header( + &node.file_path, + &node.qualified_name, + &node.kind.to_string(), + ); + + chunks.push(CodeChunk { + qualified_name: node.qualified_name.clone(), + kind: node.kind.to_string(), + file_path: node.file_path.clone(), + start_line: node.start_line, + end_line: node.end_line, + language: node.language.clone(), + content: final_content, + context_header, + token_estimate, + }); + } + + chunks +} + +fn build_context_header(file_path: &str, qualified_name: &str, kind: &str) -> String { + // Extract containing module/class from qualified name + // e.g. "src/main.rs::MyStruct::my_method" → parent is "MyStruct" + let parts: Vec<&str> = qualified_name.split("::").collect(); + if parts.len() >= 2 { + let parent = parts[..parts.len() - 1].join("::"); + format!("// {file_path} | {kind} in {parent}") + } else { + format!("// {file_path} | {kind}") + } +} diff --git a/compliance-graph/src/graph/community.rs b/compliance-graph/src/graph/community.rs index b24d254..799d140 100644 --- a/compliance-graph/src/graph/community.rs +++ b/compliance-graph/src/graph/community.rs @@ -109,8 +109,8 @@ pub fn detect_communities(code_graph: &CodeGraph) -> u32 { let mut comm_remap: HashMap = HashMap::new(); let mut next_id: u32 = 0; for &c in community.values() { - if !comm_remap.contains_key(&c) { - comm_remap.insert(c, next_id); + if let std::collections::hash_map::Entry::Vacant(e) = comm_remap.entry(c) { + e.insert(next_id); next_id += 1; } } @@ -137,8 +137,7 @@ pub fn detect_communities(code_graph: &CodeGraph) -> u32 { /// Apply community assignments back to code nodes pub fn apply_communities(code_graph: &mut CodeGraph) -> u32 { - let count = detect_communities_with_assignment(code_graph); - count + detect_communities_with_assignment(code_graph) } /// Detect communities and write assignments into the nodes @@ -235,8 +234,8 @@ fn detect_communities_with_assignment(code_graph: &mut CodeGraph) -> u32 { let mut comm_remap: HashMap = HashMap::new(); let mut next_id: u32 = 0; for &c in community.values() { - if !comm_remap.contains_key(&c) { - comm_remap.insert(c, next_id); + if let std::collections::hash_map::Entry::Vacant(e) = comm_remap.entry(c) { + e.insert(next_id); next_id += 1; } } diff --git a/compliance-graph/src/graph/embedding_store.rs b/compliance-graph/src/graph/embedding_store.rs new file mode 100644 index 0000000..888cd81 --- /dev/null +++ b/compliance-graph/src/graph/embedding_store.rs @@ -0,0 +1,236 @@ +use compliance_core::error::CoreError; +use compliance_core::models::embedding::{CodeEmbedding, EmbeddingBuildRun, EmbeddingBuildStatus}; +use futures_util::TryStreamExt; +use mongodb::bson::doc; +use mongodb::{Collection, Database, IndexModel}; +use tracing::info; + +/// MongoDB persistence layer for code embeddings and vector search +pub struct EmbeddingStore { + embeddings: Collection, + builds: Collection, +} + +impl EmbeddingStore { + pub fn new(db: &Database) -> Self { + Self { + embeddings: db.collection("code_embeddings"), + builds: db.collection("embedding_builds"), + } + } + + /// Create standard indexes. NOTE: The Atlas Vector Search index must be + /// created via the Atlas UI or CLI with the following definition: + /// ```json + /// { + /// "fields": [ + /// { "type": "vector", "path": "embedding", "numDimensions": 1536, "similarity": "cosine" }, + /// { "type": "filter", "path": "repo_id" } + /// ] + /// } + /// ``` + pub async fn ensure_indexes(&self) -> Result<(), CoreError> { + self.embeddings + .create_index( + IndexModel::builder() + .keys(doc! { "repo_id": 1, "graph_build_id": 1 }) + .build(), + ) + .await?; + + self.builds + .create_index( + IndexModel::builder() + .keys(doc! { "repo_id": 1, "started_at": -1 }) + .build(), + ) + .await?; + + Ok(()) + } + + /// Delete all embeddings for a repository + pub async fn delete_repo_embeddings(&self, repo_id: &str) -> Result { + let result = self + .embeddings + .delete_many(doc! { "repo_id": repo_id }) + .await?; + info!( + "Deleted {} embeddings for repo {repo_id}", + result.deleted_count + ); + Ok(result.deleted_count) + } + + /// Store embeddings in batches of 500 + pub async fn store_embeddings(&self, embeddings: &[CodeEmbedding]) -> Result { + let mut total_inserted = 0u64; + for batch in embeddings.chunks(500) { + let result = self.embeddings.insert_many(batch).await?; + total_inserted += result.inserted_ids.len() as u64; + } + info!("Stored {total_inserted} embeddings"); + Ok(total_inserted) + } + + /// Store a new build run + pub async fn store_build(&self, build: &EmbeddingBuildRun) -> Result<(), CoreError> { + self.builds.insert_one(build).await?; + Ok(()) + } + + /// Update an existing build run + pub async fn update_build( + &self, + repo_id: &str, + graph_build_id: &str, + status: EmbeddingBuildStatus, + embedded_chunks: u32, + error_message: Option, + ) -> Result<(), CoreError> { + let mut update = doc! { + "$set": { + "status": mongodb::bson::to_bson(&status).unwrap_or_default(), + "embedded_chunks": embedded_chunks as i64, + } + }; + + if status == EmbeddingBuildStatus::Completed || status == EmbeddingBuildStatus::Failed { + if let Ok(set_doc) = update.get_document_mut("$set") { + set_doc.insert("completed_at", mongodb::bson::DateTime::now()); + } + } + + if let Some(msg) = error_message { + if let Ok(set_doc) = update.get_document_mut("$set") { + set_doc.insert("error_message", msg); + } + } + + self.builds + .update_one( + doc! { "repo_id": repo_id, "graph_build_id": graph_build_id }, + update, + ) + .await?; + Ok(()) + } + + /// Get the latest embedding build for a repository + pub async fn get_latest_build( + &self, + repo_id: &str, + ) -> Result, CoreError> { + Ok(self + .builds + .find_one(doc! { "repo_id": repo_id }) + .sort(doc! { "started_at": -1 }) + .await?) + } + + /// Perform vector search. Tries Atlas $vectorSearch first, falls back to + /// brute-force cosine similarity for local MongoDB instances. + pub async fn vector_search( + &self, + repo_id: &str, + query_embedding: Vec, + limit: u32, + min_score: f64, + ) -> Result, CoreError> { + match self + .atlas_vector_search(repo_id, &query_embedding, limit, min_score) + .await + { + Ok(results) => Ok(results), + Err(e) => { + info!( + "Atlas $vectorSearch unavailable ({e}), falling back to brute-force cosine similarity" + ); + self.bruteforce_vector_search(repo_id, &query_embedding, limit, min_score) + .await + } + } + } + + /// Atlas $vectorSearch aggregation stage (requires Atlas Vector Search index) + async fn atlas_vector_search( + &self, + repo_id: &str, + query_embedding: &[f64], + limit: u32, + min_score: f64, + ) -> Result, CoreError> { + use mongodb::bson::{Bson, Document}; + + let pipeline = vec![ + doc! { + "$vectorSearch": { + "index": "embedding_vector_index", + "path": "embedding", + "queryVector": query_embedding.iter().map(|&v| Bson::Double(v)).collect::>(), + "numCandidates": (limit * 10) as i64, + "limit": limit as i64, + "filter": { "repo_id": repo_id }, + } + }, + doc! { + "$addFields": { + "search_score": { "$meta": "vectorSearchScore" } + } + }, + doc! { + "$match": { + "search_score": { "$gte": min_score } + } + }, + ]; + + let mut cursor = self.embeddings.aggregate(pipeline).await?; + + let mut results = Vec::new(); + while let Some(doc) = cursor.try_next().await? { + let score = doc.get_f64("search_score").unwrap_or(0.0); + let mut clean_doc: Document = doc; + clean_doc.remove("search_score"); + if let Ok(embedding) = mongodb::bson::from_document::(clean_doc) { + results.push((embedding, score)); + } + } + + Ok(results) + } + + /// Brute-force cosine similarity fallback for local MongoDB without Atlas + async fn bruteforce_vector_search( + &self, + repo_id: &str, + query_embedding: &[f64], + limit: u32, + min_score: f64, + ) -> Result, CoreError> { + let mut cursor = self.embeddings.find(doc! { "repo_id": repo_id }).await?; + + let query_norm = dot(query_embedding, query_embedding).sqrt(); + let mut scored: Vec<(CodeEmbedding, f64)> = Vec::new(); + + while let Some(emb) = cursor.try_next().await? { + let doc_norm = dot(&emb.embedding, &emb.embedding).sqrt(); + let score = if query_norm > 0.0 && doc_norm > 0.0 { + dot(query_embedding, &emb.embedding) / (query_norm * doc_norm) + } else { + 0.0 + }; + if score >= min_score { + scored.push((emb, score)); + } + } + + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(limit as usize); + Ok(scored) + } +} + +fn dot(a: &[f64], b: &[f64]) -> f64 { + a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() +} diff --git a/compliance-graph/src/graph/engine.rs b/compliance-graph/src/graph/engine.rs index b11af7f..5ec71c3 100644 --- a/compliance-graph/src/graph/engine.rs +++ b/compliance-graph/src/graph/engine.rs @@ -133,10 +133,10 @@ impl GraphEngine { } /// Try to resolve an edge target to a known node - fn resolve_edge_target<'a>( + fn resolve_edge_target( &self, target: &str, - node_map: &'a HashMap, + node_map: &HashMap, ) -> Option { // Direct match if let Some(idx) = node_map.get(target) { diff --git a/compliance-graph/src/graph/impact.rs b/compliance-graph/src/graph/impact.rs index de8eb22..bd14543 100644 --- a/compliance-graph/src/graph/impact.rs +++ b/compliance-graph/src/graph/impact.rs @@ -26,8 +26,11 @@ impl<'a> ImpactAnalyzer<'a> { file_path: &str, line_number: Option, ) -> ImpactAnalysis { - let mut analysis = - ImpactAnalysis::new(repo_id.to_string(), finding_id.to_string(), graph_build_id.to_string()); + let mut analysis = ImpactAnalysis::new( + repo_id.to_string(), + finding_id.to_string(), + graph_build_id.to_string(), + ); // Find the node containing the finding let target_node = self.find_node_at_location(file_path, line_number); @@ -97,7 +100,11 @@ impl<'a> ImpactAnalyzer<'a> { } /// Find the graph node at a given file/line location - fn find_node_at_location(&self, file_path: &str, line_number: Option) -> Option { + fn find_node_at_location( + &self, + file_path: &str, + line_number: Option, + ) -> Option { let mut best: Option<(NodeIndex, u32)> = None; // (index, line_span) for node in &self.code_graph.nodes { @@ -166,12 +173,7 @@ impl<'a> ImpactAnalyzer<'a> { } /// Find a path from source to target (BFS, limited depth) - fn find_path( - &self, - from: NodeIndex, - to: NodeIndex, - max_depth: usize, - ) -> Option> { + fn find_path(&self, from: NodeIndex, to: NodeIndex, max_depth: usize) -> Option> { let mut visited = HashSet::new(); let mut queue: VecDeque<(NodeIndex, Vec)> = VecDeque::new(); queue.push_back((from, vec![from])); @@ -209,7 +211,10 @@ impl<'a> ImpactAnalyzer<'a> { None } - fn get_node_by_index(&self, idx: NodeIndex) -> Option<&compliance_core::models::graph::CodeNode> { + fn get_node_by_index( + &self, + idx: NodeIndex, + ) -> Option<&compliance_core::models::graph::CodeNode> { let target_gi = idx.index() as u32; self.code_graph .nodes diff --git a/compliance-graph/src/graph/mod.rs b/compliance-graph/src/graph/mod.rs index f66238d..caaac75 100644 --- a/compliance-graph/src/graph/mod.rs +++ b/compliance-graph/src/graph/mod.rs @@ -1,4 +1,6 @@ +pub mod chunking; pub mod community; +pub mod embedding_store; pub mod engine; pub mod impact; pub mod persistence; diff --git a/compliance-graph/src/graph/persistence.rs b/compliance-graph/src/graph/persistence.rs index 65c7b10..7ac2017 100644 --- a/compliance-graph/src/graph/persistence.rs +++ b/compliance-graph/src/graph/persistence.rs @@ -211,8 +211,6 @@ impl GraphStore { repo_id: &str, graph_build_id: &str, ) -> Result, CoreError> { - - let filter = doc! { "repo_id": repo_id, "graph_build_id": graph_build_id, diff --git a/compliance-graph/src/lib.rs b/compliance-graph/src/lib.rs index 8945bb7..bceea55 100644 --- a/compliance-graph/src/lib.rs +++ b/compliance-graph/src/lib.rs @@ -1,3 +1,6 @@ +#![allow(clippy::only_used_in_recursion)] +#![allow(clippy::too_many_arguments)] + pub mod graph; pub mod parsers; pub mod search; diff --git a/compliance-graph/src/parsers/javascript.rs b/compliance-graph/src/parsers/javascript.rs index bfe66d8..e8637e8 100644 --- a/compliance-graph/src/parsers/javascript.rs +++ b/compliance-graph/src/parsers/javascript.rs @@ -7,6 +7,12 @@ use tree_sitter::{Node, Parser}; pub struct JavaScriptParser; +impl Default for JavaScriptParser { + fn default() -> Self { + Self::new() + } +} + impl JavaScriptParser { pub fn new() -> Self { Self @@ -51,7 +57,13 @@ impl JavaScriptParser { if let Some(body) = node.child_by_field_name("body") { self.extract_calls( - body, source, file_path, repo_id, graph_build_id, &qualified, output, + body, + source, + file_path, + repo_id, + graph_build_id, + &qualified, + output, ); } } @@ -97,7 +109,12 @@ impl JavaScriptParser { if let Some(body) = node.child_by_field_name("body") { self.walk_children( - body, source, file_path, repo_id, graph_build_id, Some(&qualified), + body, + source, + file_path, + repo_id, + graph_build_id, + Some(&qualified), output, ); } @@ -130,7 +147,13 @@ impl JavaScriptParser { if let Some(body) = node.child_by_field_name("body") { self.extract_calls( - body, source, file_path, repo_id, graph_build_id, &qualified, output, + body, + source, + file_path, + repo_id, + graph_build_id, + &qualified, + output, ); } } @@ -138,7 +161,13 @@ impl JavaScriptParser { // Arrow functions assigned to variables: const foo = () => {} "lexical_declaration" | "variable_declaration" => { self.extract_arrow_functions( - node, source, file_path, repo_id, graph_build_id, parent_qualified, output, + node, + source, + file_path, + repo_id, + graph_build_id, + parent_qualified, + output, ); } "import_statement" => { @@ -183,7 +212,13 @@ impl JavaScriptParser { let mut cursor = node.walk(); for child in node.children(&mut cursor) { self.walk_tree( - child, source, file_path, repo_id, graph_build_id, parent_qualified, output, + child, + source, + file_path, + repo_id, + graph_build_id, + parent_qualified, + output, ); } } @@ -217,7 +252,13 @@ impl JavaScriptParser { let mut cursor = node.walk(); for child in node.children(&mut cursor) { self.extract_calls( - child, source, file_path, repo_id, graph_build_id, caller_qualified, output, + child, + source, + file_path, + repo_id, + graph_build_id, + caller_qualified, + output, ); } } @@ -263,7 +304,12 @@ impl JavaScriptParser { if let Some(body) = value_n.child_by_field_name("body") { self.extract_calls( - body, source, file_path, repo_id, graph_build_id, &qualified, + body, + source, + file_path, + repo_id, + graph_build_id, + &qualified, output, ); } diff --git a/compliance-graph/src/parsers/python.rs b/compliance-graph/src/parsers/python.rs index bc0af2f..d11c80a 100644 --- a/compliance-graph/src/parsers/python.rs +++ b/compliance-graph/src/parsers/python.rs @@ -7,6 +7,12 @@ use tree_sitter::{Node, Parser}; pub struct PythonParser; +impl Default for PythonParser { + fn default() -> Self { + Self::new() + } +} + impl PythonParser { pub fn new() -> Self { Self diff --git a/compliance-graph/src/parsers/registry.rs b/compliance-graph/src/parsers/registry.rs index d5f582c..7f3dcc7 100644 --- a/compliance-graph/src/parsers/registry.rs +++ b/compliance-graph/src/parsers/registry.rs @@ -57,10 +57,7 @@ impl ParserRegistry { repo_id: &str, graph_build_id: &str, ) -> Result, CoreError> { - let ext = file_path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or(""); + let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or(""); let parser_idx = match self.extension_map.get(ext) { Some(idx) => *idx, @@ -89,7 +86,15 @@ impl ParserRegistry { let mut combined = ParseOutput::default(); let mut node_count: u32 = 0; - self.walk_directory(dir, dir, repo_id, graph_build_id, max_nodes, &mut node_count, &mut combined)?; + self.walk_directory( + dir, + dir, + repo_id, + graph_build_id, + max_nodes, + &mut node_count, + &mut combined, + )?; info!( nodes = combined.nodes.len(), @@ -162,8 +167,7 @@ impl ParserRegistry { Err(_) => continue, // Skip binary/unreadable files }; - if let Some(output) = self.parse_file(rel_path, &source, repo_id, graph_build_id)? - { + if let Some(output) = self.parse_file(rel_path, &source, repo_id, graph_build_id)? { *node_count += output.nodes.len() as u32; combined.nodes.extend(output.nodes); combined.edges.extend(output.edges); diff --git a/compliance-graph/src/parsers/rust_parser.rs b/compliance-graph/src/parsers/rust_parser.rs index 2aad595..391a7d4 100644 --- a/compliance-graph/src/parsers/rust_parser.rs +++ b/compliance-graph/src/parsers/rust_parser.rs @@ -7,6 +7,12 @@ use tree_sitter::{Node, Parser}; pub struct RustParser; +impl Default for RustParser { + fn default() -> Self { + Self::new() + } +} + impl RustParser { pub fn new() -> Self { Self @@ -196,9 +202,7 @@ impl RustParser { id: None, repo_id: repo_id.to_string(), graph_build_id: graph_build_id.to_string(), - source: parent_qualified - .unwrap_or(file_path) - .to_string(), + source: parent_qualified.unwrap_or(file_path).to_string(), target: path, kind: CodeEdgeKind::Imports, file_path: file_path.to_string(), @@ -354,10 +358,7 @@ impl RustParser { fn extract_use_path(&self, use_text: &str) -> Option { // "use foo::bar::baz;" -> "foo::bar::baz" - let trimmed = use_text - .strip_prefix("use ")? - .trim_end_matches(';') - .trim(); + let trimmed = use_text.strip_prefix("use ")?.trim_end_matches(';').trim(); Some(trimmed.to_string()) } } diff --git a/compliance-graph/src/parsers/typescript.rs b/compliance-graph/src/parsers/typescript.rs index 2e3d0e9..183b451 100644 --- a/compliance-graph/src/parsers/typescript.rs +++ b/compliance-graph/src/parsers/typescript.rs @@ -7,6 +7,12 @@ use tree_sitter::{Node, Parser}; pub struct TypeScriptParser; +impl Default for TypeScriptParser { + fn default() -> Self { + Self::new() + } +} + impl TypeScriptParser { pub fn new() -> Self { Self @@ -49,7 +55,13 @@ impl TypeScriptParser { if let Some(body) = node.child_by_field_name("body") { self.extract_calls( - body, source, file_path, repo_id, graph_build_id, &qualified, output, + body, + source, + file_path, + repo_id, + graph_build_id, + &qualified, + output, ); } } @@ -80,12 +92,23 @@ impl TypeScriptParser { // Heritage clause (extends/implements) self.extract_heritage( - &node, source, file_path, repo_id, graph_build_id, &qualified, output, + &node, + source, + file_path, + repo_id, + graph_build_id, + &qualified, + output, ); if let Some(body) = node.child_by_field_name("body") { self.walk_children( - body, source, file_path, repo_id, graph_build_id, Some(&qualified), + body, + source, + file_path, + repo_id, + graph_build_id, + Some(&qualified), output, ); } @@ -143,14 +166,26 @@ impl TypeScriptParser { if let Some(body) = node.child_by_field_name("body") { self.extract_calls( - body, source, file_path, repo_id, graph_build_id, &qualified, output, + body, + source, + file_path, + repo_id, + graph_build_id, + &qualified, + output, ); } } } "lexical_declaration" | "variable_declaration" => { self.extract_arrow_functions( - node, source, file_path, repo_id, graph_build_id, parent_qualified, output, + node, + source, + file_path, + repo_id, + graph_build_id, + parent_qualified, + output, ); } "import_statement" => { @@ -172,7 +207,13 @@ impl TypeScriptParser { } self.walk_children( - node, source, file_path, repo_id, graph_build_id, parent_qualified, output, + node, + source, + file_path, + repo_id, + graph_build_id, + parent_qualified, + output, ); } @@ -189,7 +230,13 @@ impl TypeScriptParser { let mut cursor = node.walk(); for child in node.children(&mut cursor) { self.walk_tree( - child, source, file_path, repo_id, graph_build_id, parent_qualified, output, + child, + source, + file_path, + repo_id, + graph_build_id, + parent_qualified, + output, ); } } @@ -223,7 +270,13 @@ impl TypeScriptParser { let mut cursor = node.walk(); for child in node.children(&mut cursor) { self.extract_calls( - child, source, file_path, repo_id, graph_build_id, caller_qualified, output, + child, + source, + file_path, + repo_id, + graph_build_id, + caller_qualified, + output, ); } } @@ -269,7 +322,12 @@ impl TypeScriptParser { if let Some(body) = value_n.child_by_field_name("body") { self.extract_calls( - body, source, file_path, repo_id, graph_build_id, &qualified, + body, + source, + file_path, + repo_id, + graph_build_id, + &qualified, output, ); } diff --git a/compliance-graph/src/search/index.rs b/compliance-graph/src/search/index.rs index 435685a..c5e6534 100644 --- a/compliance-graph/src/search/index.rs +++ b/compliance-graph/src/search/index.rs @@ -89,8 +89,10 @@ impl SymbolIndex { .map_err(|e| CoreError::Graph(format!("Failed to create reader: {e}")))?; let searcher = reader.searcher(); - let query_parser = - QueryParser::for_index(&self.index, vec![self.name_field, self.qualified_name_field]); + let query_parser = QueryParser::for_index( + &self.index, + vec![self.name_field, self.qualified_name_field], + ); let query = query_parser .parse_query(query_str)