From 23cf37b6c37bd506b0242a86dbaca671f02fb801 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 30 Mar 2026 13:10:56 +0000 Subject: [PATCH] fix: CVE notifications during scan + help chat doc loading + Dockerfile (#55) --- Dockerfile.agent | 5 ++ compliance-agent/Cargo.toml | 2 +- .../src/api/handlers/help_chat.rs | 56 ++++++++++---- compliance-agent/src/api/server.rs | 21 +++++- compliance-agent/src/pipeline/orchestrator.rs | 75 +++++++++++++++---- compliance-dashboard/assets/main.css | 12 +++ .../src/components/code_snippet.rs | 19 +++-- .../src/components/copy_button.rs | 49 ++++++++++++ compliance-dashboard/src/components/mod.rs | 1 + compliance-dashboard/src/pages/mcp_servers.rs | 5 +- .../src/pages/repositories.rs | 62 +++++++++------ 11 files changed, 247 insertions(+), 60 deletions(-) create mode 100644 compliance-dashboard/src/components/copy_button.rs diff --git a/Dockerfile.agent b/Dockerfile.agent index b20f8e3..8c54dd2 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -33,6 +33,11 @@ RUN pip3 install --break-system-packages ruff COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent +# Copy documentation for the help chat assistant +COPY --from=builder /app/README.md /app/README.md +COPY --from=builder /app/docs /app/docs +ENV HELP_DOCS_PATH=/app + # Ensure SSH key directory exists RUN mkdir -p /data/compliance-scanner/ssh diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index 5d1f8b0..e0a129f 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -25,7 +25,7 @@ uuid = { workspace = true } secrecy = { workspace = true } regex = { workspace = true } axum = "0.8" -tower-http = { version = "0.6", features = ["cors", "trace"] } +tower-http = { version = "0.6", features = ["cors", "trace", "set-header"] } git2 = "0.20" octocrab = "0.44" tokio-cron-scheduler = "0.13" diff --git a/compliance-agent/src/api/handlers/help_chat.rs b/compliance-agent/src/api/handlers/help_chat.rs index 78a64cf..dfbc4ec 100644 --- a/compliance-agent/src/api/handlers/help_chat.rs +++ b/compliance-agent/src/api/handlers/help_chat.rs @@ -104,28 +104,58 @@ fn load_docs(root: &Path) -> String { /// Returns a reference to the cached doc context string, initialised on /// first call via `OnceLock`. +/// +/// Discovery order: +/// 1. `HELP_DOCS_PATH` env var (explicit override) +/// 2. Walk up from the binary location +/// 3. Current working directory +/// 4. Common Docker paths (/app, /opt/compliance-scanner) fn doc_context() -> &'static str { DOC_CONTEXT.get_or_init(|| { + // 1. Explicit env var + if let Ok(path) = std::env::var("HELP_DOCS_PATH") { + let p = PathBuf::from(&path); + if p.join("README.md").is_file() || p.join("docs").is_dir() { + tracing::info!("help_chat: loading docs from HELP_DOCS_PATH={path}"); + return load_docs(&p); + } + tracing::warn!("help_chat: HELP_DOCS_PATH={path} has no README.md or docs/"); + } + + // 2. Walk up from binary location let start = std::env::current_exe() .ok() .and_then(|p| p.parent().map(Path::to_path_buf)) .unwrap_or_else(|| PathBuf::from(".")); - match find_project_root(&start) { - Some(root) => load_docs(&root), - None => { - // Fallback: try current working directory - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - if cwd.join("README.md").is_file() { - return load_docs(&cwd); - } - tracing::error!( - "help_chat: could not locate project root from {}; doc context will be empty", - start.display() - ); - String::new() + if let Some(root) = find_project_root(&start) { + return load_docs(&root); + } + + // 3. Current working directory + if let Ok(cwd) = std::env::current_dir() { + if let Some(root) = find_project_root(&cwd) { + return load_docs(&root); + } + if cwd.join("README.md").is_file() { + return load_docs(&cwd); } } + + // 4. Common Docker/deployment paths + for candidate in ["/app", "/opt/compliance-scanner", "/srv/compliance-scanner"] { + let p = PathBuf::from(candidate); + if p.join("README.md").is_file() || p.join("docs").is_dir() { + tracing::info!("help_chat: found docs at {candidate}"); + return load_docs(&p); + } + } + + tracing::error!( + "help_chat: could not locate project root; doc context will be empty. \ + Set HELP_DOCS_PATH to the directory containing README.md and docs/" + ); + String::new() }) } diff --git a/compliance-agent/src/api/server.rs b/compliance-agent/src/api/server.rs index 3038083..9b89714 100644 --- a/compliance-agent/src/api/server.rs +++ b/compliance-agent/src/api/server.rs @@ -1,8 +1,10 @@ use std::sync::Arc; +use axum::http::HeaderValue; use axum::{middleware, Extension}; use tokio::sync::RwLock; use tower_http::cors::CorsLayer; +use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; use crate::agent::ComplianceAgent; @@ -14,7 +16,24 @@ pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), A let mut app = routes::build_router() .layer(Extension(Arc::new(agent.clone()))) .layer(CorsLayer::permissive()) - .layer(TraceLayer::new_for_http()); + .layer(TraceLayer::new_for_http()) + // Security headers (defense-in-depth, primary enforcement via Traefik) + .layer(SetResponseHeaderLayer::overriding( + axum::http::header::STRICT_TRANSPORT_SECURITY, + HeaderValue::from_static("max-age=31536000; includeSubDomains"), + )) + .layer(SetResponseHeaderLayer::overriding( + axum::http::header::X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) + .layer(SetResponseHeaderLayer::overriding( + axum::http::header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::overriding( + axum::http::header::REFERRER_POLICY, + HeaderValue::from_static("strict-origin-when-cross-origin"), + )); if let (Some(kc_url), Some(kc_realm)) = (&agent.config.keycloak_url, &agent.config.keycloak_realm) diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index b02dc7a..9a68606 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -315,20 +315,67 @@ impl PipelineOrchestrator { .await?; } - // Persist CVE alerts (upsert by cve_id + repo_id) - for alert in &cve_alerts { - let filter = doc! { - "cve_id": &alert.cve_id, - "repo_id": &alert.repo_id, - }; - let update = mongodb::bson::to_document(alert) - .map(|d| doc! { "$set": d }) - .unwrap_or_else(|_| doc! {}); - self.db - .cve_alerts() - .update_one(filter, update) - .upsert(true) - .await?; + // Persist CVE alerts and create notifications + { + use compliance_core::models::notification::{parse_severity, CveNotification}; + + let repo_name = repo.name.clone(); + let mut new_notif_count = 0u32; + + for alert in &cve_alerts { + // Upsert the alert + let filter = doc! { + "cve_id": &alert.cve_id, + "repo_id": &alert.repo_id, + }; + let update = mongodb::bson::to_document(alert) + .map(|d| doc! { "$set": d }) + .unwrap_or_else(|_| doc! {}); + self.db + .cve_alerts() + .update_one(filter, update) + .upsert(true) + .await?; + + // Create notification (dedup by cve_id + repo + package + version) + let notif_filter = doc! { + "cve_id": &alert.cve_id, + "repo_id": &alert.repo_id, + "package_name": &alert.affected_package, + "package_version": &alert.affected_version, + }; + let severity = parse_severity(alert.severity.as_deref(), alert.cvss_score); + let mut notification = CveNotification::new( + alert.cve_id.clone(), + repo_id.clone(), + repo_name.clone(), + alert.affected_package.clone(), + alert.affected_version.clone(), + severity, + ); + notification.cvss_score = alert.cvss_score; + notification.summary = alert.summary.clone(); + notification.url = Some(format!("https://osv.dev/vulnerability/{}", alert.cve_id)); + + let notif_update = doc! { + "$setOnInsert": mongodb::bson::to_bson(¬ification).unwrap_or_default() + }; + if let Ok(result) = self + .db + .cve_notifications() + .update_one(notif_filter, notif_update) + .upsert(true) + .await + { + if result.upserted_id.is_some() { + new_notif_count += 1; + } + } + } + + if new_notif_count > 0 { + tracing::info!("[{repo_id}] Created {new_notif_count} CVE notification(s)"); + } } // Stage 6: Issue Creation diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index c4aec6a..aed1f8c 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -3877,3 +3877,15 @@ tbody tr:last-child td { .notification-item-pkg { font-size: 12px; color: var(--text-primary); font-family: 'JetBrains Mono', monospace; } .notification-item-repo { font-size: 11px; color: var(--text-secondary); margin-bottom: 4px; } .notification-item-summary { font-size: 11px; color: var(--text-secondary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + +/* ═══════════════════════════════════════════════════════════════ + COPY BUTTON — Reusable clipboard copy component + ═══════════════════════════════════════════════════════════════ */ +.copy-btn { background: none; border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; color: var(--text-secondary); cursor: pointer; display: inline-flex; align-items: center; transition: color 0.15s, border-color 0.15s, background 0.15s; flex-shrink: 0; } +.copy-btn:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-muted); } +.copy-btn-sm { padding: 3px 5px; border-radius: 4px; } +/* Copyable inline field pattern: value + copy button side by side */ +.copyable { display: flex; align-items: center; gap: 6px; } +.copyable code, .copyable .mono { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.code-snippet-wrapper { position: relative; } +.code-snippet-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; gap: 8px; } diff --git a/compliance-dashboard/src/components/code_snippet.rs b/compliance-dashboard/src/components/code_snippet.rs index b16f115..95ea14b 100644 --- a/compliance-dashboard/src/components/code_snippet.rs +++ b/compliance-dashboard/src/components/code_snippet.rs @@ -1,5 +1,7 @@ use dioxus::prelude::*; +use crate::components::copy_button::CopyButton; + #[component] pub fn CodeSnippet( code: String, @@ -7,15 +9,18 @@ pub fn CodeSnippet( #[props(default)] line_number: u32, ) -> Element { rsx! { - div { - if !file_path.is_empty() { - div { - style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; font-family: monospace;", - "{file_path}" - if line_number > 0 { - ":{line_number}" + div { class: "code-snippet-wrapper", + div { class: "code-snippet-header", + if !file_path.is_empty() { + span { + style: "font-size: 12px; color: var(--text-secondary); font-family: monospace;", + "{file_path}" + if line_number > 0 { + ":{line_number}" + } } } + CopyButton { value: code.clone(), small: true } } pre { class: "code-block", "{code}" } } diff --git a/compliance-dashboard/src/components/copy_button.rs b/compliance-dashboard/src/components/copy_button.rs new file mode 100644 index 0000000..809d387 --- /dev/null +++ b/compliance-dashboard/src/components/copy_button.rs @@ -0,0 +1,49 @@ +use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +use dioxus_free_icons::Icon; + +/// A small copy-to-clipboard button that shows a checkmark after copying. +/// +/// Usage: `CopyButton { value: "text to copy" }` +#[component] +pub fn CopyButton(value: String, #[props(default = false)] small: bool) -> Element { + let mut copied = use_signal(|| false); + + let size = if small { 12 } else { 14 }; + let class = if small { + "copy-btn copy-btn-sm" + } else { + "copy-btn" + }; + + rsx! { + button { + class: class, + title: if copied() { "Copied!" } else { "Copy to clipboard" }, + onclick: move |_| { + let val = value.clone(); + // Escape for JS single-quoted string + let escaped = val + .replace('\\', "\\\\") + .replace('\'', "\\'") + .replace('\n', "\\n") + .replace('\r', "\\r"); + let js = format!("navigator.clipboard.writeText('{escaped}')"); + document::eval(&js); + copied.set(true); + spawn(async move { + #[cfg(feature = "web")] + gloo_timers::future::TimeoutFuture::new(2000).await; + #[cfg(not(feature = "web"))] + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + copied.set(false); + }); + }, + if copied() { + Icon { icon: BsCheckLg, width: size, height: size } + } else { + Icon { icon: BsClipboard, width: size, height: size } + } + } + } +} diff --git a/compliance-dashboard/src/components/mod.rs b/compliance-dashboard/src/components/mod.rs index a5e7bce..6deaa91 100644 --- a/compliance-dashboard/src/components/mod.rs +++ b/compliance-dashboard/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod app_shell; pub mod attack_chain; pub mod code_inspector; pub mod code_snippet; +pub mod copy_button; pub mod file_tree; pub mod help_chat; pub mod notification_bell; diff --git a/compliance-dashboard/src/pages/mcp_servers.rs b/compliance-dashboard/src/pages/mcp_servers.rs index 2a47f37..15eea79 100644 --- a/compliance-dashboard/src/pages/mcp_servers.rs +++ b/compliance-dashboard/src/pages/mcp_servers.rs @@ -259,7 +259,10 @@ pub fn McpServersPage() -> Element { div { class: "mcp-detail-row", Icon { icon: BsGlobe, width: 13, height: 13 } span { class: "mcp-detail-label", "Endpoint" } - code { class: "mcp-detail-value", "{server.endpoint_url}" } + div { class: "copyable", + code { class: "mcp-detail-value", "{server.endpoint_url}" } + crate::components::copy_button::CopyButton { value: server.endpoint_url.clone(), small: true } + } } div { class: "mcp-detail-row", Icon { icon: BsHddNetwork, width: 13, height: 13 } diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index a63e693..22ba48b 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -137,11 +137,18 @@ pub fn RepositoriesPage() -> Element { "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}" + class: "copyable", + style: "margin-top: 4px; padding: 8px; background: var(--bg-secondary); border-radius: 4px;", + code { + style: "font-size: 11px; word-break: break-all; user-select: all;", + if ssh_public_key().is_empty() { + "Loading..." + } else { + "{ssh_public_key}" + } + } + if !ssh_public_key().is_empty() { + crate::components::copy_button::CopyButton { value: ssh_public_key(), small: true } } } } @@ -390,28 +397,37 @@ pub fn RepositoriesPage() -> Element { } div { class: "form-group", label { "Webhook URL" } - input { - r#type: "text", - readonly: true, - style: "font-family: monospace; font-size: 12px;", - value: { - #[cfg(feature = "web")] - let origin = web_sys::window() - .and_then(|w: web_sys::Window| w.location().origin().ok()) - .unwrap_or_default(); - #[cfg(not(feature = "web"))] - let origin = String::new(); - format!("{origin}/webhook/{}/{eid}", edit_webhook_tracker()) - }, + { + #[cfg(feature = "web")] + let origin = web_sys::window() + .and_then(|w: web_sys::Window| w.location().origin().ok()) + .unwrap_or_default(); + #[cfg(not(feature = "web"))] + let origin = String::new(); + let webhook_url = format!("{origin}/webhook/{}/{eid}", edit_webhook_tracker()); + rsx! { + div { class: "copyable", + input { + r#type: "text", + readonly: true, + style: "font-family: monospace; font-size: 12px; flex: 1;", + value: "{webhook_url}", + } + crate::components::copy_button::CopyButton { value: webhook_url.clone() } + } + } } } div { class: "form-group", label { "Webhook Secret" } - input { - r#type: "text", - readonly: true, - style: "font-family: monospace; font-size: 12px;", - value: "{secret}", + div { class: "copyable", + input { + r#type: "text", + readonly: true, + style: "font-family: monospace; font-size: 12px; flex: 1;", + value: "{secret}", + } + crate::components::copy_button::CopyButton { value: secret.clone() } } } }