diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index dbf0e12..8f353dd 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -433,6 +433,32 @@ pub async fn trigger_scan( Ok(Json(serde_json::json!({ "status": "scan_triggered" }))) } +/// Return the webhook secret for a repository (used by dashboard to display it) +pub async fn get_webhook_config( + Extension(agent): AgentExt, + Path(id): Path, +) -> Result, StatusCode> { + let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; + let repo = agent + .db + .repositories() + .find_one(doc! { "_id": oid }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let tracker_type = repo + .tracker_type + .as_ref() + .map(|t| t.to_string()) + .unwrap_or_else(|| "gitea".to_string()); + + Ok(Json(serde_json::json!({ + "webhook_secret": repo.webhook_secret, + "tracker_type": tracker_type, + }))) +} + #[tracing::instrument(skip_all, fields(repo_id = %id))] pub async fn delete_repository( Extension(agent): AgentExt, diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index 808b5fd..502984b 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -2,6 +2,7 @@ use axum::routing::{delete, get, patch, post}; use axum::Router; use crate::api::handlers; +use crate::webhooks; pub fn build_router() -> Router { Router::new() @@ -21,6 +22,10 @@ pub fn build_router() -> Router { "/api/v1/repositories/{id}", delete(handlers::delete_repository).patch(handlers::update_repository), ) + .route( + "/api/v1/repositories/{id}/webhook-config", + get(handlers::get_webhook_config), + ) .route("/api/v1/findings", get(handlers::list_findings)) .route("/api/v1/findings/{id}", get(handlers::get_finding)) .route( @@ -94,4 +99,17 @@ pub fn build_router() -> Router { "/api/v1/chat/{repo_id}/status", get(handlers::chat::embedding_status), ) + // Webhook endpoints (proxied through dashboard) + .route( + "/webhook/github/{repo_id}", + post(webhooks::github::handle_github_webhook), + ) + .route( + "/webhook/gitlab/{repo_id}", + post(webhooks::gitlab::handle_gitlab_webhook), + ) + .route( + "/webhook/gitea/{repo_id}", + post(webhooks::gitea::handle_gitea_webhook), + ) } diff --git a/compliance-agent/src/config.rs b/compliance-agent/src/config.rs index a00621e..f166007 100644 --- a/compliance-agent/src/config.rs +++ b/compliance-agent/src/config.rs @@ -31,7 +31,6 @@ pub fn load_config() -> Result { gitlab_url: env_var_opt("GITLAB_URL"), gitlab_token: env_secret_opt("GITLAB_TOKEN"), gitlab_webhook_secret: env_secret_opt("GITLAB_WEBHOOK_SECRET"), - gitea_webhook_secret: env_secret_opt("GITEA_WEBHOOK_SECRET"), jira_url: env_var_opt("JIRA_URL"), jira_email: env_var_opt("JIRA_EMAIL"), jira_api_token: env_secret_opt("JIRA_API_TOKEN"), diff --git a/compliance-agent/src/webhooks/gitea.rs b/compliance-agent/src/webhooks/gitea.rs index ac9ce1f..ec58b2c 100644 --- a/compliance-agent/src/webhooks/gitea.rs +++ b/compliance-agent/src/webhooks/gitea.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use axum::body::Bytes; -use axum::extract::Extension; +use axum::extract::{Extension, Path}; use axum::http::{HeaderMap, StatusCode}; use hmac::{Hmac, Mac}; use sha2::Sha256; @@ -14,19 +14,37 @@ type HmacSha256 = Hmac; pub async fn handle_gitea_webhook( Extension(agent): Extension>, + Path(repo_id): Path, headers: HeaderMap, body: Bytes, ) -> StatusCode { - // Verify HMAC-SHA256 signature (Gitea uses X-Gitea-Signature, no sha256= prefix) - if let Some(secret) = &agent.config.gitea_webhook_secret { - use secrecy::ExposeSecret; + // Look up the repo to get its webhook secret + let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) { + Ok(oid) => oid, + Err(_) => return StatusCode::NOT_FOUND, + }; + let repo = match agent + .db + .repositories() + .find_one(mongodb::bson::doc! { "_id": oid }) + .await + { + Ok(Some(repo)) => repo, + _ => { + tracing::warn!("Gitea webhook: repo {repo_id} not found"); + return StatusCode::NOT_FOUND; + } + }; + + // Verify HMAC-SHA256 signature using the per-repo secret + if let Some(secret) = &repo.webhook_secret { let signature = headers .get("x-gitea-signature") .and_then(|v| v.to_str().ok()) .unwrap_or(""); - if !verify_signature(secret.expose_secret(), &body, signature) { - tracing::warn!("Gitea webhook: invalid signature"); + if !verify_signature(secret, &body, signature) { + tracing::warn!("Gitea webhook: invalid signature for repo {repo_id}"); return StatusCode::UNAUTHORIZED; } } @@ -45,8 +63,18 @@ pub async fn handle_gitea_webhook( }; match event { - "push" => handle_push(agent, &payload).await, - "pull_request" => handle_pull_request(agent, &payload).await, + "push" => { + let agent_clone = (*agent).clone(); + let repo_id = repo_id.clone(); + tokio::spawn(async move { + tracing::info!("Gitea push webhook: triggering scan for {repo_id}"); + if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { + tracing::error!("Webhook-triggered scan failed: {e}"); + } + }); + StatusCode::OK + } + "pull_request" => handle_pull_request(agent, &repo_id, &payload).await, _ => { tracing::debug!("Gitea webhook: ignoring event '{event}'"); StatusCode::OK @@ -54,42 +82,9 @@ pub async fn handle_gitea_webhook( } } -async fn handle_push(agent: Arc, payload: &serde_json::Value) -> StatusCode { - let repo_url = payload["repository"]["clone_url"] - .as_str() - .or_else(|| payload["repository"]["html_url"].as_str()) - .unwrap_or(""); - - if repo_url.is_empty() { - return StatusCode::BAD_REQUEST; - } - - let repo = agent - .db - .repositories() - .find_one(mongodb::bson::doc! { "git_url": repo_url }) - .await - .ok() - .flatten(); - - if let Some(repo) = repo { - let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); - let agent_clone = (*agent).clone(); - tokio::spawn(async move { - tracing::info!("Gitea push webhook: triggering scan for {repo_id}"); - if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { - tracing::error!("Webhook-triggered scan failed: {e}"); - } - }); - } else { - tracing::debug!("Gitea push webhook: no tracked repo for {repo_url}"); - } - - StatusCode::OK -} - async fn handle_pull_request( agent: Arc, + repo_id: &str, payload: &serde_json::Value, ) -> StatusCode { let action = payload["action"].as_str().unwrap_or(""); @@ -97,10 +92,6 @@ async fn handle_pull_request( return StatusCode::OK; } - let repo_url = payload["repository"]["clone_url"] - .as_str() - .or_else(|| payload["repository"]["html_url"].as_str()) - .unwrap_or(""); let pr_number = payload["pull_request"]["number"].as_u64().unwrap_or(0); let head_sha = payload["pull_request"]["head"]["sha"] .as_str() @@ -109,42 +100,30 @@ async fn handle_pull_request( .as_str() .unwrap_or(""); - if repo_url.is_empty() || pr_number == 0 || head_sha.is_empty() || base_sha.is_empty() { + if pr_number == 0 || head_sha.is_empty() || base_sha.is_empty() { tracing::warn!("Gitea PR webhook: missing required fields"); return StatusCode::BAD_REQUEST; } - let repo = agent - .db - .repositories() - .find_one(mongodb::bson::doc! { "git_url": repo_url }) - .await - .ok() - .flatten(); - - if let Some(repo) = repo { - let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); - let head_sha = head_sha.to_string(); - let base_sha = base_sha.to_string(); - let agent_clone = (*agent).clone(); - tokio::spawn(async move { - tracing::info!("Gitea PR webhook: reviewing PR #{pr_number} on {repo_id}"); - if let Err(e) = agent_clone - .run_pr_review(&repo_id, pr_number, &base_sha, &head_sha) - .await - { - tracing::error!("PR review failed for #{pr_number}: {e}"); - } - }); - } else { - tracing::debug!("Gitea PR webhook: no tracked repo for {repo_url}"); - } + let repo_id = repo_id.to_string(); + let head_sha = head_sha.to_string(); + let base_sha = base_sha.to_string(); + let agent_clone = (*agent).clone(); + tokio::spawn(async move { + tracing::info!("Gitea PR webhook: reviewing PR #{pr_number} on {repo_id}"); + if let Err(e) = agent_clone + .run_pr_review(&repo_id, pr_number, &base_sha, &head_sha) + .await + { + tracing::error!("PR review failed for #{pr_number}: {e}"); + } + }); StatusCode::OK } fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool { - // Gitea sends raw hex (no sha256= prefix unlike GitHub) + // Gitea sends raw hex (no sha256= prefix) let sig_bytes = match hex::decode(signature) { Ok(b) => b, Err(_) => return false, diff --git a/compliance-agent/src/webhooks/github.rs b/compliance-agent/src/webhooks/github.rs index 4b616e0..7273714 100644 --- a/compliance-agent/src/webhooks/github.rs +++ b/compliance-agent/src/webhooks/github.rs @@ -1,10 +1,9 @@ use std::sync::Arc; use axum::body::Bytes; -use axum::extract::Extension; +use axum::extract::{Extension, Path}; use axum::http::{HeaderMap, StatusCode}; use hmac::{Hmac, Mac}; -use secrecy::ExposeSecret; use sha2::Sha256; use compliance_core::models::ScanTrigger; @@ -15,18 +14,37 @@ type HmacSha256 = Hmac; pub async fn handle_github_webhook( Extension(agent): Extension>, + Path(repo_id): Path, headers: HeaderMap, body: Bytes, ) -> StatusCode { - // Verify HMAC signature - if let Some(secret) = &agent.config.github_webhook_secret { + // Look up the repo to get its webhook secret + let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) { + Ok(oid) => oid, + Err(_) => return StatusCode::NOT_FOUND, + }; + let repo = match agent + .db + .repositories() + .find_one(mongodb::bson::doc! { "_id": oid }) + .await + { + Ok(Some(repo)) => repo, + _ => { + tracing::warn!("GitHub webhook: repo {repo_id} not found"); + return StatusCode::NOT_FOUND; + } + }; + + // Verify HMAC-SHA256 signature using the per-repo secret + if let Some(secret) = &repo.webhook_secret { let signature = headers .get("x-hub-signature-256") .and_then(|v| v.to_str().ok()) .unwrap_or(""); - if !verify_signature(secret.expose_secret(), &body, signature) { - tracing::warn!("GitHub webhook: invalid signature"); + if !verify_signature(secret, &body, signature) { + tracing::warn!("GitHub webhook: invalid signature for repo {repo_id}"); return StatusCode::UNAUTHORIZED; } } @@ -45,8 +63,18 @@ pub async fn handle_github_webhook( }; match event { - "push" => handle_push(agent, &payload).await, - "pull_request" => handle_pull_request(agent, &payload).await, + "push" => { + let agent_clone = (*agent).clone(); + let repo_id = repo_id.clone(); + tokio::spawn(async move { + tracing::info!("GitHub push webhook: triggering scan for {repo_id}"); + if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { + tracing::error!("Webhook-triggered scan failed: {e}"); + } + }); + StatusCode::OK + } + "pull_request" => handle_pull_request(agent, &repo_id, &payload).await, _ => { tracing::debug!("GitHub webhook: ignoring event '{event}'"); StatusCode::OK @@ -54,43 +82,9 @@ pub async fn handle_github_webhook( } } -async fn handle_push(agent: Arc, payload: &serde_json::Value) -> StatusCode { - let repo_url = payload["repository"]["clone_url"] - .as_str() - .or_else(|| payload["repository"]["html_url"].as_str()) - .unwrap_or(""); - - if repo_url.is_empty() { - return StatusCode::BAD_REQUEST; - } - - // Find matching tracked repository - let repo = agent - .db - .repositories() - .find_one(mongodb::bson::doc! { "git_url": repo_url }) - .await - .ok() - .flatten(); - - if let Some(repo) = repo { - let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); - let agent_clone = (*agent).clone(); - tokio::spawn(async move { - tracing::info!("GitHub push webhook: triggering scan for {repo_id}"); - if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { - tracing::error!("Webhook-triggered scan failed: {e}"); - } - }); - } else { - tracing::debug!("GitHub push webhook: no tracked repo for {repo_url}"); - } - - StatusCode::OK -} - async fn handle_pull_request( agent: Arc, + repo_id: &str, payload: &serde_json::Value, ) -> StatusCode { let action = payload["action"].as_str().unwrap_or(""); @@ -98,7 +92,6 @@ async fn handle_pull_request( return StatusCode::OK; } - let repo_url = payload["repository"]["clone_url"].as_str().unwrap_or(""); let pr_number = payload["pull_request"]["number"].as_u64().unwrap_or(0); let head_sha = payload["pull_request"]["head"]["sha"] .as_str() @@ -107,40 +100,29 @@ async fn handle_pull_request( .as_str() .unwrap_or(""); - if repo_url.is_empty() || pr_number == 0 || head_sha.is_empty() || base_sha.is_empty() { + if pr_number == 0 || head_sha.is_empty() || base_sha.is_empty() { return StatusCode::BAD_REQUEST; } - let repo = agent - .db - .repositories() - .find_one(mongodb::bson::doc! { "git_url": repo_url }) - .await - .ok() - .flatten(); - - if let Some(repo) = repo { - let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); - let head_sha = head_sha.to_string(); - let base_sha = base_sha.to_string(); - let agent_clone = (*agent).clone(); - tokio::spawn(async move { - tracing::info!("GitHub PR webhook: reviewing PR #{pr_number} on {repo_id}"); - if let Err(e) = agent_clone - .run_pr_review(&repo_id, pr_number, &base_sha, &head_sha) - .await - { - tracing::error!("PR review failed for #{pr_number}: {e}"); - } - }); - } else { - tracing::debug!("GitHub PR webhook: no tracked repo for {repo_url}"); - } + let repo_id = repo_id.to_string(); + let head_sha = head_sha.to_string(); + let base_sha = base_sha.to_string(); + let agent_clone = (*agent).clone(); + tokio::spawn(async move { + tracing::info!("GitHub PR webhook: reviewing PR #{pr_number} on {repo_id}"); + if let Err(e) = agent_clone + .run_pr_review(&repo_id, pr_number, &base_sha, &head_sha) + .await + { + tracing::error!("PR review failed for #{pr_number}: {e}"); + } + }); StatusCode::OK } fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool { + // GitHub sends sha256= let sig = signature.strip_prefix("sha256=").unwrap_or(signature); let sig_bytes = match hex::decode(sig) { Ok(b) => b, diff --git a/compliance-agent/src/webhooks/gitlab.rs b/compliance-agent/src/webhooks/gitlab.rs index 075bce5..b0a7219 100644 --- a/compliance-agent/src/webhooks/gitlab.rs +++ b/compliance-agent/src/webhooks/gitlab.rs @@ -1,9 +1,8 @@ use std::sync::Arc; use axum::body::Bytes; -use axum::extract::Extension; +use axum::extract::{Extension, Path}; use axum::http::{HeaderMap, StatusCode}; -use secrecy::ExposeSecret; use compliance_core::models::ScanTrigger; @@ -11,18 +10,37 @@ use crate::agent::ComplianceAgent; pub async fn handle_gitlab_webhook( Extension(agent): Extension>, + Path(repo_id): Path, headers: HeaderMap, body: Bytes, ) -> StatusCode { - // Verify GitLab token - if let Some(secret) = &agent.config.gitlab_webhook_secret { + // Look up the repo to get its webhook secret + let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) { + Ok(oid) => oid, + Err(_) => return StatusCode::NOT_FOUND, + }; + let repo = match agent + .db + .repositories() + .find_one(mongodb::bson::doc! { "_id": oid }) + .await + { + Ok(Some(repo)) => repo, + _ => { + tracing::warn!("GitLab webhook: repo {repo_id} not found"); + return StatusCode::NOT_FOUND; + } + }; + + // GitLab sends the secret token in X-Gitlab-Token header (plain text comparison) + if let Some(secret) = &repo.webhook_secret { let token = headers .get("x-gitlab-token") .and_then(|v| v.to_str().ok()) .unwrap_or(""); - if token != secret.expose_secret() { - tracing::warn!("GitLab webhook: invalid token"); + if token != secret { + tracing::warn!("GitLab webhook: invalid token for repo {repo_id}"); return StatusCode::UNAUTHORIZED; } } @@ -38,8 +56,18 @@ pub async fn handle_gitlab_webhook( let event_type = payload["object_kind"].as_str().unwrap_or(""); match event_type { - "push" => handle_push(agent, &payload).await, - "merge_request" => handle_merge_request(agent, &payload).await, + "push" => { + let agent_clone = (*agent).clone(); + let repo_id = repo_id.clone(); + tokio::spawn(async move { + tracing::info!("GitLab push webhook: triggering scan for {repo_id}"); + if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { + tracing::error!("Webhook-triggered scan failed: {e}"); + } + }); + StatusCode::OK + } + "merge_request" => handle_merge_request(agent, &repo_id, &payload).await, _ => { tracing::debug!("GitLab webhook: ignoring event '{event_type}'"); StatusCode::OK @@ -47,40 +75,9 @@ pub async fn handle_gitlab_webhook( } } -async fn handle_push(agent: Arc, payload: &serde_json::Value) -> StatusCode { - let repo_url = payload["project"]["git_http_url"] - .as_str() - .or_else(|| payload["project"]["web_url"].as_str()) - .unwrap_or(""); - - if repo_url.is_empty() { - return StatusCode::BAD_REQUEST; - } - - let repo = agent - .db - .repositories() - .find_one(mongodb::bson::doc! { "git_url": repo_url }) - .await - .ok() - .flatten(); - - if let Some(repo) = repo { - let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); - let agent_clone = (*agent).clone(); - tokio::spawn(async move { - tracing::info!("GitLab push webhook: triggering scan for {repo_id}"); - if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { - tracing::error!("Webhook-triggered scan failed: {e}"); - } - }); - } - - StatusCode::OK -} - async fn handle_merge_request( agent: Arc, + repo_id: &str, payload: &serde_json::Value, ) -> StatusCode { let action = payload["object_attributes"]["action"] @@ -90,49 +87,32 @@ async fn handle_merge_request( return StatusCode::OK; } - let repo_url = payload["project"]["git_http_url"] - .as_str() - .or_else(|| payload["project"]["web_url"].as_str()) - .unwrap_or(""); let mr_iid = payload["object_attributes"]["iid"].as_u64().unwrap_or(0); let head_sha = payload["object_attributes"]["last_commit"]["id"] .as_str() .unwrap_or(""); - // GitLab doesn't include base sha directly; use the target branch's latest let base_sha = payload["object_attributes"]["diff_refs"]["base_sha"] .as_str() .unwrap_or(""); - if repo_url.is_empty() || mr_iid == 0 || head_sha.is_empty() || base_sha.is_empty() { + if mr_iid == 0 || head_sha.is_empty() || base_sha.is_empty() { tracing::warn!("GitLab MR webhook: missing required fields"); return StatusCode::BAD_REQUEST; } - let repo = agent - .db - .repositories() - .find_one(mongodb::bson::doc! { "git_url": repo_url }) - .await - .ok() - .flatten(); - - if let Some(repo) = repo { - let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); - let head_sha = head_sha.to_string(); - let base_sha = base_sha.to_string(); - let agent_clone = (*agent).clone(); - tokio::spawn(async move { - tracing::info!("GitLab MR webhook: reviewing MR !{mr_iid} on {repo_id}"); - if let Err(e) = agent_clone - .run_pr_review(&repo_id, mr_iid, &base_sha, &head_sha) - .await - { - tracing::error!("MR review failed for !{mr_iid}: {e}"); - } - }); - } else { - tracing::debug!("GitLab MR webhook: no tracked repo for {repo_url}"); - } + let repo_id = repo_id.to_string(); + let head_sha = head_sha.to_string(); + let base_sha = base_sha.to_string(); + let agent_clone = (*agent).clone(); + tokio::spawn(async move { + tracing::info!("GitLab MR webhook: reviewing MR !{mr_iid} on {repo_id}"); + if let Err(e) = agent_clone + .run_pr_review(&repo_id, mr_iid, &base_sha, &head_sha) + .await + { + tracing::error!("MR review failed for !{mr_iid}: {e}"); + } + }); StatusCode::OK } diff --git a/compliance-agent/src/webhooks/server.rs b/compliance-agent/src/webhooks/server.rs index 6db45f9..b90f860 100644 --- a/compliance-agent/src/webhooks/server.rs +++ b/compliance-agent/src/webhooks/server.rs @@ -9,9 +9,19 @@ use crate::webhooks::{gitea, github, gitlab}; pub async fn start_webhook_server(agent: &ComplianceAgent) -> Result<(), AgentError> { let app = Router::new() - .route("/webhook/github", post(github::handle_github_webhook)) - .route("/webhook/gitlab", post(gitlab::handle_gitlab_webhook)) - .route("/webhook/gitea", post(gitea::handle_gitea_webhook)) + // Per-repo webhook URLs: /webhook/{platform}/{repo_id} + .route( + "/webhook/github/{repo_id}", + post(github::handle_github_webhook), + ) + .route( + "/webhook/gitlab/{repo_id}", + post(gitlab::handle_gitlab_webhook), + ) + .route( + "/webhook/gitea/{repo_id}", + post(gitea::handle_gitea_webhook), + ) .layer(Extension(Arc::new(agent.clone()))); let addr = "0.0.0.0:3002"; diff --git a/compliance-core/src/config.rs b/compliance-core/src/config.rs index 1490893..401f9a8 100644 --- a/compliance-core/src/config.rs +++ b/compliance-core/src/config.rs @@ -14,7 +14,6 @@ pub struct AgentConfig { pub gitlab_url: Option, pub gitlab_token: Option, pub gitlab_webhook_secret: Option, - pub gitea_webhook_secret: Option, pub jira_url: Option, pub jira_email: Option, pub jira_api_token: Option, diff --git a/compliance-core/src/models/repository.rs b/compliance-core/src/models/repository.rs index 5fab4d0..eae5cae 100644 --- a/compliance-core/src/models/repository.rs +++ b/compliance-core/src/models/repository.rs @@ -25,6 +25,9 @@ pub struct TrackedRepository { pub scan_schedule: Option, #[serde(default)] pub webhook_enabled: bool, + /// Auto-generated HMAC secret for verifying incoming webhooks + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhook_secret: Option, pub tracker_type: Option, pub tracker_owner: Option, pub tracker_repo: Option, @@ -72,6 +75,8 @@ where impl TrackedRepository { pub fn new(name: String, git_url: String) -> Self { let now = Utc::now(); + // Generate a random webhook secret (hex-encoded UUID v4, no dashes) + let webhook_secret = uuid::Uuid::new_v4().to_string().replace('-', ""); Self { id: None, name, @@ -82,6 +87,7 @@ impl TrackedRepository { auth_token: None, auth_username: None, webhook_enabled: false, + webhook_secret: Some(webhook_secret), tracker_type: None, tracker_owner: None, tracker_repo: None, diff --git a/compliance-dashboard/src/infrastructure/repositories.rs b/compliance-dashboard/src/infrastructure/repositories.rs index af68bb9..c684522 100644 --- a/compliance-dashboard/src/infrastructure/repositories.rs +++ b/compliance-dashboard/src/infrastructure/repositories.rs @@ -216,6 +216,31 @@ pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> { Ok(()) } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct WebhookConfigResponse { + pub webhook_secret: Option, + pub tracker_type: String, +} + +#[server] +pub async fn fetch_webhook_config(repo_id: String) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!( + "{}/api/v1/repositories/{repo_id}/webhook-config", + state.agent_api_url + ); + + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: WebhookConfigResponse = resp + .json() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body) +} + /// Check if a repository has any running scans #[server] pub async fn check_repo_scanning(repo_id: String) -> Result { diff --git a/compliance-dashboard/src/infrastructure/server.rs b/compliance-dashboard/src/infrastructure/server.rs index 364c396..a98f2af 100644 --- a/compliance-dashboard/src/infrastructure/server.rs +++ b/compliance-dashboard/src/infrastructure/server.rs @@ -1,4 +1,4 @@ -use axum::routing::get; +use axum::routing::{get, post}; use axum::{middleware, Extension}; use dioxus::prelude::*; use time::Duration; @@ -63,6 +63,8 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> { .route("/auth", get(auth_login)) .route("/auth/callback", get(auth_callback)) .route("/logout", get(logout)) + // Webhook proxy: forward to agent (no auth required) + .route("/webhook/{platform}/{repo_id}", post(webhook_proxy)) .serve_dioxus_application(ServeConfig::new(), app) .layer(Extension(PendingOAuthStore::default())) .layer(middleware::from_fn(require_auth)) @@ -77,6 +79,53 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> { }) } +/// Forward incoming webhooks to the agent's webhook server. +/// The dashboard acts as a public-facing proxy so the agent isn't exposed directly. +async fn webhook_proxy( + Extension(state): Extension, + axum::extract::Path((platform, repo_id)): axum::extract::Path<(String, String)>, + headers: axum::http::HeaderMap, + body: axum::body::Bytes, +) -> axum::http::StatusCode { + // The agent_api_url typically looks like "http://agent:3001" or "http://localhost:3001" + // Webhook routes are on the same server, so strip any trailing path + let base = state.agent_api_url.trim_end_matches('/'); + // Remove /api/v1 suffix if present to get base URL + let base = base + .strip_suffix("/api/v1") + .or_else(|| base.strip_suffix("/api")) + .unwrap_or(base); + let agent_url = format!("{base}/webhook/{platform}/{repo_id}"); + + // Forward all relevant headers + let client = reqwest::Client::new(); + let mut req = client.post(&agent_url).body(body.to_vec()); + + for (name, value) in &headers { + let name_str = name.as_str().to_lowercase(); + // Forward platform-specific headers + if name_str.starts_with("x-gitea-") + || name_str.starts_with("x-github-") + || name_str.starts_with("x-hub-") + || name_str.starts_with("x-gitlab-") + || name_str == "content-type" + { + if let Ok(v) = value.to_str() { + req = req.header(name.as_str(), v); + } + } + } + + match req.send().await { + Ok(resp) => axum::http::StatusCode::from_u16(resp.status().as_u16()) + .unwrap_or(axum::http::StatusCode::BAD_GATEWAY), + Err(e) => { + tracing::error!("Webhook proxy failed: {e}"); + axum::http::StatusCode::BAD_GATEWAY + } + } +} + /// 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"); diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index 7748e7e..29a7499 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -47,6 +47,8 @@ pub fn RepositoriesPage() -> Element { let mut edit_tracker_repo = use_signal(String::new); let mut edit_tracker_token = use_signal(String::new); let mut edit_saving = use_signal(|| false); + let mut edit_webhook_secret = use_signal(|| Option::::None); + let mut edit_webhook_tracker = use_signal(String::new); let mut scanning_ids = use_signal(Vec::::new); let mut graph_repo_id = use_signal(|| Option::::None); @@ -345,6 +347,7 @@ pub fn RepositoriesPage() -> Element { option { value: "", "None" } option { value: "github", "GitHub" } option { value: "gitlab", "GitLab" } + option { value: "gitea", "Gitea" } option { value: "jira", "Jira" } } } @@ -375,6 +378,39 @@ pub fn RepositoriesPage() -> Element { oninput: move |e| edit_tracker_token.set(e.value()), } } + // Webhook configuration section + if let Some(secret) = edit_webhook_secret() { + h4 { + style: "margin-top: 16px; margin-bottom: 8px; font-size: 14px; color: var(--text-secondary);", + "Webhook Configuration" + } + p { + style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;", + "Add this webhook in your repository settings to enable push-triggered scans and PR reviews." + } + div { class: "form-group", + label { "Webhook URL" } + input { + r#type: "text", + readonly: true, + style: "font-family: monospace; font-size: 12px;", + value: format!("/webhook/{}/{eid}", edit_webhook_tracker()), + } + p { + style: "font-size: 11px; color: var(--text-secondary); margin-top: 4px;", + "Use the full dashboard URL as the base, e.g. https://your-domain.com/webhook/..." + } + } + div { class: "form-group", + label { "Webhook Secret" } + input { + r#type: "text", + readonly: true, + style: "font-family: monospace; font-size: 12px;", + value: "{secret}", + } + } + } div { class: "modal-actions", button { class: "btn btn-secondary", @@ -496,7 +532,17 @@ pub fn RepositoriesPage() -> Element { edit_tracker_owner.set(edit_repo_data.tracker_owner.clone().unwrap_or_default()); edit_tracker_repo.set(edit_repo_data.tracker_repo.clone().unwrap_or_default()); edit_tracker_token.set(String::new()); + edit_webhook_secret.set(None); + edit_webhook_tracker.set(String::new()); edit_repo_id.set(Some(repo_id_edit.clone())); + // Fetch webhook config in background + let rid = repo_id_edit.clone(); + spawn(async move { + if let Ok(cfg) = crate::infrastructure::repositories::fetch_webhook_config(rid).await { + edit_webhook_secret.set(cfg.webhook_secret); + edit_webhook_tracker.set(cfg.tracker_type); + } + }); }, Icon { icon: BsPencil, width: 16, height: 16 } }