feat: auto-generated per-repo webhook secrets with dashboard proxy
Some checks failed
CI / Format (push) Successful in 5s
CI / Clippy (push) Failing after 1m57s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Format (pull_request) Successful in 8s
CI / Clippy (pull_request) Failing after 1m53s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped

- Auto-generate webhook_secret on repository creation (UUID-based)
- Webhook routes use per-repo URLs: /webhook/{platform}/{repo_id}
- Verify signatures using per-repo secret (not global env var)
- Dashboard proxies webhooks to agent (agent not exposed publicly)
- Edit modal shows webhook URL + secret for user to copy into Gitea
- Add webhook-config API endpoint to retrieve per-repo secret
- Add Gitea option to edit dialog tracker type dropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-11 11:25:05 +01:00
parent 7a0a53d399
commit 0cb208408e
12 changed files with 339 additions and 220 deletions

View File

@@ -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<String>,
) -> Result<Json<serde_json::Value>, 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,

View File

@@ -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),
)
}

View File

@@ -31,7 +31,6 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
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"),

View File

@@ -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<Sha256>;
pub async fn handle_gitea_webhook(
Extension(agent): Extension<Arc<ComplianceAgent>>,
Path(repo_id): Path<String>,
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<ComplianceAgent>, 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<ComplianceAgent>,
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,

View File

@@ -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<Sha256>;
pub async fn handle_github_webhook(
Extension(agent): Extension<Arc<ComplianceAgent>>,
Path(repo_id): Path<String>,
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<ComplianceAgent>, 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<ComplianceAgent>,
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=<hex>
let sig = signature.strip_prefix("sha256=").unwrap_or(signature);
let sig_bytes = match hex::decode(sig) {
Ok(b) => b,

View File

@@ -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<Arc<ComplianceAgent>>,
Path(repo_id): Path<String>,
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<ComplianceAgent>, 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<ComplianceAgent>,
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
}

View File

@@ -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";

View File

@@ -14,7 +14,6 @@ pub struct AgentConfig {
pub gitlab_url: Option<String>,
pub gitlab_token: Option<SecretString>,
pub gitlab_webhook_secret: Option<SecretString>,
pub gitea_webhook_secret: Option<SecretString>,
pub jira_url: Option<String>,
pub jira_email: Option<String>,
pub jira_api_token: Option<SecretString>,

View File

@@ -25,6 +25,9 @@ pub struct TrackedRepository {
pub scan_schedule: Option<String>,
#[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<String>,
pub tracker_type: Option<TrackerType>,
pub tracker_owner: Option<String>,
pub tracker_repo: Option<String>,
@@ -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,

View File

@@ -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<String>,
pub tracker_type: String,
}
#[server]
pub async fn fetch_webhook_config(repo_id: String) -> Result<WebhookConfigResponse, ServerFnError> {
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<bool, ServerFnError> {

View File

@@ -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<ServerState>,
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");

View File

@@ -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::<String>::None);
let mut edit_webhook_tracker = use_signal(String::new);
let mut scanning_ids = use_signal(Vec::<String>::new);
let mut graph_repo_id = use_signal(|| Option::<String>::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 }
}