From 23ba52276bf0aaadbccbb95a9eaf4b9b7eb1de05 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 9 Mar 2026 11:05:31 +0100 Subject: [PATCH 1/6] feat: add new scanners, enhanced triage, findings refinement, and deployment tooling - Add gitleaks secret detection, lint scanning (clippy/eslint/ruff), and LLM code review scanners - Enhance LLM triage with multi-action support (confirm/downgrade/upgrade/dismiss), surrounding code context, and file-path classification confidence adjustment - Add text search, column sorting, and bulk status update to findings dashboard - Fix finding detail page status refresh and add developer feedback field - Fix BSON DateTime deserialization across all models with shared serde helpers - Add scan progress spinner with polling to repositories page - Batch OSV.dev queries to avoid "Too many queries" errors - Add gitleaks, semgrep, and ruff to Dockerfile.agent for deployment Co-Authored-By: Claude Opus 4.6 --- Dockerfile.agent | 12 +- compliance-agent/src/api/handlers/mod.rs | 91 ++++- compliance-agent/src/api/routes.rs | 8 + compliance-agent/src/llm/mod.rs | 1 + compliance-agent/src/llm/review_prompts.rs | 77 ++++ compliance-agent/src/llm/triage.rs | 185 ++++++++- compliance-agent/src/pipeline/code_review.rs | 186 +++++++++ compliance-agent/src/pipeline/cve.rs | 83 ++-- compliance-agent/src/pipeline/git.rs | 63 +++ compliance-agent/src/pipeline/gitleaks.rs | 117 ++++++ compliance-agent/src/pipeline/lint.rs | 361 ++++++++++++++++++ compliance-agent/src/pipeline/mod.rs | 3 + compliance-agent/src/pipeline/orchestrator.rs | 32 ++ compliance-core/src/models/cve.rs | 1 + compliance-core/src/models/dast.rs | 5 + compliance-core/src/models/finding.rs | 10 + compliance-core/src/models/graph.rs | 3 + compliance-core/src/models/issue.rs | 2 + compliance-core/src/models/mcp.rs | 2 + compliance-core/src/models/mod.rs | 1 + compliance-core/src/models/repository.rs | 27 +- compliance-core/src/models/sbom.rs | 2 + compliance-core/src/models/scan.rs | 11 + compliance-core/src/models/serde_helpers.rs | 70 ++++ compliance-dashboard/assets/main.css | 18 + .../src/infrastructure/findings.rs | 56 +++ .../src/infrastructure/repositories.rs | 32 ++ .../src/pages/finding_detail.rs | 40 +- compliance-dashboard/src/pages/findings.rs | 148 ++++++- .../src/pages/repositories.rs | 48 ++- compliance-mcp/src/tools/findings.rs | 2 +- 31 files changed, 1602 insertions(+), 95 deletions(-) create mode 100644 compliance-agent/src/llm/review_prompts.rs create mode 100644 compliance-agent/src/pipeline/code_review.rs create mode 100644 compliance-agent/src/pipeline/gitleaks.rs create mode 100644 compliance-agent/src/pipeline/lint.rs create mode 100644 compliance-core/src/models/serde_helpers.rs diff --git a/Dockerfile.agent b/Dockerfile.agent index f42e05a..054c35b 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -5,11 +5,21 @@ COPY . . RUN cargo build --release -p compliance-agent FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl python3 python3-pip && 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 +# Install gitleaks for secret detection +RUN curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \ + | tar -xz -C /usr/local/bin gitleaks + +# Install semgrep for static analysis +RUN pip3 install --break-system-packages semgrep + +# Install ruff for Python linting +RUN pip3 install --break-system-packages ruff + COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent EXPOSE 3001 3002 diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index a3b7909..183c14c 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -41,6 +41,12 @@ pub struct FindingsFilter { pub scan_type: Option, #[serde(default)] pub status: Option, + #[serde(default)] + pub q: Option, + #[serde(default)] + pub sort_by: Option, + #[serde(default)] + pub sort_order: Option, #[serde(default = "default_page")] pub page: u64, #[serde(default = "default_limit")] @@ -91,6 +97,17 @@ pub struct UpdateStatusRequest { pub status: String, } +#[derive(Deserialize)] +pub struct BulkUpdateStatusRequest { + pub ids: Vec, + pub status: String, +} + +#[derive(Deserialize)] +pub struct UpdateFeedbackRequest { + pub feedback: String, +} + #[derive(Deserialize)] pub struct SbomFilter { #[serde(default)] @@ -367,6 +384,29 @@ pub async fn list_findings( if let Some(status) = &filter.status { query.insert("status", status); } + // Text search across title, description, file_path, rule_id + if let Some(q) = &filter.q { + if !q.is_empty() { + let regex = doc! { "$regex": q, "$options": "i" }; + query.insert( + "$or", + mongodb::bson::bson!([ + { "title": regex.clone() }, + { "description": regex.clone() }, + { "file_path": regex.clone() }, + { "rule_id": regex }, + ]), + ); + } + } + + // Dynamic sort + let sort_field = filter.sort_by.as_deref().unwrap_or("created_at"); + let sort_dir: i32 = match filter.sort_order.as_deref() { + Some("asc") => 1, + _ => -1, + }; + let sort_doc = doc! { sort_field: sort_dir }; let skip = (filter.page.saturating_sub(1)) * filter.limit as u64; let total = db @@ -378,7 +418,7 @@ pub async fn list_findings( let findings = match db .findings() .find(query) - .sort(doc! { "created_at": -1 }) + .sort(sort_doc) .skip(skip) .limit(filter.limit) .await @@ -434,6 +474,55 @@ pub async fn update_finding_status( Ok(Json(serde_json::json!({ "status": "updated" }))) } +pub async fn bulk_update_finding_status( + Extension(agent): AgentExt, + Json(req): Json, +) -> Result, StatusCode> { + let oids: Vec = req + .ids + .iter() + .filter_map(|id| mongodb::bson::oid::ObjectId::parse_str(id).ok()) + .collect(); + + if oids.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + let result = agent + .db + .findings() + .update_many( + doc! { "_id": { "$in": oids } }, + doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } }, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json( + serde_json::json!({ "status": "updated", "modified_count": result.modified_count }), + )) +} + +pub async fn update_finding_feedback( + Extension(agent): AgentExt, + Path(id): Path, + Json(req): Json, +) -> Result, StatusCode> { + let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; + + agent + .db + .findings() + .update_one( + doc! { "_id": oid }, + doc! { "$set": { "developer_feedback": &req.feedback, "updated_at": mongodb::bson::DateTime::now() } }, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(serde_json::json!({ "status": "updated" }))) +} + pub async fn list_sbom( Extension(agent): AgentExt, Query(filter): Query, diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index 78fdc3d..cd5edb7 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -23,6 +23,14 @@ pub fn build_router() -> Router { "/api/v1/findings/{id}/status", patch(handlers::update_finding_status), ) + .route( + "/api/v1/findings/bulk-status", + patch(handlers::bulk_update_finding_status), + ) + .route( + "/api/v1/findings/{id}/feedback", + patch(handlers::update_finding_feedback), + ) .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)) diff --git a/compliance-agent/src/llm/mod.rs b/compliance-agent/src/llm/mod.rs index 9259b95..17e8550 100644 --- a/compliance-agent/src/llm/mod.rs +++ b/compliance-agent/src/llm/mod.rs @@ -5,6 +5,7 @@ pub mod descriptions; pub mod fixes; #[allow(dead_code)] pub mod pr_review; +pub mod review_prompts; pub mod triage; pub use client::LlmClient; diff --git a/compliance-agent/src/llm/review_prompts.rs b/compliance-agent/src/llm/review_prompts.rs new file mode 100644 index 0000000..d3b4193 --- /dev/null +++ b/compliance-agent/src/llm/review_prompts.rs @@ -0,0 +1,77 @@ +// System prompts for multi-pass LLM code review. +// Each pass focuses on a different aspect to avoid overloading a single prompt. + +pub const LOGIC_REVIEW_PROMPT: &str = r#"You are a senior software engineer reviewing code changes. Focus ONLY on logic and correctness issues. + +Look for: +- Off-by-one errors, wrong comparisons, missing edge cases +- Incorrect control flow (unreachable code, missing returns, wrong loop conditions) +- Race conditions or concurrency bugs +- Resource leaks (unclosed handles, missing cleanup) +- Wrong variable used (copy-paste errors) +- Incorrect error handling (swallowed errors, wrong error type) + +Ignore: style, naming, formatting, documentation, minor improvements. + +For each issue found, respond with a JSON array: +[{"title": "...", "description": "...", "severity": "high|medium|low", "file": "...", "line": N, "suggestion": "..."}] + +If no issues found, respond with: []"#; + +pub const SECURITY_REVIEW_PROMPT: &str = r#"You are a security engineer reviewing code changes. Focus ONLY on security vulnerabilities. + +Look for: +- Injection vulnerabilities (SQL, command, XSS, template injection) +- Authentication/authorization bypasses +- Sensitive data exposure (logging secrets, hardcoded credentials) +- Insecure cryptography (weak algorithms, predictable randomness) +- Path traversal, SSRF, open redirects +- Unsafe deserialization +- Missing input validation at trust boundaries + +Ignore: code style, performance, general quality. + +For each issue found, respond with a JSON array: +[{"title": "...", "description": "...", "severity": "critical|high|medium", "file": "...", "line": N, "cwe": "CWE-XXX", "suggestion": "..."}] + +If no issues found, respond with: []"#; + +pub const CONVENTION_REVIEW_PROMPT: &str = r#"You are a code reviewer checking adherence to project conventions. Focus ONLY on patterns that indicate likely bugs or maintenance problems. + +Look for: +- Inconsistent error handling patterns within the same module +- Public API that doesn't follow the project's established patterns +- Missing or incorrect type annotations that could cause runtime issues +- Anti-patterns specific to the language (e.g. unwrap in Rust library code, any in TypeScript) + +Do NOT report: minor style preferences, documentation gaps, formatting. +Only report issues with HIGH confidence that they deviate from the visible codebase conventions. + +For each issue found, respond with a JSON array: +[{"title": "...", "description": "...", "severity": "medium|low", "file": "...", "line": N, "suggestion": "..."}] + +If no issues found, respond with: []"#; + +pub const COMPLEXITY_REVIEW_PROMPT: &str = r#"You are reviewing code changes for excessive complexity that could lead to bugs. + +Look for: +- Functions over 50 lines that should be decomposed +- Deeply nested control flow (4+ levels) +- Complex boolean expressions that are hard to reason about +- Functions with 5+ parameters +- Code duplication within the changed files + +Only report complexity issues that are HIGH risk for future bugs. Ignore acceptable complexity in configuration, CLI argument parsing, or generated code. + +For each issue found, respond with a JSON array: +[{"title": "...", "description": "...", "severity": "medium|low", "file": "...", "line": N, "suggestion": "..."}] + +If no issues found, respond with: []"#; + +/// All review types with their prompts +pub const REVIEW_PASSES: &[(&str, &str)] = &[ + ("logic", LOGIC_REVIEW_PROMPT), + ("security", SECURITY_REVIEW_PROMPT), + ("convention", CONVENTION_REVIEW_PROMPT), + ("complexity", COMPLEXITY_REVIEW_PROMPT), +]; diff --git a/compliance-agent/src/llm/triage.rs b/compliance-agent/src/llm/triage.rs index 491f19d..e641bcb 100644 --- a/compliance-agent/src/llm/triage.rs +++ b/compliance-agent/src/llm/triage.rs @@ -5,13 +5,22 @@ use compliance_core::models::{Finding, FindingStatus}; use crate::llm::LlmClient; use crate::pipeline::orchestrator::GraphContext; -const TRIAGE_SYSTEM_PROMPT: &str = r#"You are a security finding triage expert. Analyze the following security finding and determine: -1. Is this a true positive? (yes/no) -2. Confidence score (0-10, where 10 is highest confidence this is a real issue) -3. Brief remediation suggestion (1-2 sentences) +const TRIAGE_SYSTEM_PROMPT: &str = r#"You are a security finding triage expert. Analyze the following security finding with its code context and determine the appropriate action. + +Actions: +- "confirm": The finding is a true positive at the reported severity. Keep as-is. +- "downgrade": The finding is real but over-reported. Lower severity recommended. +- "upgrade": The finding is under-reported. Higher severity recommended. +- "dismiss": The finding is a false positive. Should be removed. + +Consider: +- Is the code in a test, example, or generated file? (lower confidence for test code) +- Does the surrounding code context confirm or refute the finding? +- Is the finding actionable by a developer? +- Would a real attacker be able to exploit this? Respond in JSON format: -{"true_positive": true/false, "confidence": N, "remediation": "..."}"#; +{"action": "confirm|downgrade|upgrade|dismiss", "confidence": 0-10, "rationale": "brief explanation", "remediation": "optional fix suggestion"}"#; pub async fn triage_findings( llm: &Arc, @@ -21,8 +30,10 @@ pub async fn triage_findings( let mut passed = 0; for finding in findings.iter_mut() { + let file_classification = classify_file_path(finding.file_path.as_deref()); + let mut user_prompt = format!( - "Scanner: {}\nRule: {}\nSeverity: {}\nTitle: {}\nDescription: {}\nFile: {}\nLine: {}\nCode: {}", + "Scanner: {}\nRule: {}\nSeverity: {}\nTitle: {}\nDescription: {}\nFile: {}\nLine: {}\nCode: {}\nFile classification: {}", finding.scanner, finding.rule_id.as_deref().unwrap_or("N/A"), finding.severity, @@ -31,8 +42,14 @@ pub async fn triage_findings( finding.file_path.as_deref().unwrap_or("N/A"), finding.line_number.map(|n| n.to_string()).unwrap_or_else(|| "N/A".to_string()), finding.code_snippet.as_deref().unwrap_or("N/A"), + file_classification, ); + // Enrich with surrounding code context if possible + if let Some(context) = read_surrounding_context(finding) { + user_prompt.push_str(&format!("\n\n--- Surrounding Code (50 lines) ---\n{context}")); + } + // Enrich with graph context if available if let Some(ctx) = graph_context { if let Some(impact) = ctx @@ -69,32 +86,54 @@ pub async fn triage_findings( .await { Ok(response) => { - // Strip markdown code fences if present (e.g. ```json ... ```) let cleaned = response.trim(); let cleaned = if cleaned.starts_with("```") { - let inner = cleaned + cleaned .trim_start_matches("```json") .trim_start_matches("```") .trim_end_matches("```") - .trim(); - inner + .trim() } else { cleaned }; if let Ok(result) = serde_json::from_str::(cleaned) { - finding.confidence = Some(result.confidence); + // Apply file-path confidence adjustment + let adjusted_confidence = adjust_confidence(result.confidence, &file_classification); + finding.confidence = Some(adjusted_confidence); + finding.triage_action = Some(result.action.clone()); + finding.triage_rationale = Some(result.rationale); + if let Some(remediation) = result.remediation { finding.remediation = Some(remediation); } - if result.confidence >= 3.0 { - finding.status = FindingStatus::Triaged; - passed += 1; - } else { - finding.status = FindingStatus::FalsePositive; + match result.action.as_str() { + "dismiss" => { + finding.status = FindingStatus::FalsePositive; + } + "downgrade" => { + // Downgrade severity by one level + finding.severity = downgrade_severity(&finding.severity); + finding.status = FindingStatus::Triaged; + passed += 1; + } + "upgrade" => { + finding.severity = upgrade_severity(&finding.severity); + finding.status = FindingStatus::Triaged; + passed += 1; + } + _ => { + // "confirm" or unknown — keep as-is + if adjusted_confidence >= 3.0 { + finding.status = FindingStatus::Triaged; + passed += 1; + } else { + finding.status = FindingStatus::FalsePositive; + } + } } } else { - // If LLM response doesn't parse, keep the finding + // Parse failure — keep the finding finding.status = FindingStatus::Triaged; passed += 1; tracing::warn!( @@ -117,12 +156,118 @@ pub async fn triage_findings( passed } +/// Read ~50 lines of surrounding code from the file at the finding's location +fn read_surrounding_context(finding: &Finding) -> Option { + let file_path = finding.file_path.as_deref()?; + let line = finding.line_number? as usize; + + // Try to read the file — this works because the repo is cloned locally + let content = std::fs::read_to_string(file_path).ok()?; + let lines: Vec<&str> = content.lines().collect(); + + let start = line.saturating_sub(25); + let end = (line + 25).min(lines.len()); + + Some( + lines[start..end] + .iter() + .enumerate() + .map(|(i, l)| format!("{:>4} | {}", start + i + 1, l)) + .collect::>() + .join("\n"), + ) +} + +/// Classify a file path to inform triage confidence adjustment +fn classify_file_path(path: Option<&str>) -> String { + let path = match path { + Some(p) => p.to_lowercase(), + None => return "unknown".to_string(), + }; + + if path.contains("/test/") + || path.contains("/tests/") + || path.contains("_test.") + || path.contains(".test.") + || path.contains(".spec.") + || path.contains("/fixtures/") + || path.contains("/testdata/") + { + return "test".to_string(); + } + + if path.contains("/example") + || path.contains("/examples/") + || path.contains("/demo/") + || path.contains("/sample") + { + return "example".to_string(); + } + + if path.contains("/generated/") + || path.contains("/gen/") + || path.contains(".generated.") + || path.contains(".pb.go") + || path.contains("_generated.rs") + { + return "generated".to_string(); + } + + if path.contains("/vendor/") + || path.contains("/node_modules/") + || path.contains("/third_party/") + { + return "vendored".to_string(); + } + + "production".to_string() +} + +/// Adjust confidence based on file classification +fn adjust_confidence(raw_confidence: f64, classification: &str) -> f64 { + let multiplier = match classification { + "test" => 0.5, + "example" => 0.6, + "generated" => 0.3, + "vendored" => 0.4, + _ => 1.0, + }; + raw_confidence * multiplier +} + +fn downgrade_severity(severity: &compliance_core::models::Severity) -> compliance_core::models::Severity { + use compliance_core::models::Severity; + match severity { + Severity::Critical => Severity::High, + Severity::High => Severity::Medium, + Severity::Medium => Severity::Low, + Severity::Low => Severity::Info, + Severity::Info => Severity::Info, + } +} + +fn upgrade_severity(severity: &compliance_core::models::Severity) -> compliance_core::models::Severity { + use compliance_core::models::Severity; + match severity { + Severity::Info => Severity::Low, + Severity::Low => Severity::Medium, + Severity::Medium => Severity::High, + Severity::High => Severity::Critical, + Severity::Critical => Severity::Critical, + } +} + #[derive(serde::Deserialize)] struct TriageResult { - #[serde(default)] - #[allow(dead_code)] - true_positive: bool, + #[serde(default = "default_action")] + action: String, #[serde(default)] confidence: f64, + #[serde(default)] + rationale: String, remediation: Option, } + +fn default_action() -> String { + "confirm".to_string() +} diff --git a/compliance-agent/src/pipeline/code_review.rs b/compliance-agent/src/pipeline/code_review.rs new file mode 100644 index 0000000..6360033 --- /dev/null +++ b/compliance-agent/src/pipeline/code_review.rs @@ -0,0 +1,186 @@ +use std::path::Path; +use std::sync::Arc; + +use compliance_core::models::{Finding, ScanType, Severity}; +use compliance_core::traits::ScanOutput; + +use crate::llm::review_prompts::REVIEW_PASSES; +use crate::llm::LlmClient; +use crate::pipeline::dedup; +use crate::pipeline::git::{DiffFile, GitOps}; + +pub struct CodeReviewScanner { + llm: Arc, +} + +impl CodeReviewScanner { + pub fn new(llm: Arc) -> Self { + Self { llm } + } + + /// Run multi-pass LLM code review on the diff between old and new commits. + pub async fn review_diff( + &self, + repo_path: &Path, + repo_id: &str, + old_sha: &str, + new_sha: &str, + ) -> ScanOutput { + let diff_files = match GitOps::get_diff_content(repo_path, old_sha, new_sha) { + Ok(files) => files, + Err(e) => { + tracing::warn!("Failed to extract diff for code review: {e}"); + return ScanOutput::default(); + } + }; + + if diff_files.is_empty() { + return ScanOutput::default(); + } + + let mut all_findings = Vec::new(); + + // Chunk diff files into groups to avoid exceeding context limits + let chunks = chunk_diff_files(&diff_files, 8000); + + for (pass_name, system_prompt) in REVIEW_PASSES { + for chunk in &chunks { + let user_prompt = format!( + "Review the following code changes:\n\n{}", + chunk + .iter() + .map(|f| format!("--- {} ---\n{}", f.path, f.hunks)) + .collect::>() + .join("\n\n") + ); + + match self.llm.chat(system_prompt, &user_prompt, Some(0.1)).await { + Ok(response) => { + let parsed = parse_review_response(&response, pass_name, repo_id, chunk); + all_findings.extend(parsed); + } + Err(e) => { + tracing::warn!("Code review pass '{pass_name}' failed: {e}"); + } + } + } + } + + ScanOutput { + findings: all_findings, + sbom_entries: Vec::new(), + } + } +} + +/// Group diff files into chunks that fit within a token budget (rough char estimate) +fn chunk_diff_files(files: &[DiffFile], max_chars: usize) -> Vec> { + let mut chunks: Vec> = Vec::new(); + let mut current_chunk: Vec<&DiffFile> = Vec::new(); + let mut current_size = 0; + + for file in files { + if current_size + file.hunks.len() > max_chars && !current_chunk.is_empty() { + chunks.push(std::mem::take(&mut current_chunk)); + current_size = 0; + } + current_chunk.push(file); + current_size += file.hunks.len(); + } + + if !current_chunk.is_empty() { + chunks.push(current_chunk); + } + + chunks +} + +fn parse_review_response( + response: &str, + pass_name: &str, + repo_id: &str, + chunk: &[&DiffFile], +) -> Vec { + let cleaned = response.trim(); + let cleaned = if cleaned.starts_with("```") { + cleaned + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim() + } else { + cleaned + }; + + let issues: Vec = match serde_json::from_str(cleaned) { + Ok(v) => v, + Err(_) => { + if cleaned != "[]" { + tracing::debug!("Failed to parse {pass_name} review response: {cleaned}"); + } + return Vec::new(); + } + }; + + issues + .into_iter() + .filter(|issue| { + // Verify the file exists in the diff chunk + chunk.iter().any(|f| f.path == issue.file) + }) + .map(|issue| { + let severity = match issue.severity.as_str() { + "critical" => Severity::Critical, + "high" => Severity::High, + "medium" => Severity::Medium, + "low" => Severity::Low, + _ => Severity::Info, + }; + + let fingerprint = dedup::compute_fingerprint(&[ + repo_id, + "code-review", + pass_name, + &issue.file, + &issue.line.to_string(), + &issue.title, + ]); + + let description = if let Some(suggestion) = &issue.suggestion { + format!("{}\n\nSuggested fix: {}", issue.description, suggestion) + } else { + issue.description.clone() + }; + + let mut finding = Finding::new( + repo_id.to_string(), + fingerprint, + format!("code-review/{pass_name}"), + ScanType::CodeReview, + issue.title, + description, + severity, + ); + finding.rule_id = Some(format!("review/{pass_name}")); + finding.file_path = Some(issue.file); + finding.line_number = Some(issue.line); + finding.cwe = issue.cwe; + finding.suggested_fix = issue.suggestion; + finding + }) + .collect() +} + +#[derive(serde::Deserialize)] +struct ReviewIssue { + title: String, + description: String, + severity: String, + file: String, + #[serde(default)] + line: u32, + #[serde(default)] + cwe: Option, + #[serde(default)] + suggestion: Option, +} diff --git a/compliance-agent/src/pipeline/cve.rs b/compliance-agent/src/pipeline/cve.rs index 6c61feb..63649cb 100644 --- a/compliance-agent/src/pipeline/cve.rs +++ b/compliance-agent/src/pipeline/cve.rs @@ -64,6 +64,8 @@ impl CveScanner { } async fn query_osv_batch(&self, entries: &[SbomEntry]) -> Result>, CoreError> { + const OSV_BATCH_SIZE: usize = 500; + let queries: Vec<_> = entries .iter() .filter_map(|e| { @@ -79,47 +81,54 @@ impl CveScanner { return Ok(Vec::new()); } - let body = serde_json::json!({ "queries": queries }); + let mut all_vulns: Vec> = Vec::with_capacity(queries.len()); - let resp = self - .http - .post("https://api.osv.dev/v1/querybatch") - .json(&body) - .send() - .await - .map_err(|e| CoreError::Http(format!("OSV.dev request failed: {e}")))?; + for chunk in queries.chunks(OSV_BATCH_SIZE) { + let body = serde_json::json!({ "queries": chunk }); - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - tracing::warn!("OSV.dev returned {status}: {body}"); - return Ok(Vec::new()); + let resp = self + .http + .post("https://api.osv.dev/v1/querybatch") + .json(&body) + .send() + .await + .map_err(|e| CoreError::Http(format!("OSV.dev request failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + tracing::warn!("OSV.dev returned {status}: {body}"); + // Push empty results for this chunk so indices stay aligned + all_vulns.extend(std::iter::repeat_with(Vec::new).take(chunk.len())); + continue; + } + + let result: OsvBatchResponse = resp + .json() + .await + .map_err(|e| CoreError::Http(format!("Failed to parse OSV.dev response: {e}")))?; + + let chunk_vulns = result + .results + .into_iter() + .map(|r| { + r.vulns + .unwrap_or_default() + .into_iter() + .map(|v| OsvVuln { + id: v.id, + summary: v.summary, + severity: v.database_specific.and_then(|d| { + d.get("severity").and_then(|s| s.as_str()).map(String::from) + }), + }) + .collect() + }); + + all_vulns.extend(chunk_vulns); } - let result: OsvBatchResponse = resp - .json() - .await - .map_err(|e| CoreError::Http(format!("Failed to parse OSV.dev response: {e}")))?; - - let vulns = result - .results - .into_iter() - .map(|r| { - r.vulns - .unwrap_or_default() - .into_iter() - .map(|v| OsvVuln { - id: v.id, - summary: v.summary, - severity: v.database_specific.and_then(|d| { - d.get("severity").and_then(|s| s.as_str()).map(String::from) - }), - }) - .collect() - }) - .collect(); - - Ok(vulns) + Ok(all_vulns) } async fn query_nvd(&self, cve_id: &str) -> Result, CoreError> { diff --git a/compliance-agent/src/pipeline/git.rs b/compliance-agent/src/pipeline/git.rs index 4544fe5..3b6a01c 100644 --- a/compliance-agent/src/pipeline/git.rs +++ b/compliance-agent/src/pipeline/git.rs @@ -63,6 +63,62 @@ impl GitOps { } } + /// Extract structured diff content between two commits + pub fn get_diff_content( + repo_path: &Path, + old_sha: &str, + new_sha: &str, + ) -> Result, AgentError> { + let repo = Repository::open(repo_path)?; + let old_commit = repo.find_commit(git2::Oid::from_str(old_sha)?)?; + let new_commit = repo.find_commit(git2::Oid::from_str(new_sha)?)?; + + let old_tree = old_commit.tree()?; + let new_tree = new_commit.tree()?; + + let diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?; + + let mut diff_files: Vec = Vec::new(); + + diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| { + let file_path = delta + .new_file() + .path() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Find or create the DiffFile entry + let idx = if let Some(pos) = diff_files.iter().position(|f| f.path == file_path) { + pos + } else { + diff_files.push(DiffFile { + path: file_path, + hunks: String::new(), + }); + diff_files.len() - 1 + }; + let diff_file = &mut diff_files[idx]; + + let prefix = match line.origin() { + '+' => "+", + '-' => "-", + ' ' => " ", + _ => "", + }; + + let content = std::str::from_utf8(line.content()).unwrap_or(""); + diff_file.hunks.push_str(prefix); + diff_file.hunks.push_str(content); + + true + })?; + + // Filter out binary files and very large diffs + diff_files.retain(|f| !f.hunks.is_empty() && f.hunks.len() < 50_000); + + Ok(diff_files) + } + #[allow(dead_code)] pub fn get_changed_files( repo_path: &Path, @@ -94,3 +150,10 @@ impl GitOps { Ok(files) } } + +/// A file changed between two commits with its diff content +#[derive(Debug, Clone)] +pub struct DiffFile { + pub path: String, + pub hunks: String, +} diff --git a/compliance-agent/src/pipeline/gitleaks.rs b/compliance-agent/src/pipeline/gitleaks.rs new file mode 100644 index 0000000..032ef8f --- /dev/null +++ b/compliance-agent/src/pipeline/gitleaks.rs @@ -0,0 +1,117 @@ +use std::path::Path; + +use compliance_core::models::{Finding, ScanType, Severity}; +use compliance_core::traits::{ScanOutput, Scanner}; +use compliance_core::CoreError; + +use crate::pipeline::dedup; + +pub struct GitleaksScanner; + +impl Scanner for GitleaksScanner { + fn name(&self) -> &str { + "gitleaks" + } + + fn scan_type(&self) -> ScanType { + ScanType::SecretDetection + } + + async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { + let output = tokio::process::Command::new("gitleaks") + .args(["detect", "--source", ".", "--report-format", "json", "--report-path", "/dev/stdout", "--no-banner", "--exit-code", "0"]) + .current_dir(repo_path) + .output() + .await + .map_err(|e| CoreError::Scanner { + scanner: "gitleaks".to_string(), + source: Box::new(e), + })?; + + if output.stdout.is_empty() { + return Ok(ScanOutput::default()); + } + + let results: Vec = serde_json::from_slice(&output.stdout) + .unwrap_or_default(); + + let findings = results + .into_iter() + .filter(|r| !is_allowlisted(&r.file)) + .map(|r| { + let severity = match r.rule_id.as_str() { + s if s.contains("private-key") => Severity::Critical, + s if s.contains("token") || s.contains("password") || s.contains("secret") => Severity::High, + s if s.contains("api-key") => Severity::High, + _ => Severity::Medium, + }; + + let fingerprint = dedup::compute_fingerprint(&[ + repo_id, + &r.rule_id, + &r.file, + &r.start_line.to_string(), + ]); + + let title = format!("Secret detected: {}", r.description); + let description = format!( + "Potential secret ({}) found in {}:{}. Match: {}", + r.rule_id, + r.file, + r.start_line, + r.r#match.chars().take(80).collect::(), + ); + + let mut finding = Finding::new( + repo_id.to_string(), + fingerprint, + "gitleaks".to_string(), + ScanType::SecretDetection, + title, + description, + severity, + ); + finding.rule_id = Some(r.rule_id); + finding.file_path = Some(r.file); + finding.line_number = Some(r.start_line); + finding.code_snippet = Some(r.r#match); + finding + }) + .collect(); + + Ok(ScanOutput { + findings, + sbom_entries: Vec::new(), + }) + } +} + +/// Skip files that commonly contain example/placeholder secrets +fn is_allowlisted(file_path: &str) -> bool { + let lower = file_path.to_lowercase(); + lower.ends_with(".env.example") + || lower.ends_with(".env.sample") + || lower.ends_with(".env.template") + || lower.contains("/test/") + || lower.contains("/tests/") + || lower.contains("/fixtures/") + || lower.contains("/testdata/") + || lower.contains("mock") + || lower.ends_with("_test.go") + || lower.ends_with(".test.ts") + || lower.ends_with(".test.js") + || lower.ends_with(".spec.ts") + || lower.ends_with(".spec.js") +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "PascalCase")] +struct GitleaksResult { + description: String, + #[serde(rename = "RuleID")] + rule_id: String, + file: String, + start_line: u32, + #[serde(rename = "Match")] + r#match: String, +} diff --git a/compliance-agent/src/pipeline/lint.rs b/compliance-agent/src/pipeline/lint.rs new file mode 100644 index 0000000..1cb767f --- /dev/null +++ b/compliance-agent/src/pipeline/lint.rs @@ -0,0 +1,361 @@ +use std::path::Path; +use std::time::Duration; + +use compliance_core::models::{Finding, ScanType, Severity}; +use compliance_core::traits::{ScanOutput, Scanner}; +use compliance_core::CoreError; +use tokio::process::Command; + +use crate::pipeline::dedup; + +/// Timeout for each individual lint command +const LINT_TIMEOUT: Duration = Duration::from_secs(120); + +pub struct LintScanner; + +impl Scanner for LintScanner { + fn name(&self) -> &str { + "lint" + } + + fn scan_type(&self) -> ScanType { + ScanType::Lint + } + + async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { + let mut all_findings = Vec::new(); + + // Detect which languages are present and run appropriate linters + if has_rust_project(repo_path) { + match run_clippy(repo_path, repo_id).await { + Ok(findings) => all_findings.extend(findings), + Err(e) => tracing::warn!("Clippy failed: {e}"), + } + } + + if has_js_project(repo_path) { + match run_eslint(repo_path, repo_id).await { + Ok(findings) => all_findings.extend(findings), + Err(e) => tracing::warn!("ESLint failed: {e}"), + } + } + + if has_python_project(repo_path) { + match run_ruff(repo_path, repo_id).await { + Ok(findings) => all_findings.extend(findings), + Err(e) => tracing::warn!("Ruff failed: {e}"), + } + } + + Ok(ScanOutput { + findings: all_findings, + sbom_entries: Vec::new(), + }) + } +} + +fn has_rust_project(repo_path: &Path) -> bool { + repo_path.join("Cargo.toml").exists() +} + +fn has_js_project(repo_path: &Path) -> bool { + // Only run if eslint is actually installed in the project + repo_path.join("package.json").exists() + && repo_path.join("node_modules/.bin/eslint").exists() +} + +fn has_python_project(repo_path: &Path) -> bool { + repo_path.join("pyproject.toml").exists() + || repo_path.join("setup.py").exists() + || repo_path.join("requirements.txt").exists() +} + +/// Run a command with a timeout, returning its output or an error +async fn run_with_timeout( + child: tokio::process::Child, + scanner_name: &str, +) -> Result { + let result = tokio::time::timeout(LINT_TIMEOUT, child.wait_with_output()).await; + match result { + Ok(Ok(output)) => Ok(output), + Ok(Err(e)) => Err(CoreError::Scanner { + scanner: scanner_name.to_string(), + source: Box::new(e), + }), + Err(_) => { + // Process is dropped here which sends SIGKILL on Unix + Err(CoreError::Scanner { + scanner: scanner_name.to_string(), + source: Box::new(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("{scanner_name} timed out after {}s", LINT_TIMEOUT.as_secs()), + )), + }) + } + } +} + +// ── Clippy ────────────────────────────────────────────── + +async fn run_clippy(repo_path: &Path, repo_id: &str) -> Result, CoreError> { + let child = Command::new("cargo") + .args(["clippy", "--message-format=json", "--quiet", "--", "-W", "clippy::all"]) + .current_dir(repo_path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| CoreError::Scanner { + scanner: "clippy".to_string(), + source: Box::new(e), + })?; + + let output = run_with_timeout(child, "clippy").await?; + let stdout = String::from_utf8_lossy(&output.stdout); + let mut findings = Vec::new(); + + for line in stdout.lines() { + let msg: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, + }; + + if msg.get("reason").and_then(|v| v.as_str()) != Some("compiler-message") { + continue; + } + + let message = match msg.get("message") { + Some(m) => m, + None => continue, + }; + + let level = message + .get("level") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if level != "warning" && level != "error" { + continue; + } + + let text = message + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let code = message + .get("code") + .and_then(|v| v.get("code")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if text.starts_with("aborting due to") || code.is_empty() { + continue; + } + + let (file_path, line_number) = extract_primary_span(message); + + let severity = if level == "error" { + Severity::High + } else { + Severity::Low + }; + + let fingerprint = + dedup::compute_fingerprint(&[repo_id, "clippy", &code, &file_path, &line_number.to_string()]); + + let mut finding = Finding::new( + repo_id.to_string(), + fingerprint, + "clippy".to_string(), + ScanType::Lint, + format!("[clippy] {text}"), + text, + severity, + ); + finding.rule_id = Some(code); + if !file_path.is_empty() { + finding.file_path = Some(file_path); + } + if line_number > 0 { + finding.line_number = Some(line_number); + } + findings.push(finding); + } + + Ok(findings) +} + +fn extract_primary_span(message: &serde_json::Value) -> (String, u32) { + let spans = match message.get("spans").and_then(|v| v.as_array()) { + Some(s) => s, + None => return (String::new(), 0), + }; + + for span in spans { + if span.get("is_primary").and_then(|v| v.as_bool()) == Some(true) { + let file = span + .get("file_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let line = span + .get("line_start") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + return (file, line); + } + } + + (String::new(), 0) +} + +// ── ESLint ────────────────────────────────────────────── + +async fn run_eslint(repo_path: &Path, repo_id: &str) -> Result, CoreError> { + // Use the project-local eslint binary directly, not npx (which can hang downloading) + let eslint_bin = repo_path.join("node_modules/.bin/eslint"); + let child = Command::new(eslint_bin) + .args([".", "--format", "json", "--no-error-on-unmatched-pattern"]) + .current_dir(repo_path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| CoreError::Scanner { + scanner: "eslint".to_string(), + source: Box::new(e), + })?; + + let output = run_with_timeout(child, "eslint").await?; + + if output.stdout.is_empty() { + return Ok(Vec::new()); + } + + let results: Vec = + serde_json::from_slice(&output.stdout).unwrap_or_default(); + + let mut findings = Vec::new(); + for file_result in results { + for msg in file_result.messages { + let severity = match msg.severity { + 2 => Severity::Medium, + _ => Severity::Low, + }; + + let rule_id = msg.rule_id.unwrap_or_default(); + let fingerprint = dedup::compute_fingerprint(&[ + repo_id, + "eslint", + &rule_id, + &file_result.file_path, + &msg.line.to_string(), + ]); + + let mut finding = Finding::new( + repo_id.to_string(), + fingerprint, + "eslint".to_string(), + ScanType::Lint, + format!("[eslint] {}", msg.message), + msg.message, + severity, + ); + finding.rule_id = Some(rule_id); + finding.file_path = Some(file_result.file_path.clone()); + finding.line_number = Some(msg.line); + findings.push(finding); + } + } + + Ok(findings) +} + +#[derive(serde::Deserialize)] +struct EslintFileResult { + #[serde(rename = "filePath")] + file_path: String, + messages: Vec, +} + +#[derive(serde::Deserialize)] +struct EslintMessage { + #[serde(rename = "ruleId")] + rule_id: Option, + severity: u8, + message: String, + line: u32, +} + +// ── Ruff ──────────────────────────────────────────────── + +async fn run_ruff(repo_path: &Path, repo_id: &str) -> Result, CoreError> { + let child = Command::new("ruff") + .args(["check", ".", "--output-format", "json", "--exit-zero"]) + .current_dir(repo_path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| CoreError::Scanner { + scanner: "ruff".to_string(), + source: Box::new(e), + })?; + + let output = run_with_timeout(child, "ruff").await?; + + if output.stdout.is_empty() { + return Ok(Vec::new()); + } + + let results: Vec = + serde_json::from_slice(&output.stdout).unwrap_or_default(); + + let findings = results + .into_iter() + .map(|r| { + let severity = if r.code.starts_with('E') || r.code.starts_with('F') { + Severity::Medium + } else { + Severity::Low + }; + + let fingerprint = dedup::compute_fingerprint(&[ + repo_id, + "ruff", + &r.code, + &r.filename, + &r.location.row.to_string(), + ]); + + let mut finding = Finding::new( + repo_id.to_string(), + fingerprint, + "ruff".to_string(), + ScanType::Lint, + format!("[ruff] {}: {}", r.code, r.message), + r.message, + severity, + ); + finding.rule_id = Some(r.code); + finding.file_path = Some(r.filename); + finding.line_number = Some(r.location.row); + finding + }) + .collect(); + + Ok(findings) +} + +#[derive(serde::Deserialize)] +struct RuffResult { + code: String, + message: String, + filename: String, + location: RuffLocation, +} + +#[derive(serde::Deserialize)] +struct RuffLocation { + row: u32, +} diff --git a/compliance-agent/src/pipeline/mod.rs b/compliance-agent/src/pipeline/mod.rs index 257da50..c620b65 100644 --- a/compliance-agent/src/pipeline/mod.rs +++ b/compliance-agent/src/pipeline/mod.rs @@ -1,6 +1,9 @@ +pub mod code_review; pub mod cve; pub mod dedup; pub mod git; +pub mod gitleaks; +pub mod lint; pub mod orchestrator; pub mod patterns; pub mod sbom; diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index 39923ca..a9d1ac5 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -9,8 +9,11 @@ use compliance_core::AgentConfig; use crate::database::Database; use crate::error::AgentError; use crate::llm::LlmClient; +use crate::pipeline::code_review::CodeReviewScanner; use crate::pipeline::cve::CveScanner; use crate::pipeline::git::GitOps; +use crate::pipeline::gitleaks::GitleaksScanner; +use crate::pipeline::lint::LintScanner; use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner}; use crate::pipeline::sbom::SbomScanner; use crate::pipeline::semgrep::SemgrepScanner; @@ -182,6 +185,35 @@ impl PipelineOrchestrator { Err(e) => tracing::warn!("[{repo_id}] OAuth pattern scan failed: {e}"), } + // Stage 4a: Secret Detection (Gitleaks) + tracing::info!("[{repo_id}] Stage 4a: Secret Detection"); + self.update_phase(scan_run_id, "secret_detection").await; + let gitleaks = GitleaksScanner; + match gitleaks.scan(&repo_path, &repo_id).await { + Ok(output) => all_findings.extend(output.findings), + Err(e) => tracing::warn!("[{repo_id}] Gitleaks failed: {e}"), + } + + // Stage 4b: Lint Scanning + tracing::info!("[{repo_id}] Stage 4b: Lint Scanning"); + self.update_phase(scan_run_id, "lint_scanning").await; + let lint = LintScanner; + match lint.scan(&repo_path, &repo_id).await { + Ok(output) => all_findings.extend(output.findings), + Err(e) => tracing::warn!("[{repo_id}] Lint scanning failed: {e}"), + } + + // Stage 4c: LLM Code Review (only on incremental scans) + if let Some(old_sha) = &repo.last_scanned_commit { + tracing::info!("[{repo_id}] Stage 4c: LLM Code Review"); + self.update_phase(scan_run_id, "code_review").await; + let reviewer = CodeReviewScanner::new(self.llm.clone()); + let review_output = reviewer + .review_diff(&repo_path, &repo_id, old_sha, ¤t_sha) + .await; + all_findings.extend(review_output.findings); + } + // Stage 4.5: Graph Building tracing::info!("[{repo_id}] Stage 4.5: Graph Building"); self.update_phase(scan_run_id, "graph_building").await; diff --git a/compliance-core/src/models/cve.rs b/compliance-core/src/models/cve.rs index 199fc08..ef19adc 100644 --- a/compliance-core/src/models/cve.rs +++ b/compliance-core/src/models/cve.rs @@ -23,6 +23,7 @@ pub struct CveAlert { pub summary: Option, pub llm_impact_summary: Option, pub references: Vec, + #[serde(with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, } diff --git a/compliance-core/src/models/dast.rs b/compliance-core/src/models/dast.rs index d2755a0..7ff9599 100644 --- a/compliance-core/src/models/dast.rs +++ b/compliance-core/src/models/dast.rs @@ -58,7 +58,9 @@ pub struct DastTarget { pub rate_limit: u32, /// Whether destructive tests (DELETE, PUT) are allowed pub allow_destructive: bool, + #[serde(with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, + #[serde(with = "super::serde_helpers::bson_datetime")] pub updated_at: DateTime, } @@ -135,7 +137,9 @@ pub struct DastScanRun { pub error_message: Option, /// Linked SAST scan run ID (if triggered as part of pipeline) pub sast_scan_run_id: Option, + #[serde(with = "super::serde_helpers::bson_datetime")] pub started_at: DateTime, + #[serde(default, with = "super::serde_helpers::opt_bson_datetime")] pub completed_at: Option>, } @@ -240,6 +244,7 @@ pub struct DastFinding { pub remediation: Option, /// Linked SAST finding ID (if correlated) pub linked_sast_finding_id: Option, + #[serde(with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, } diff --git a/compliance-core/src/models/finding.rs b/compliance-core/src/models/finding.rs index 0bcbf3b..745cc0e 100644 --- a/compliance-core/src/models/finding.rs +++ b/compliance-core/src/models/finding.rs @@ -71,7 +71,14 @@ pub struct Finding { pub status: FindingStatus, pub tracker_issue_url: Option, pub scan_run_id: Option, + /// LLM triage action and reasoning + pub triage_action: Option, + pub triage_rationale: Option, + /// Developer feedback on finding quality + pub developer_feedback: Option, + #[serde(with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, + #[serde(with = "super::serde_helpers::bson_datetime")] pub updated_at: DateTime, } @@ -108,6 +115,9 @@ impl Finding { status: FindingStatus::Open, tracker_issue_url: None, scan_run_id: None, + triage_action: None, + triage_rationale: None, + developer_feedback: None, created_at: now, updated_at: now, } diff --git a/compliance-core/src/models/graph.rs b/compliance-core/src/models/graph.rs index 7bbd9be..88b88f6 100644 --- a/compliance-core/src/models/graph.rs +++ b/compliance-core/src/models/graph.rs @@ -122,7 +122,9 @@ pub struct GraphBuildRun { pub community_count: u32, pub languages_parsed: Vec, pub error_message: Option, + #[serde(with = "super::serde_helpers::bson_datetime")] pub started_at: DateTime, + #[serde(default, with = "super::serde_helpers::opt_bson_datetime")] pub completed_at: Option>, } @@ -164,6 +166,7 @@ pub struct ImpactAnalysis { pub direct_callers: Vec, /// Direct callees of the affected function pub direct_callees: Vec, + #[serde(with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, } diff --git a/compliance-core/src/models/issue.rs b/compliance-core/src/models/issue.rs index f4f82ec..3c7d670 100644 --- a/compliance-core/src/models/issue.rs +++ b/compliance-core/src/models/issue.rs @@ -49,7 +49,9 @@ pub struct TrackerIssue { pub external_url: String, pub title: String, pub status: IssueStatus, + #[serde(with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, + #[serde(with = "super::serde_helpers::bson_datetime")] pub updated_at: DateTime, } diff --git a/compliance-core/src/models/mcp.rs b/compliance-core/src/models/mcp.rs index 390c255..a966255 100644 --- a/compliance-core/src/models/mcp.rs +++ b/compliance-core/src/models/mcp.rs @@ -62,6 +62,8 @@ pub struct McpServerConfig { pub mongodb_uri: Option, /// Database name pub mongodb_database: Option, + #[serde(with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, + #[serde(with = "super::serde_helpers::bson_datetime")] pub updated_at: DateTime, } diff --git a/compliance-core/src/models/mod.rs b/compliance-core/src/models/mod.rs index 00fea6c..a63ca9e 100644 --- a/compliance-core/src/models/mod.rs +++ b/compliance-core/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub(crate) mod serde_helpers; pub mod chat; pub mod cve; pub mod dast; diff --git a/compliance-core/src/models/repository.rs b/compliance-core/src/models/repository.rs index 569a139..cc43c30 100644 --- a/compliance-core/src/models/repository.rs +++ b/compliance-core/src/models/repository.rs @@ -31,15 +31,9 @@ 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", with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, - #[serde( - default = "chrono::Utc::now", - deserialize_with = "deserialize_datetime" - )] + #[serde(default = "chrono::Utc::now", with = "super::serde_helpers::bson_datetime")] pub updated_at: DateTime, } @@ -47,23 +41,6 @@ fn default_branch() -> String { "main".to_string() } -/// Handles findings_count stored as either a plain integer or a BSON Int64 -/// which the driver may present as a map `{"low": N, "high": N, "unsigned": bool}`. -/// Handles datetime stored as either a BSON DateTime or an RFC 3339 string. -fn deserialize_datetime<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - 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), - other => Err(serde::de::Error::custom(format!( - "expected DateTime or string, got: {other:?}" - ))), - } -} - fn deserialize_findings_count<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, diff --git a/compliance-core/src/models/sbom.rs b/compliance-core/src/models/sbom.rs index 1590fed..2bc015c 100644 --- a/compliance-core/src/models/sbom.rs +++ b/compliance-core/src/models/sbom.rs @@ -20,7 +20,9 @@ pub struct SbomEntry { pub license: Option, pub purl: Option, pub known_vulnerabilities: Vec, + #[serde(with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, + #[serde(with = "super::serde_helpers::bson_datetime")] pub updated_at: DateTime, } diff --git a/compliance-core/src/models/scan.rs b/compliance-core/src/models/scan.rs index f4ba15a..fc7e256 100644 --- a/compliance-core/src/models/scan.rs +++ b/compliance-core/src/models/scan.rs @@ -13,6 +13,9 @@ pub enum ScanType { OAuth, Graph, Dast, + SecretDetection, + Lint, + CodeReview, } impl std::fmt::Display for ScanType { @@ -25,6 +28,9 @@ impl std::fmt::Display for ScanType { Self::OAuth => write!(f, "oauth"), Self::Graph => write!(f, "graph"), Self::Dast => write!(f, "dast"), + Self::SecretDetection => write!(f, "secret_detection"), + Self::Lint => write!(f, "lint"), + Self::CodeReview => write!(f, "code_review"), } } } @@ -45,6 +51,9 @@ pub enum ScanPhase { SbomGeneration, CveScanning, PatternScanning, + SecretDetection, + LintScanning, + CodeReview, GraphBuilding, LlmTriage, IssueCreation, @@ -64,7 +73,9 @@ pub struct ScanRun { pub phases_completed: Vec, pub new_findings_count: u32, pub error_message: Option, + #[serde(with = "super::serde_helpers::bson_datetime")] pub started_at: DateTime, + #[serde(default, with = "super::serde_helpers::opt_bson_datetime")] pub completed_at: Option>, } diff --git a/compliance-core/src/models/serde_helpers.rs b/compliance-core/src/models/serde_helpers.rs new file mode 100644 index 0000000..b7f6dfd --- /dev/null +++ b/compliance-core/src/models/serde_helpers.rs @@ -0,0 +1,70 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, Serializer}; + +/// Serialize/deserialize `DateTime` as BSON DateTime. +/// Handles both BSON DateTime objects and RFC 3339 strings on deserialization. +pub mod bson_datetime { + use super::*; + use serde::Serialize as _; + + pub fn serialize(dt: &DateTime, serializer: S) -> Result + where + S: Serializer, + { + let bson_dt: bson::DateTime = (*dt).into(); + bson_dt.serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let bson_val = bson::Bson::deserialize(deserializer)?; + match bson_val { + bson::Bson::DateTime(dt) => Ok(dt.into()), + 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:?}" + ))), + } + } +} + +/// Serialize/deserialize `Option>` as BSON DateTime. +pub mod opt_bson_datetime { + use super::*; + use serde::Serialize as _; + + pub fn serialize(dt: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match dt { + Some(dt) => { + let bson_dt: bson::DateTime = (*dt).into(); + bson_dt.serialize(serializer) + } + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let bson_val = Option::::deserialize(deserializer)?; + match bson_val { + Some(bson::Bson::DateTime(dt)) => Ok(Some(dt.into())), + Some(bson::Bson::String(s)) => s + .parse::>() + .map(Some) + .map_err(serde::de::Error::custom), + Some(bson::Bson::Null) | None => Ok(None), + Some(other) => Err(serde::de::Error::custom(format!( + "expected DateTime, string, or null, got: {other:?}" + ))), + } + } +} diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index 93b80fb..11b5096 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -609,6 +609,24 @@ tbody tr:last-child td { background: var(--danger-bg); } +.btn-scanning { + opacity: 0.7; + cursor: not-allowed; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--border-bright); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + .btn-danger { background: var(--danger); color: #fff; diff --git a/compliance-dashboard/src/infrastructure/findings.rs b/compliance-dashboard/src/infrastructure/findings.rs index 0eba0bb..92ed396 100644 --- a/compliance-dashboard/src/infrastructure/findings.rs +++ b/compliance-dashboard/src/infrastructure/findings.rs @@ -11,12 +11,16 @@ pub struct FindingsListResponse { } #[server] +#[allow(clippy::too_many_arguments)] pub async fn fetch_findings( page: u64, severity: String, scan_type: String, status: String, repo_id: String, + q: String, + sort_by: String, + sort_order: String, ) -> Result { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; @@ -37,6 +41,18 @@ pub async fn fetch_findings( if !repo_id.is_empty() { url.push_str(&format!("&repo_id={repo_id}")); } + if !q.is_empty() { + url.push_str(&format!( + "&q={}", + url::form_urlencoded::byte_serialize(q.as_bytes()).collect::() + )); + } + if !sort_by.is_empty() { + url.push_str(&format!("&sort_by={sort_by}")); + } + if !sort_order.is_empty() { + url.push_str(&format!("&sort_order={sort_order}")); + } let resp = reqwest::get(&url) .await @@ -82,3 +98,43 @@ pub async fn update_finding_status(id: String, status: String) -> Result<(), Ser Ok(()) } + +#[server] +pub async fn bulk_update_finding_status( + ids: Vec, + status: String, +) -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/findings/bulk-status", state.agent_api_url); + + let client = reqwest::Client::new(); + client + .patch(&url) + .json(&serde_json::json!({ "ids": ids, "status": status })) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(()) +} + +#[server] +pub async fn update_finding_feedback( + id: String, + feedback: String, +) -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/findings/{id}/feedback", state.agent_api_url); + + let client = reqwest::Client::new(); + client + .patch(&url) + .json(&serde_json::json!({ "feedback": feedback })) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(()) +} diff --git a/compliance-dashboard/src/infrastructure/repositories.rs b/compliance-dashboard/src/infrastructure/repositories.rs index f4f740d..0e4fe29 100644 --- a/compliance-dashboard/src/infrastructure/repositories.rs +++ b/compliance-dashboard/src/infrastructure/repositories.rs @@ -99,3 +99,35 @@ pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> { Ok(()) } + +/// Check if a repository has any running scans +#[server] +pub async fn check_repo_scanning(repo_id: String) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!( + "{}/api/v1/scan-runs?page=1&limit=1", + state.agent_api_url + ); + + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + // Check if the most recent scan for this repo is still running + if let Some(scans) = body.get("data").and_then(|d| d.as_array()) { + for scan in scans { + let scan_repo = scan.get("repo_id").and_then(|v| v.as_str()).unwrap_or(""); + let status = scan.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if scan_repo == repo_id && status == "running" { + return Ok(true); + } + } + } + + Ok(false) +} diff --git a/compliance-dashboard/src/pages/finding_detail.rs b/compliance-dashboard/src/pages/finding_detail.rs index 1bb702d..94629ac 100644 --- a/compliance-dashboard/src/pages/finding_detail.rs +++ b/compliance-dashboard/src/pages/finding_detail.rs @@ -8,7 +8,7 @@ use crate::components::severity_badge::SeverityBadge; pub fn FindingDetailPage(id: String) -> Element { let finding_id = id.clone(); - let finding = use_resource(move || { + let mut finding = use_resource(move || { let fid = finding_id.clone(); async move { crate::infrastructure::findings::fetch_finding_detail(fid) @@ -22,6 +22,8 @@ pub fn FindingDetailPage(id: String) -> Element { match snapshot { Some(Some(f)) => { let finding_id_for_status = id.clone(); + let finding_id_for_feedback = id.clone(); + let existing_feedback = f.developer_feedback.clone().unwrap_or_default(); rsx! { PageHeader { title: f.title.clone(), @@ -39,6 +41,9 @@ pub fn FindingDetailPage(id: String) -> Element { if let Some(score) = f.cvss_score { span { class: "badge badge-medium", "CVSS: {score}" } } + if let Some(confidence) = f.confidence { + span { class: "badge badge-info", "Confidence: {confidence:.1}" } + } } div { class: "card", @@ -46,6 +51,19 @@ pub fn FindingDetailPage(id: String) -> Element { p { "{f.description}" } } + if let Some(rationale) = &f.triage_rationale { + div { class: "card", + div { class: "card-header", "Triage Rationale" } + div { + style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;", + if let Some(action) = &f.triage_action { + span { class: "badge badge-info", "{action}" } + } + } + p { style: "color: var(--text-secondary); font-size: 14px;", "{rationale}" } + } + } + if let Some(code) = &f.code_snippet { div { class: "card", div { class: "card-header", "Code Evidence" } @@ -99,6 +117,7 @@ pub fn FindingDetailPage(id: String) -> Element { spawn(async move { let _ = crate::infrastructure::findings::update_finding_status(id, s).await; }); + finding.restart(); }, "{status}" } @@ -107,6 +126,25 @@ pub fn FindingDetailPage(id: String) -> Element { } } } + + div { class: "card", + div { class: "card-header", "Developer Feedback" } + p { + style: "font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;", + "Share your assessment of this finding (e.g. false positive, actionable, needs context)" + } + textarea { + style: "width: 100%; min-height: 80px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; color: var(--text-primary); font-size: 14px; resize: vertical;", + value: "{existing_feedback}", + oninput: move |e| { + let feedback = e.value(); + let id = finding_id_for_feedback.clone(); + spawn(async move { + let _ = crate::infrastructure::findings::update_finding_feedback(id, feedback).await; + }); + }, + } + } } } Some(None) => rsx! { diff --git a/compliance-dashboard/src/pages/findings.rs b/compliance-dashboard/src/pages/findings.rs index e6fd390..f3aee70 100644 --- a/compliance-dashboard/src/pages/findings.rs +++ b/compliance-dashboard/src/pages/findings.rs @@ -12,6 +12,10 @@ pub fn FindingsPage() -> Element { let mut type_filter = use_signal(String::new); let mut status_filter = use_signal(String::new); let mut repo_filter = use_signal(String::new); + let mut search_query = use_signal(String::new); + let mut sort_by = use_signal(|| "created_at".to_string()); + let mut sort_order = use_signal(|| "desc".to_string()); + let mut selected_ids = use_signal(Vec::::new); let repos = use_resource(|| async { crate::infrastructure::repositories::fetch_repositories(1) @@ -25,13 +29,44 @@ pub fn FindingsPage() -> Element { let typ = type_filter(); let stat = status_filter(); let repo = repo_filter(); + let q = search_query(); + let sb = sort_by(); + let so = sort_order(); async move { - crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, repo) + crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, repo, q, sb, so) .await .ok() } }); + let toggle_sort = move |field: &'static str| { + move |_: MouseEvent| { + if sort_by() == field { + sort_order.set(if sort_order() == "asc" { + "desc".to_string() + } else { + "asc".to_string() + }); + } else { + sort_by.set(field.to_string()); + sort_order.set("desc".to_string()); + } + page.set(1); + } + }; + + let sort_indicator = move |field: &str| -> String { + if sort_by() == field { + if sort_order() == "asc" { + " \u{25B2}".to_string() + } else { + " \u{25BC}".to_string() + } + } else { + String::new() + } + }; + rsx! { PageHeader { title: "Findings", @@ -39,6 +74,12 @@ pub fn FindingsPage() -> Element { } div { class: "filter-bar", + input { + r#type: "text", + placeholder: "Search findings...", + style: "min-width: 200px;", + oninput: move |e| { search_query.set(e.value()); page.set(1); }, + } select { onchange: move |e| { repo_filter.set(e.value()); page.set(1); }, option { value: "", "All Repositories" } @@ -76,6 +117,9 @@ pub fn FindingsPage() -> Element { option { value: "cve", "CVE" } option { value: "gdpr", "GDPR" } option { value: "oauth", "OAuth" } + option { value: "secret_detection", "Secrets" } + option { value: "lint", "Lint" } + option { value: "code_review", "Code Review" } } select { onchange: move |e| { status_filter.set(e.value()); page.set(1); }, @@ -88,29 +132,123 @@ pub fn FindingsPage() -> Element { } } + // Bulk action bar + if !selected_ids().is_empty() { + div { + class: "card", + style: "display: flex; align-items: center; gap: 12px; padding: 12px 16px; margin-bottom: 16px; background: rgba(56, 189, 248, 0.08); border-color: rgba(56, 189, 248, 0.2);", + span { + style: "font-size: 14px; color: var(--text-secondary);", + "{selected_ids().len()} selected" + } + for status in ["triaged", "resolved", "false_positive", "ignored"] { + { + let status_str = status.to_string(); + let label = match status { + "false_positive" => "False Positive", + other => { + // Capitalize first letter + let mut s = other.to_string(); + if let Some(c) = s.get_mut(0..1) { c.make_ascii_uppercase(); } + // Leak to get a &str that lives long enough - this is fine for static-ish UI strings + &*Box::leak(s.into_boxed_str()) + } + }; + rsx! { + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| { + let ids = selected_ids(); + let s = status_str.clone(); + spawn(async move { + let _ = crate::infrastructure::findings::bulk_update_finding_status(ids, s).await; + }); + selected_ids.set(Vec::new()); + }, + "Mark {label}" + } + } + } + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| { selected_ids.set(Vec::new()); }, + "Clear" + } + } + } + match &*findings.read() { Some(Some(resp)) => { let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1); + let all_ids: Vec = resp.data.iter().filter_map(|f| f.id.as_ref().map(|id| id.to_hex())).collect(); rsx! { div { class: "card", div { class: "table-wrapper", table { thead { tr { - th { "Severity" } - th { "Title" } - th { "Type" } + th { + style: "width: 40px;", + input { + r#type: "checkbox", + checked: !all_ids.is_empty() && selected_ids().len() == all_ids.len(), + onchange: move |_| { + if selected_ids().len() == all_ids.len() { + selected_ids.set(Vec::new()); + } else { + selected_ids.set(all_ids.clone()); + } + }, + } + } + th { + style: "cursor: pointer; user-select: none;", + onclick: toggle_sort("severity"), + "Severity{sort_indicator(\"severity\")}" + } + th { + style: "cursor: pointer; user-select: none;", + onclick: toggle_sort("title"), + "Title{sort_indicator(\"title\")}" + } + th { + style: "cursor: pointer; user-select: none;", + onclick: toggle_sort("scan_type"), + "Type{sort_indicator(\"scan_type\")}" + } th { "Scanner" } th { "File" } - th { "Status" } + th { + style: "cursor: pointer; user-select: none;", + onclick: toggle_sort("status"), + "Status{sort_indicator(\"status\")}" + } } } tbody { for finding in &resp.data { { let id = finding.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); + let id_for_check = id.clone(); + let is_selected = selected_ids().contains(&id); rsx! { tr { + td { + input { + r#type: "checkbox", + checked: is_selected, + onchange: move |_| { + let mut ids = selected_ids(); + if ids.contains(&id_for_check) { + ids.retain(|i| i != &id_for_check); + } else { + ids.push(id_for_check.clone()); + } + selected_ids.set(ids); + }, + } + } td { SeverityBadge { severity: finding.severity.to_string() } } td { Link { diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index b1ff63b..779305c 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -5,6 +5,17 @@ use crate::components::page_header::PageHeader; use crate::components::pagination::Pagination; use crate::components::toast::{ToastType, Toasts}; +async fn async_sleep_5s() { + #[cfg(feature = "web")] + { + gloo_timers::future::TimeoutFuture::new(5_000).await; + } + #[cfg(not(feature = "web"))] + { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } +} + #[component] pub fn RepositoriesPage() -> Element { let mut page = use_signal(|| 1u64); @@ -14,6 +25,7 @@ pub fn RepositoriesPage() -> Element { 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 scanning_ids = use_signal(Vec::::new); let mut repos = use_resource(move || { let p = page(); @@ -158,6 +170,7 @@ pub fn RepositoriesPage() -> Element { let repo_id_scan = repo_id.clone(); let repo_id_del = repo_id.clone(); let repo_name_del = repo.name.clone(); + let is_scanning = scanning_ids().contains(&repo_id); rsx! { tr { td { "{repo.name}" } @@ -192,17 +205,44 @@ pub fn RepositoriesPage() -> Element { "Graph" } button { - class: "btn btn-ghost", + class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" }, + disabled: is_scanning, onclick: move |_| { let id = repo_id_scan.clone(); + // Add to scanning set + let mut ids = scanning_ids(); + ids.push(id.clone()); + scanning_ids.set(ids); spawn(async move { - match crate::infrastructure::repositories::trigger_repo_scan(id).await { - Ok(_) => toasts.push(ToastType::Success, "Scan triggered"), + match crate::infrastructure::repositories::trigger_repo_scan(id.clone()).await { + Ok(_) => { + toasts.push(ToastType::Success, "Scan triggered"); + // Poll until scan completes + loop { + async_sleep_5s().await; + match crate::infrastructure::repositories::check_repo_scanning(id.clone()).await { + Ok(false) => break, + Ok(true) => continue, + Err(_) => break, + } + } + toasts.push(ToastType::Success, "Scan complete"); + repos.restart(); + } Err(e) => toasts.push(ToastType::Error, e.to_string()), } + // Remove from scanning set + let mut ids = scanning_ids(); + ids.retain(|i| i != &id); + scanning_ids.set(ids); }); }, - "Scan" + if is_scanning { + span { class: "spinner" } + "Scanning..." + } else { + "Scan" + } } button { class: "btn btn-ghost btn-ghost-danger", diff --git a/compliance-mcp/src/tools/findings.rs b/compliance-mcp/src/tools/findings.rs index 14929aa..70489a5 100644 --- a/compliance-mcp/src/tools/findings.rs +++ b/compliance-mcp/src/tools/findings.rs @@ -20,7 +20,7 @@ pub struct ListFindingsParams { pub severity: Option, /// Filter by status: open, triaged, false_positive, resolved, ignored pub status: Option, - /// Filter by scan type: sast, sbom, cve, gdpr, oauth + /// Filter by scan type: sast, sbom, cve, gdpr, oauth, secret_detection, lint, code_review pub scan_type: Option, /// Maximum number of results (default 50, max 200) pub limit: Option, -- 2.49.1 From 492a93a83ed0983d72ecd08099ec28a0ed51e5a2 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 9 Mar 2026 11:53:17 +0100 Subject: [PATCH 2/6] feat: add private repository support with SSH key and HTTPS token auth - Generate SSH ed25519 key pair on agent startup for cloning private repos via SSH - Add GET /api/v1/settings/ssh-public-key endpoint to expose deploy key - Add auth_token and auth_username fields to TrackedRepository model - Wire git2 credential callbacks for both SSH and HTTPS authentication - Validate repository access before saving (test-connect on add) - Update dashboard add form with optional auth section showing deploy key and token fields - Show error toast if private repo cannot be accessed Co-Authored-By: Claude Opus 4.6 --- compliance-agent/src/api/handlers/chat.rs | 7 +- compliance-agent/src/api/handlers/graph.rs | 7 +- compliance-agent/src/api/handlers/mod.rs | 30 +++++- compliance-agent/src/api/routes.rs | 1 + compliance-agent/src/config.rs | 2 + compliance-agent/src/main.rs | 7 ++ compliance-agent/src/pipeline/git.rs | 90 +++++++++++++++++- compliance-agent/src/pipeline/orchestrator.rs | 9 +- compliance-agent/src/ssh.rs | 57 ++++++++++++ compliance-core/src/config.rs | 1 + compliance-core/src/models/repository.rs | 8 ++ .../src/infrastructure/repositories.rs | 46 +++++++++- .../src/pages/repositories.rs | 91 ++++++++++++++++++- 13 files changed, 338 insertions(+), 18 deletions(-) create mode 100644 compliance-agent/src/ssh.rs diff --git a/compliance-agent/src/api/handlers/chat.rs b/compliance-agent/src/api/handlers/chat.rs index aafe290..9413f99 100644 --- a/compliance-agent/src/api/handlers/chat.rs +++ b/compliance-agent/src/api/handlers/chat.rs @@ -187,7 +187,12 @@ pub async fn build_embeddings( } }; - let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path); + let creds = crate::pipeline::git::RepoCredentials { + ssh_key_path: Some(agent_clone.config.ssh_key_path.clone()), + auth_token: repo.auth_token.clone(), + auth_username: repo.auth_username.clone(), + }; + let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds); let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) { Ok(p) => p, Err(e) => { diff --git a/compliance-agent/src/api/handlers/graph.rs b/compliance-agent/src/api/handlers/graph.rs index ea12acd..bfbafec 100644 --- a/compliance-agent/src/api/handlers/graph.rs +++ b/compliance-agent/src/api/handlers/graph.rs @@ -291,7 +291,12 @@ pub async fn trigger_build( } }; - let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path); + let creds = crate::pipeline::git::RepoCredentials { + ssh_key_path: Some(agent_clone.config.ssh_key_path.clone()), + auth_token: repo.auth_token.clone(), + auth_username: repo.auth_username.clone(), + }; + let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds); let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) { Ok(p) => p, Err(e) => { diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index 183c14c..d3bb3f1 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -82,6 +82,8 @@ pub struct AddRepositoryRequest { pub git_url: String, #[serde(default = "default_branch")] pub default_branch: String, + pub auth_token: Option, + pub auth_username: Option, pub tracker_type: Option, pub tracker_owner: Option, pub tracker_repo: Option, @@ -284,9 +286,25 @@ pub async fn list_repositories( pub async fn add_repository( Extension(agent): AgentExt, Json(req): Json, -) -> Result>, StatusCode> { +) -> Result>, (StatusCode, String)> { + // Validate repository access before saving + let creds = crate::pipeline::git::RepoCredentials { + ssh_key_path: Some(agent.config.ssh_key_path.clone()), + auth_token: req.auth_token.clone(), + auth_username: req.auth_username.clone(), + }; + + if let Err(e) = crate::pipeline::git::GitOps::test_access(&req.git_url, &creds) { + return Err(( + StatusCode::BAD_REQUEST, + format!("Cannot access repository: {e}"), + )); + } + let mut repo = TrackedRepository::new(req.name, req.git_url); repo.default_branch = req.default_branch; + repo.auth_token = req.auth_token; + repo.auth_username = req.auth_username; repo.tracker_type = req.tracker_type; repo.tracker_owner = req.tracker_owner; repo.tracker_repo = req.tracker_repo; @@ -297,7 +315,7 @@ pub async fn add_repository( .repositories() .insert_one(&repo) .await - .map_err(|_| StatusCode::CONFLICT)?; + .map_err(|_| (StatusCode::CONFLICT, "Repository already exists".to_string()))?; Ok(Json(ApiResponse { data: repo, @@ -306,6 +324,14 @@ pub async fn add_repository( })) } +pub async fn get_ssh_public_key( + Extension(agent): AgentExt, +) -> Result, StatusCode> { + let public_path = format!("{}.pub", agent.config.ssh_key_path); + let public_key = std::fs::read_to_string(&public_path).map_err(|_| StatusCode::NOT_FOUND)?; + Ok(Json(serde_json::json!({ "public_key": public_key.trim() }))) +} + pub async fn trigger_scan( Extension(agent): AgentExt, Path(id): Path, diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index cd5edb7..8d42d9c 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -7,6 +7,7 @@ pub fn build_router() -> Router { Router::new() .route("/api/v1/health", get(handlers::health)) .route("/api/v1/stats/overview", get(handlers::stats_overview)) + .route("/api/v1/settings/ssh-public-key", get(handlers::get_ssh_public_key)) .route("/api/v1/repositories", get(handlers::list_repositories)) .route("/api/v1/repositories", post(handlers::add_repository)) .route( diff --git a/compliance-agent/src/config.rs b/compliance-agent/src/config.rs index 612fc7d..f166007 100644 --- a/compliance-agent/src/config.rs +++ b/compliance-agent/src/config.rs @@ -45,6 +45,8 @@ pub fn load_config() -> Result { .unwrap_or_else(|| "0 0 0 * * *".to_string()), git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH") .unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()), + ssh_key_path: env_var_opt("SSH_KEY_PATH") + .unwrap_or_else(|| "/data/compliance-scanner/ssh/id_ed25519".to_string()), keycloak_url: env_var_opt("KEYCLOAK_URL"), keycloak_realm: env_var_opt("KEYCLOAK_REALM"), }) diff --git a/compliance-agent/src/main.rs b/compliance-agent/src/main.rs index c67ac85..97ec23e 100644 --- a/compliance-agent/src/main.rs +++ b/compliance-agent/src/main.rs @@ -7,6 +7,7 @@ mod llm; mod pipeline; mod rag; mod scheduler; +mod ssh; #[allow(dead_code)] mod trackers; mod webhooks; @@ -20,6 +21,12 @@ async fn main() -> Result<(), Box> { tracing::info!("Loading configuration..."); let config = config::load_config()?; + // Ensure SSH key pair exists for cloning private repos + match ssh::ensure_ssh_key(&config.ssh_key_path) { + Ok(pubkey) => tracing::info!("SSH public key: {}", pubkey.trim()), + Err(e) => tracing::warn!("SSH key generation skipped: {e}"), + } + tracing::info!("Connecting to MongoDB..."); let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?; db.ensure_indexes().await?; diff --git a/compliance-agent/src/pipeline/git.rs b/compliance-agent/src/pipeline/git.rs index 3b6a01c..2585040 100644 --- a/compliance-agent/src/pipeline/git.rs +++ b/compliance-agent/src/pipeline/git.rs @@ -1,17 +1,82 @@ use std::path::{Path, PathBuf}; -use git2::{FetchOptions, Repository}; +use git2::{Cred, FetchOptions, RemoteCallbacks, Repository}; use crate::error::AgentError; +/// Credentials for accessing a private repository +#[derive(Debug, Clone, Default)] +pub struct RepoCredentials { + /// Path to the SSH private key (for SSH URLs) + pub ssh_key_path: Option, + /// Auth token / password (for HTTPS URLs) + pub auth_token: Option, + /// Username for HTTPS auth (defaults to "x-access-token") + pub auth_username: Option, +} + +impl RepoCredentials { + pub(crate) fn make_callbacks(&self) -> RemoteCallbacks<'_> { + let mut callbacks = RemoteCallbacks::new(); + let ssh_key = self.ssh_key_path.clone(); + let token = self.auth_token.clone(); + let username = self.auth_username.clone(); + + callbacks.credentials(move |_url, username_from_url, allowed_types| { + // SSH key authentication + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + if let Some(ref key_path) = ssh_key { + let key = Path::new(key_path); + if key.exists() { + let user = username_from_url.unwrap_or("git"); + return Cred::ssh_key(user, None, key, None); + } + } + } + + // HTTPS userpass authentication + if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { + if let Some(ref tok) = token { + let user = username + .as_deref() + .unwrap_or("x-access-token"); + return Cred::userpass_plaintext(user, tok); + } + } + + Cred::default() + }); + + callbacks + } + + fn fetch_options(&self) -> FetchOptions<'_> { + let mut fetch_opts = FetchOptions::new(); + if self.has_credentials() { + fetch_opts.remote_callbacks(self.make_callbacks()); + } + fetch_opts + } + + fn has_credentials(&self) -> bool { + self.ssh_key_path + .as_ref() + .map(|p| Path::new(p).exists()) + .unwrap_or(false) + || self.auth_token.is_some() + } +} + pub struct GitOps { base_path: PathBuf, + credentials: RepoCredentials, } impl GitOps { - pub fn new(base_path: &str) -> Self { + pub fn new(base_path: &str, credentials: RepoCredentials) -> Self { Self { base_path: PathBuf::from(base_path), + credentials, } } @@ -22,17 +87,25 @@ impl GitOps { self.fetch(&repo_path)?; } else { std::fs::create_dir_all(&repo_path)?; - Repository::clone(git_url, &repo_path)?; + self.clone_repo(git_url, &repo_path)?; tracing::info!("Cloned {git_url} to {}", repo_path.display()); } Ok(repo_path) } + fn clone_repo(&self, git_url: &str, repo_path: &Path) -> Result<(), AgentError> { + let mut builder = git2::build::RepoBuilder::new(); + let fetch_opts = self.credentials.fetch_options(); + builder.fetch_options(fetch_opts); + builder.clone(git_url, repo_path)?; + Ok(()) + } + fn fetch(&self, repo_path: &Path) -> Result<(), AgentError> { let repo = Repository::open(repo_path)?; let mut remote = repo.find_remote("origin")?; - let mut fetch_opts = FetchOptions::new(); + let mut fetch_opts = self.credentials.fetch_options(); remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?; // Fast-forward to origin/HEAD @@ -48,6 +121,15 @@ impl GitOps { Ok(()) } + /// Test that we can access a remote repository (used during add validation) + pub fn test_access(git_url: &str, credentials: &RepoCredentials) -> Result<(), AgentError> { + let mut remote = git2::Remote::create_detached(git_url)?; + let callbacks = credentials.make_callbacks(); + remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None)?; + remote.disconnect()?; + Ok(()) + } + pub fn get_head_sha(repo_path: &Path) -> Result { let repo = Repository::open(repo_path)?; let head = repo.head()?; diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index a9d1ac5..6452c09 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -11,7 +11,7 @@ use crate::error::AgentError; use crate::llm::LlmClient; use crate::pipeline::code_review::CodeReviewScanner; use crate::pipeline::cve::CveScanner; -use crate::pipeline::git::GitOps; +use crate::pipeline::git::{GitOps, RepoCredentials}; use crate::pipeline::gitleaks::GitleaksScanner; use crate::pipeline::lint::LintScanner; use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner}; @@ -117,7 +117,12 @@ impl PipelineOrchestrator { // Stage 0: Change detection tracing::info!("[{repo_id}] Stage 0: Change detection"); - let git_ops = GitOps::new(&self.config.git_clone_base_path); + let creds = RepoCredentials { + ssh_key_path: Some(self.config.ssh_key_path.clone()), + auth_token: repo.auth_token.clone(), + auth_username: repo.auth_username.clone(), + }; + let git_ops = GitOps::new(&self.config.git_clone_base_path, creds); let repo_path = git_ops.clone_or_fetch(&repo.git_url, &repo.name)?; if !GitOps::has_new_commits(&repo_path, repo.last_scanned_commit.as_deref())? { diff --git a/compliance-agent/src/ssh.rs b/compliance-agent/src/ssh.rs new file mode 100644 index 0000000..470565d --- /dev/null +++ b/compliance-agent/src/ssh.rs @@ -0,0 +1,57 @@ +use std::path::Path; + +use crate::error::AgentError; + +/// Ensure the SSH key pair exists at the given path, generating it if missing. +/// Returns the public key contents. +pub fn ensure_ssh_key(key_path: &str) -> Result { + let private_path = Path::new(key_path); + let public_path = private_path.with_extension("pub"); + + if private_path.exists() && public_path.exists() { + return std::fs::read_to_string(&public_path).map_err(|e| { + AgentError::Config(format!("Failed to read SSH public key: {e}")) + }); + } + + // Create parent directory + if let Some(parent) = private_path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Generate ed25519 key pair using ssh-keygen + let output = std::process::Command::new("ssh-keygen") + .args([ + "-t", + "ed25519", + "-f", + key_path, + "-N", + "", // no passphrase + "-C", + "compliance-scanner-agent", + ]) + .output() + .map_err(|e| AgentError::Config(format!("Failed to run ssh-keygen: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AgentError::Config(format!( + "ssh-keygen failed: {stderr}" + ))); + } + + // Set correct permissions + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(private_path, std::fs::Permissions::from_mode(0o600))?; + } + + let public_key = std::fs::read_to_string(&public_path).map_err(|e| { + AgentError::Config(format!("Failed to read generated SSH public key: {e}")) + })?; + + tracing::info!("Generated new SSH key pair at {key_path}"); + Ok(public_key) +} diff --git a/compliance-core/src/config.rs b/compliance-core/src/config.rs index aba5725..de60b26 100644 --- a/compliance-core/src/config.rs +++ b/compliance-core/src/config.rs @@ -24,6 +24,7 @@ pub struct AgentConfig { pub scan_schedule: String, pub cve_monitor_schedule: String, pub git_clone_base_path: String, + pub ssh_key_path: String, pub keycloak_url: Option, pub keycloak_realm: Option, } diff --git a/compliance-core/src/models/repository.rs b/compliance-core/src/models/repository.rs index cc43c30..6842ba2 100644 --- a/compliance-core/src/models/repository.rs +++ b/compliance-core/src/models/repository.rs @@ -28,6 +28,12 @@ pub struct TrackedRepository { pub tracker_type: Option, pub tracker_owner: Option, pub tracker_repo: Option, + /// Optional auth token for HTTPS private repos (PAT or password) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_token: Option, + /// Optional username for HTTPS auth (defaults to "x-access-token" for PATs) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_username: Option, pub last_scanned_commit: Option, #[serde(default, deserialize_with = "deserialize_findings_count")] pub findings_count: u32, @@ -64,6 +70,8 @@ impl TrackedRepository { default_branch: "main".to_string(), local_path: None, scan_schedule: None, + auth_token: None, + auth_username: None, webhook_enabled: false, tracker_type: None, tracker_owner: None, diff --git a/compliance-dashboard/src/infrastructure/repositories.rs b/compliance-dashboard/src/infrastructure/repositories.rs index 0e4fe29..bb2ce28 100644 --- a/compliance-dashboard/src/infrastructure/repositories.rs +++ b/compliance-dashboard/src/infrastructure/repositories.rs @@ -34,19 +34,29 @@ pub async fn add_repository( name: String, git_url: String, default_branch: String, + auth_token: Option, + auth_username: Option, ) -> Result<(), ServerFnError> { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; let url = format!("{}/api/v1/repositories", state.agent_api_url); + let mut body = serde_json::json!({ + "name": name, + "git_url": git_url, + "default_branch": default_branch, + }); + if let Some(token) = auth_token.filter(|t| !t.is_empty()) { + body["auth_token"] = serde_json::Value::String(token); + } + if let Some(username) = auth_username.filter(|u| !u.is_empty()) { + body["auth_username"] = serde_json::Value::String(username); + } + let client = reqwest::Client::new(); let resp = client .post(&url) - .json(&serde_json::json!({ - "name": name, - "git_url": git_url, - "default_branch": default_branch, - })) + .json(&body) .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; @@ -61,6 +71,32 @@ pub async fn add_repository( Ok(()) } +#[server] +pub async fn fetch_ssh_public_key() -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/settings/ssh-public-key", state.agent_api_url); + + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + if !resp.status().is_success() { + return Err(ServerFnError::new("SSH key not available".to_string())); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(body + .get("public_key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string()) +} + #[server] pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> { let state: super::server_state::ServerState = diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index 779305c..3f91d11 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -23,6 +23,12 @@ pub fn RepositoriesPage() -> Element { let mut name = use_signal(String::new); let mut git_url = use_signal(String::new); let mut branch = use_signal(|| "main".to_string()); + let mut auth_token = use_signal(String::new); + let mut auth_username = use_signal(String::new); + let mut show_auth = use_signal(|| false); + let mut show_ssh_key = use_signal(|| false); + let mut ssh_public_key = use_signal(String::new); + let mut adding = use_signal(|| false); let mut toasts = use_context::(); let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name) let mut scanning_ids = use_signal(Vec::::new); @@ -66,7 +72,7 @@ pub fn RepositoriesPage() -> Element { label { "Git URL" } input { r#type: "text", - placeholder: "https://github.com/org/repo.git", + placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git", value: "{git_url}", oninput: move |e| git_url.set(e.value()), } @@ -80,26 +86,105 @@ pub fn RepositoriesPage() -> Element { oninput: move |e| branch.set(e.value()), } } + + // Private repo auth section + div { style: "margin-top: 8px;", + button { + class: "btn btn-ghost", + style: "font-size: 12px; padding: 4px 8px;", + onclick: move |_| { + show_auth.toggle(); + if !show_ssh_key() { + // Fetch SSH key on first open + show_ssh_key.set(true); + spawn(async move { + match crate::infrastructure::repositories::fetch_ssh_public_key().await { + Ok(key) => ssh_public_key.set(key), + Err(_) => ssh_public_key.set("(not available)".to_string()), + } + }); + } + }, + if show_auth() { "Hide auth options" } else { "Private repository?" } + } + } + + if show_auth() { + div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;", + // SSH deploy key display + div { style: "margin-bottom: 12px;", + label { style: "font-size: 12px; color: var(--text-secondary);", + "For SSH URLs: add this deploy key (read-only) to your repository" + } + div { + style: "margin-top: 4px; padding: 8px; background: var(--bg-secondary); border-radius: 4px; font-family: monospace; font-size: 11px; word-break: break-all; user-select: all;", + if ssh_public_key().is_empty() { + "Loading..." + } else { + "{ssh_public_key}" + } + } + } + + // HTTPS auth fields + p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;", + "For HTTPS URLs: provide an access token (PAT) or username/password" + } + div { class: "form-group", + label { "Auth Token / Password" } + input { + r#type: "password", + placeholder: "ghp_xxxx or personal access token", + value: "{auth_token}", + oninput: move |e| auth_token.set(e.value()), + } + } + div { class: "form-group", + label { "Username (optional, defaults to x-access-token)" } + input { + r#type: "text", + placeholder: "x-access-token", + value: "{auth_username}", + oninput: move |e| auth_username.set(e.value()), + } + } + } + } + button { class: "btn btn-primary", + disabled: adding(), onclick: move |_| { let n = name(); let u = git_url(); let b = branch(); + let tok = { + let v = auth_token(); + if v.is_empty() { None } else { Some(v) } + }; + let usr = { + let v = auth_username(); + if v.is_empty() { None } else { Some(v) } + }; + adding.set(true); spawn(async move { - match crate::infrastructure::repositories::add_repository(n, u, b).await { + match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr).await { Ok(_) => { toasts.push(ToastType::Success, "Repository added"); repos.restart(); } Err(e) => toasts.push(ToastType::Error, e.to_string()), } + adding.set(false); }); show_add_form.set(false); + show_auth.set(false); name.set(String::new()); git_url.set(String::new()); + auth_token.set(String::new()); + auth_username.set(String::new()); }, - "Add" + if adding() { "Validating..." } else { "Add" } } } } -- 2.49.1 From b973887754eb21222283895dbfecc582ace9feb2 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 9 Mar 2026 12:02:31 +0100 Subject: [PATCH 3/6] feat: seed default MCP servers (Findings, SBOM, DAST) on dashboard startup - Add MCP_ENDPOINT_URL env var to configure MCP server base URL - Seed three default MCP server configs on dashboard startup if not present - Each server has its own tool subset: findings (3 tools), SBOM (2 tools), DAST (2 tools) - Uses upsert-by-name to avoid duplicates on restart Co-Authored-By: Claude Opus 4.6 --- .env.example | 3 + compliance-core/src/config.rs | 1 + .../src/infrastructure/config.rs | 1 + .../src/infrastructure/server.rs | 79 +++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/.env.example b/.env.example index e8d8ec7..f5e9f4f 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,9 @@ GIT_CLONE_BASE_PATH=/tmp/compliance-scanner/repos DASHBOARD_PORT=8080 AGENT_API_URL=http://localhost:3001 +# MCP Server +MCP_ENDPOINT_URL=http://localhost:8090 + # Keycloak (required for authentication) KEYCLOAK_URL=http://localhost:8080 KEYCLOAK_REALM=compliance diff --git a/compliance-core/src/config.rs b/compliance-core/src/config.rs index de60b26..401f9a8 100644 --- a/compliance-core/src/config.rs +++ b/compliance-core/src/config.rs @@ -35,4 +35,5 @@ pub struct DashboardConfig { pub mongodb_database: String, pub agent_api_url: String, pub dashboard_port: u16, + pub mcp_endpoint_url: Option, } diff --git a/compliance-dashboard/src/infrastructure/config.rs b/compliance-dashboard/src/infrastructure/config.rs index 953d931..2781328 100644 --- a/compliance-dashboard/src/infrastructure/config.rs +++ b/compliance-dashboard/src/infrastructure/config.rs @@ -14,5 +14,6 @@ pub fn load_config() -> Result { .ok() .and_then(|p| p.parse().ok()) .unwrap_or(8080), + mcp_endpoint_url: std::env::var("MCP_ENDPOINT_URL").ok().filter(|v| !v.is_empty()), }) } diff --git a/compliance-dashboard/src/infrastructure/server.rs b/compliance-dashboard/src/infrastructure/server.rs index f28fc05..e526596 100644 --- a/compliance-dashboard/src/infrastructure/server.rs +++ b/compliance-dashboard/src/infrastructure/server.rs @@ -4,6 +4,9 @@ use dioxus::prelude::*; use time::Duration; use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer}; +use compliance_core::models::{McpServerConfig, McpServerStatus, McpTransport}; +use mongodb::bson::doc; + use super::config; use super::database::Database; use super::error::DashboardError; @@ -22,6 +25,9 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> { KeycloakConfig::from_env().map(|kc| &*Box::leak(Box::new(kc))); let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?; + // Seed default MCP server configs + seed_default_mcp_servers(&db, config.mcp_endpoint_url.as_deref()).await; + if let Some(kc) = keycloak { tracing::info!("Keycloak configured for realm '{}'", kc.realm); } else { @@ -70,3 +76,76 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> { Ok(()) }) } + +/// Seed three default MCP server configs (Findings, SBOM, DAST) if they don't already exist. +async fn seed_default_mcp_servers(db: &Database, mcp_endpoint_url: Option<&str>) { + let endpoint = mcp_endpoint_url.unwrap_or("http://localhost:8090"); + + let defaults = [ + ( + "Findings MCP", + "Exposes security findings, triage data, and finding summaries to LLM agents", + vec![ + "list_findings", + "get_finding", + "findings_summary", + ], + ), + ( + "SBOM MCP", + "Exposes software bill of materials and vulnerability reports to LLM agents", + vec![ + "list_sbom_packages", + "sbom_vuln_report", + ], + ), + ( + "DAST MCP", + "Exposes DAST scan findings and scan summaries to LLM agents", + vec![ + "list_dast_findings", + "dast_scan_summary", + ], + ), + ]; + + let collection = db.mcp_servers(); + + for (name, description, tools) in defaults { + // Skip if already exists + let exists = collection + .find_one(doc! { "name": name }) + .await + .ok() + .flatten() + .is_some(); + + if exists { + continue; + } + + let now = chrono::Utc::now(); + let token = format!("mcp_{}", uuid::Uuid::new_v4().to_string().replace('-', "")); + + let server = McpServerConfig { + id: None, + name: name.to_string(), + endpoint_url: format!("{endpoint}/mcp"), + transport: McpTransport::Http, + port: Some(8090), + status: McpServerStatus::Stopped, + access_token: token, + tools_enabled: tools.into_iter().map(|s| s.to_string()).collect(), + description: Some(description.to_string()), + mongodb_uri: None, + mongodb_database: None, + created_at: now, + updated_at: now, + }; + + match collection.insert_one(server).await { + Ok(_) => tracing::info!("Seeded default MCP server: {name}"), + Err(e) => tracing::warn!("Failed to seed MCP server '{name}': {e}"), + } + } +} -- 2.49.1 From d9b21d3410f3cedcb21cc9ce75e5b0a4399d0e81 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 9 Mar 2026 12:06:03 +0100 Subject: [PATCH 4/6] fix: refresh findings list after bulk status update Co-Authored-By: Claude Opus 4.6 --- compliance-dashboard/src/pages/findings.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compliance-dashboard/src/pages/findings.rs b/compliance-dashboard/src/pages/findings.rs index f3aee70..efebf04 100644 --- a/compliance-dashboard/src/pages/findings.rs +++ b/compliance-dashboard/src/pages/findings.rs @@ -23,7 +23,7 @@ pub fn FindingsPage() -> Element { .ok() }); - let findings = use_resource(move || { + let mut findings = use_resource(move || { let p = page(); let sev = severity_filter(); let typ = type_filter(); @@ -162,6 +162,7 @@ pub fn FindingsPage() -> Element { let s = status_str.clone(); spawn(async move { let _ = crate::infrastructure::findings::bulk_update_finding_status(ids, s).await; + findings.restart(); }); selected_ids.set(Vec::new()); }, -- 2.49.1 From 3958c1a036fa288588f1c60846c16469e9d61475 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 9 Mar 2026 12:08:55 +0100 Subject: [PATCH 5/6] style: fix cargo fmt formatting Co-Authored-By: Claude Opus 4.6 --- compliance-agent/src/api/handlers/chat.rs | 3 +- compliance-agent/src/api/handlers/graph.rs | 3 +- compliance-agent/src/api/handlers/mod.rs | 7 +++- compliance-agent/src/api/routes.rs | 5 ++- compliance-agent/src/llm/triage.rs | 15 ++++++-- compliance-agent/src/pipeline/cve.rs | 29 +++++++-------- compliance-agent/src/pipeline/git.rs | 4 +- compliance-agent/src/pipeline/gitleaks.rs | 21 +++++++++-- compliance-agent/src/pipeline/lint.rs | 37 ++++++++++--------- compliance-agent/src/ssh.rs | 14 +++---- compliance-core/src/models/mod.rs | 2 +- compliance-core/src/models/repository.rs | 10 ++++- compliance-core/src/models/serde_helpers.rs | 4 +- .../src/infrastructure/config.rs | 4 +- .../src/infrastructure/findings.rs | 5 +-- .../src/infrastructure/repositories.rs | 5 +-- .../src/infrastructure/server.rs | 16 ++------ 17 files changed, 99 insertions(+), 85 deletions(-) diff --git a/compliance-agent/src/api/handlers/chat.rs b/compliance-agent/src/api/handlers/chat.rs index 9413f99..0fafe85 100644 --- a/compliance-agent/src/api/handlers/chat.rs +++ b/compliance-agent/src/api/handlers/chat.rs @@ -192,7 +192,8 @@ pub async fn build_embeddings( auth_token: repo.auth_token.clone(), auth_username: repo.auth_username.clone(), }; - let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds); + let git_ops = + crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds); let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) { Ok(p) => p, Err(e) => { diff --git a/compliance-agent/src/api/handlers/graph.rs b/compliance-agent/src/api/handlers/graph.rs index bfbafec..a797ee7 100644 --- a/compliance-agent/src/api/handlers/graph.rs +++ b/compliance-agent/src/api/handlers/graph.rs @@ -296,7 +296,8 @@ pub async fn trigger_build( auth_token: repo.auth_token.clone(), auth_username: repo.auth_username.clone(), }; - let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds); + let git_ops = + crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds); let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) { Ok(p) => p, Err(e) => { diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index d3bb3f1..a9a8801 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -315,7 +315,12 @@ pub async fn add_repository( .repositories() .insert_one(&repo) .await - .map_err(|_| (StatusCode::CONFLICT, "Repository already exists".to_string()))?; + .map_err(|_| { + ( + StatusCode::CONFLICT, + "Repository already exists".to_string(), + ) + })?; Ok(Json(ApiResponse { data: repo, diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index 8d42d9c..f355040 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -7,7 +7,10 @@ pub fn build_router() -> Router { Router::new() .route("/api/v1/health", get(handlers::health)) .route("/api/v1/stats/overview", get(handlers::stats_overview)) - .route("/api/v1/settings/ssh-public-key", get(handlers::get_ssh_public_key)) + .route( + "/api/v1/settings/ssh-public-key", + get(handlers::get_ssh_public_key), + ) .route("/api/v1/repositories", get(handlers::list_repositories)) .route("/api/v1/repositories", post(handlers::add_repository)) .route( diff --git a/compliance-agent/src/llm/triage.rs b/compliance-agent/src/llm/triage.rs index e641bcb..62d056d 100644 --- a/compliance-agent/src/llm/triage.rs +++ b/compliance-agent/src/llm/triage.rs @@ -47,7 +47,9 @@ pub async fn triage_findings( // Enrich with surrounding code context if possible if let Some(context) = read_surrounding_context(finding) { - user_prompt.push_str(&format!("\n\n--- Surrounding Code (50 lines) ---\n{context}")); + user_prompt.push_str(&format!( + "\n\n--- Surrounding Code (50 lines) ---\n{context}" + )); } // Enrich with graph context if available @@ -98,7 +100,8 @@ pub async fn triage_findings( }; if let Ok(result) = serde_json::from_str::(cleaned) { // Apply file-path confidence adjustment - let adjusted_confidence = adjust_confidence(result.confidence, &file_classification); + let adjusted_confidence = + adjust_confidence(result.confidence, &file_classification); finding.confidence = Some(adjusted_confidence); finding.triage_action = Some(result.action.clone()); finding.triage_rationale = Some(result.rationale); @@ -235,7 +238,9 @@ fn adjust_confidence(raw_confidence: f64, classification: &str) -> f64 { raw_confidence * multiplier } -fn downgrade_severity(severity: &compliance_core::models::Severity) -> compliance_core::models::Severity { +fn downgrade_severity( + severity: &compliance_core::models::Severity, +) -> compliance_core::models::Severity { use compliance_core::models::Severity; match severity { Severity::Critical => Severity::High, @@ -246,7 +251,9 @@ fn downgrade_severity(severity: &compliance_core::models::Severity) -> complianc } } -fn upgrade_severity(severity: &compliance_core::models::Severity) -> compliance_core::models::Severity { +fn upgrade_severity( + severity: &compliance_core::models::Severity, +) -> compliance_core::models::Severity { use compliance_core::models::Severity; match severity { Severity::Info => Severity::Low, diff --git a/compliance-agent/src/pipeline/cve.rs b/compliance-agent/src/pipeline/cve.rs index 63649cb..0a8e8b1 100644 --- a/compliance-agent/src/pipeline/cve.rs +++ b/compliance-agent/src/pipeline/cve.rs @@ -108,22 +108,19 @@ impl CveScanner { .await .map_err(|e| CoreError::Http(format!("Failed to parse OSV.dev response: {e}")))?; - let chunk_vulns = result - .results - .into_iter() - .map(|r| { - r.vulns - .unwrap_or_default() - .into_iter() - .map(|v| OsvVuln { - id: v.id, - summary: v.summary, - severity: v.database_specific.and_then(|d| { - d.get("severity").and_then(|s| s.as_str()).map(String::from) - }), - }) - .collect() - }); + let chunk_vulns = result.results.into_iter().map(|r| { + r.vulns + .unwrap_or_default() + .into_iter() + .map(|v| OsvVuln { + id: v.id, + summary: v.summary, + severity: v.database_specific.and_then(|d| { + d.get("severity").and_then(|s| s.as_str()).map(String::from) + }), + }) + .collect() + }); all_vulns.extend(chunk_vulns); } diff --git a/compliance-agent/src/pipeline/git.rs b/compliance-agent/src/pipeline/git.rs index 2585040..3647047 100644 --- a/compliance-agent/src/pipeline/git.rs +++ b/compliance-agent/src/pipeline/git.rs @@ -37,9 +37,7 @@ impl RepoCredentials { // HTTPS userpass authentication if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { if let Some(ref tok) = token { - let user = username - .as_deref() - .unwrap_or("x-access-token"); + let user = username.as_deref().unwrap_or("x-access-token"); return Cred::userpass_plaintext(user, tok); } } diff --git a/compliance-agent/src/pipeline/gitleaks.rs b/compliance-agent/src/pipeline/gitleaks.rs index 032ef8f..5010e39 100644 --- a/compliance-agent/src/pipeline/gitleaks.rs +++ b/compliance-agent/src/pipeline/gitleaks.rs @@ -19,7 +19,18 @@ impl Scanner for GitleaksScanner { async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { let output = tokio::process::Command::new("gitleaks") - .args(["detect", "--source", ".", "--report-format", "json", "--report-path", "/dev/stdout", "--no-banner", "--exit-code", "0"]) + .args([ + "detect", + "--source", + ".", + "--report-format", + "json", + "--report-path", + "/dev/stdout", + "--no-banner", + "--exit-code", + "0", + ]) .current_dir(repo_path) .output() .await @@ -32,8 +43,8 @@ impl Scanner for GitleaksScanner { return Ok(ScanOutput::default()); } - let results: Vec = serde_json::from_slice(&output.stdout) - .unwrap_or_default(); + let results: Vec = + serde_json::from_slice(&output.stdout).unwrap_or_default(); let findings = results .into_iter() @@ -41,7 +52,9 @@ impl Scanner for GitleaksScanner { .map(|r| { let severity = match r.rule_id.as_str() { s if s.contains("private-key") => Severity::Critical, - s if s.contains("token") || s.contains("password") || s.contains("secret") => Severity::High, + s if s.contains("token") || s.contains("password") || s.contains("secret") => { + Severity::High + } s if s.contains("api-key") => Severity::High, _ => Severity::Medium, }; diff --git a/compliance-agent/src/pipeline/lint.rs b/compliance-agent/src/pipeline/lint.rs index 1cb767f..721357c 100644 --- a/compliance-agent/src/pipeline/lint.rs +++ b/compliance-agent/src/pipeline/lint.rs @@ -60,8 +60,7 @@ fn has_rust_project(repo_path: &Path) -> bool { fn has_js_project(repo_path: &Path) -> bool { // Only run if eslint is actually installed in the project - repo_path.join("package.json").exists() - && repo_path.join("node_modules/.bin/eslint").exists() + repo_path.join("package.json").exists() && repo_path.join("node_modules/.bin/eslint").exists() } fn has_python_project(repo_path: &Path) -> bool { @@ -99,7 +98,14 @@ async fn run_with_timeout( async fn run_clippy(repo_path: &Path, repo_id: &str) -> Result, CoreError> { let child = Command::new("cargo") - .args(["clippy", "--message-format=json", "--quiet", "--", "-W", "clippy::all"]) + .args([ + "clippy", + "--message-format=json", + "--quiet", + "--", + "-W", + "clippy::all", + ]) .current_dir(repo_path) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -128,10 +134,7 @@ async fn run_clippy(repo_path: &Path, repo_id: &str) -> Result, Cor None => continue, }; - let level = message - .get("level") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let level = message.get("level").and_then(|v| v.as_str()).unwrap_or(""); if level != "warning" && level != "error" { continue; @@ -162,8 +165,13 @@ async fn run_clippy(repo_path: &Path, repo_id: &str) -> Result, Cor Severity::Low }; - let fingerprint = - dedup::compute_fingerprint(&[repo_id, "clippy", &code, &file_path, &line_number.to_string()]); + let fingerprint = dedup::compute_fingerprint(&[ + repo_id, + "clippy", + &code, + &file_path, + &line_number.to_string(), + ]); let mut finding = Finding::new( repo_id.to_string(), @@ -200,10 +208,7 @@ fn extract_primary_span(message: &serde_json::Value) -> (String, u32) { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let line = span - .get("line_start") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32; + let line = span.get("line_start").and_then(|v| v.as_u64()).unwrap_or(0) as u32; return (file, line); } } @@ -233,8 +238,7 @@ async fn run_eslint(repo_path: &Path, repo_id: &str) -> Result, Cor return Ok(Vec::new()); } - let results: Vec = - serde_json::from_slice(&output.stdout).unwrap_or_default(); + let results: Vec = serde_json::from_slice(&output.stdout).unwrap_or_default(); let mut findings = Vec::new(); for file_result in results { @@ -308,8 +312,7 @@ async fn run_ruff(repo_path: &Path, repo_id: &str) -> Result, CoreE return Ok(Vec::new()); } - let results: Vec = - serde_json::from_slice(&output.stdout).unwrap_or_default(); + let results: Vec = serde_json::from_slice(&output.stdout).unwrap_or_default(); let findings = results .into_iter() diff --git a/compliance-agent/src/ssh.rs b/compliance-agent/src/ssh.rs index 470565d..772a0c0 100644 --- a/compliance-agent/src/ssh.rs +++ b/compliance-agent/src/ssh.rs @@ -9,9 +9,8 @@ pub fn ensure_ssh_key(key_path: &str) -> Result { let public_path = private_path.with_extension("pub"); if private_path.exists() && public_path.exists() { - return std::fs::read_to_string(&public_path).map_err(|e| { - AgentError::Config(format!("Failed to read SSH public key: {e}")) - }); + return std::fs::read_to_string(&public_path) + .map_err(|e| AgentError::Config(format!("Failed to read SSH public key: {e}"))); } // Create parent directory @@ -36,9 +35,7 @@ pub fn ensure_ssh_key(key_path: &str) -> Result { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(AgentError::Config(format!( - "ssh-keygen failed: {stderr}" - ))); + return Err(AgentError::Config(format!("ssh-keygen failed: {stderr}"))); } // Set correct permissions @@ -48,9 +45,8 @@ pub fn ensure_ssh_key(key_path: &str) -> Result { std::fs::set_permissions(private_path, std::fs::Permissions::from_mode(0o600))?; } - let public_key = std::fs::read_to_string(&public_path).map_err(|e| { - AgentError::Config(format!("Failed to read generated SSH public key: {e}")) - })?; + let public_key = std::fs::read_to_string(&public_path) + .map_err(|e| AgentError::Config(format!("Failed to read generated SSH public key: {e}")))?; tracing::info!("Generated new SSH key pair at {key_path}"); Ok(public_key) diff --git a/compliance-core/src/models/mod.rs b/compliance-core/src/models/mod.rs index a63ca9e..8d9f064 100644 --- a/compliance-core/src/models/mod.rs +++ b/compliance-core/src/models/mod.rs @@ -1,5 +1,4 @@ pub mod auth; -pub(crate) mod serde_helpers; pub mod chat; pub mod cve; pub mod dast; @@ -11,6 +10,7 @@ pub mod mcp; pub mod repository; pub mod sbom; pub mod scan; +pub(crate) mod serde_helpers; pub use auth::AuthInfo; pub use chat::{ChatMessage, ChatRequest, ChatResponse, SourceReference}; diff --git a/compliance-core/src/models/repository.rs b/compliance-core/src/models/repository.rs index 6842ba2..96fddf8 100644 --- a/compliance-core/src/models/repository.rs +++ b/compliance-core/src/models/repository.rs @@ -37,9 +37,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", with = "super::serde_helpers::bson_datetime")] + #[serde( + default = "chrono::Utc::now", + with = "super::serde_helpers::bson_datetime" + )] pub created_at: DateTime, - #[serde(default = "chrono::Utc::now", with = "super::serde_helpers::bson_datetime")] + #[serde( + default = "chrono::Utc::now", + with = "super::serde_helpers::bson_datetime" + )] pub updated_at: DateTime, } diff --git a/compliance-core/src/models/serde_helpers.rs b/compliance-core/src/models/serde_helpers.rs index b7f6dfd..2f7e347 100644 --- a/compliance-core/src/models/serde_helpers.rs +++ b/compliance-core/src/models/serde_helpers.rs @@ -22,9 +22,7 @@ pub mod bson_datetime { let bson_val = bson::Bson::deserialize(deserializer)?; match bson_val { 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/src/infrastructure/config.rs b/compliance-dashboard/src/infrastructure/config.rs index 2781328..8848a5f 100644 --- a/compliance-dashboard/src/infrastructure/config.rs +++ b/compliance-dashboard/src/infrastructure/config.rs @@ -14,6 +14,8 @@ pub fn load_config() -> Result { .ok() .and_then(|p| p.parse().ok()) .unwrap_or(8080), - mcp_endpoint_url: std::env::var("MCP_ENDPOINT_URL").ok().filter(|v| !v.is_empty()), + mcp_endpoint_url: std::env::var("MCP_ENDPOINT_URL") + .ok() + .filter(|v| !v.is_empty()), }) } diff --git a/compliance-dashboard/src/infrastructure/findings.rs b/compliance-dashboard/src/infrastructure/findings.rs index 92ed396..eefe518 100644 --- a/compliance-dashboard/src/infrastructure/findings.rs +++ b/compliance-dashboard/src/infrastructure/findings.rs @@ -120,10 +120,7 @@ pub async fn bulk_update_finding_status( } #[server] -pub async fn update_finding_feedback( - id: String, - feedback: String, -) -> Result<(), ServerFnError> { +pub async fn update_finding_feedback(id: String, feedback: String) -> Result<(), ServerFnError> { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; let url = format!("{}/api/v1/findings/{id}/feedback", state.agent_api_url); diff --git a/compliance-dashboard/src/infrastructure/repositories.rs b/compliance-dashboard/src/infrastructure/repositories.rs index bb2ce28..6f55ae6 100644 --- a/compliance-dashboard/src/infrastructure/repositories.rs +++ b/compliance-dashboard/src/infrastructure/repositories.rs @@ -141,10 +141,7 @@ pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> { pub async fn check_repo_scanning(repo_id: String) -> Result { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; - let url = format!( - "{}/api/v1/scan-runs?page=1&limit=1", - state.agent_api_url - ); + let url = format!("{}/api/v1/scan-runs?page=1&limit=1", state.agent_api_url); let resp = reqwest::get(&url) .await diff --git a/compliance-dashboard/src/infrastructure/server.rs b/compliance-dashboard/src/infrastructure/server.rs index e526596..364c396 100644 --- a/compliance-dashboard/src/infrastructure/server.rs +++ b/compliance-dashboard/src/infrastructure/server.rs @@ -85,27 +85,17 @@ async fn seed_default_mcp_servers(db: &Database, mcp_endpoint_url: Option<&str>) ( "Findings MCP", "Exposes security findings, triage data, and finding summaries to LLM agents", - vec![ - "list_findings", - "get_finding", - "findings_summary", - ], + vec!["list_findings", "get_finding", "findings_summary"], ), ( "SBOM MCP", "Exposes software bill of materials and vulnerability reports to LLM agents", - vec![ - "list_sbom_packages", - "sbom_vuln_report", - ], + vec!["list_sbom_packages", "sbom_vuln_report"], ), ( "DAST MCP", "Exposes DAST scan findings and scan summaries to LLM agents", - vec![ - "list_dast_findings", - "dast_scan_summary", - ], + vec!["list_dast_findings", "dast_scan_summary"], ), ]; -- 2.49.1 From b3a284daddee92ac03ab607e9d8edc19a4ef869d Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 9 Mar 2026 13:37:30 +0100 Subject: [PATCH 6/6] fix: refactor fetch_findings to use FindingsQuery struct to fix clippy too_many_arguments Co-Authored-By: Claude Opus 4.6 --- .../src/infrastructure/findings.rs | 56 ++++++++++--------- compliance-dashboard/src/pages/findings.rs | 20 ++++--- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/compliance-dashboard/src/infrastructure/findings.rs b/compliance-dashboard/src/infrastructure/findings.rs index eefe518..41ce95b 100644 --- a/compliance-dashboard/src/infrastructure/findings.rs +++ b/compliance-dashboard/src/infrastructure/findings.rs @@ -10,48 +10,50 @@ pub struct FindingsListResponse { pub page: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FindingsQuery { + pub page: u64, + pub severity: String, + pub scan_type: String, + pub status: String, + pub repo_id: String, + pub q: String, + pub sort_by: String, + pub sort_order: String, +} + #[server] -#[allow(clippy::too_many_arguments)] -pub async fn fetch_findings( - page: u64, - severity: String, - scan_type: String, - status: String, - repo_id: String, - q: String, - sort_by: String, - sort_order: String, -) -> Result { +pub async fn fetch_findings(query: FindingsQuery) -> Result { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; let mut url = format!( - "{}/api/v1/findings?page={page}&limit=20", - state.agent_api_url + "{}/api/v1/findings?page={}&limit=20", + state.agent_api_url, query.page ); - if !severity.is_empty() { - url.push_str(&format!("&severity={severity}")); + if !query.severity.is_empty() { + url.push_str(&format!("&severity={}", query.severity)); } - if !scan_type.is_empty() { - url.push_str(&format!("&scan_type={scan_type}")); + if !query.scan_type.is_empty() { + url.push_str(&format!("&scan_type={}", query.scan_type)); } - if !status.is_empty() { - url.push_str(&format!("&status={status}")); + if !query.status.is_empty() { + url.push_str(&format!("&status={}", query.status)); } - if !repo_id.is_empty() { - url.push_str(&format!("&repo_id={repo_id}")); + if !query.repo_id.is_empty() { + url.push_str(&format!("&repo_id={}", query.repo_id)); } - if !q.is_empty() { + if !query.q.is_empty() { url.push_str(&format!( "&q={}", - url::form_urlencoded::byte_serialize(q.as_bytes()).collect::() + url::form_urlencoded::byte_serialize(query.q.as_bytes()).collect::() )); } - if !sort_by.is_empty() { - url.push_str(&format!("&sort_by={sort_by}")); + if !query.sort_by.is_empty() { + url.push_str(&format!("&sort_by={}", query.sort_by)); } - if !sort_order.is_empty() { - url.push_str(&format!("&sort_order={sort_order}")); + if !query.sort_order.is_empty() { + url.push_str(&format!("&sort_order={}", query.sort_order)); } let resp = reqwest::get(&url) diff --git a/compliance-dashboard/src/pages/findings.rs b/compliance-dashboard/src/pages/findings.rs index efebf04..8b25678 100644 --- a/compliance-dashboard/src/pages/findings.rs +++ b/compliance-dashboard/src/pages/findings.rs @@ -24,16 +24,18 @@ pub fn FindingsPage() -> Element { }); let mut findings = use_resource(move || { - let p = page(); - let sev = severity_filter(); - let typ = type_filter(); - let stat = status_filter(); - let repo = repo_filter(); - let q = search_query(); - let sb = sort_by(); - let so = sort_order(); + let query = crate::infrastructure::findings::FindingsQuery { + page: page(), + severity: severity_filter(), + scan_type: type_filter(), + status: status_filter(), + repo_id: repo_filter(), + q: search_query(), + sort_by: sort_by(), + sort_order: sort_order(), + }; async move { - crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, repo, q, sb, so) + crate::infrastructure::findings::fetch_findings(query) .await .ok() } -- 2.49.1