diff --git a/Cargo.lock b/Cargo.lock index 47d56d6..d3e3e28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,6 +650,7 @@ dependencies = [ "dioxus-logger 0.6.2", "dotenvy", "gloo-timers", + "js-sys", "mongodb", "rand 0.9.2", "reqwest", @@ -665,6 +666,7 @@ dependencies = [ "tracing", "url", "uuid", + "wasm-bindgen", "web-sys", ] diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index 0dca8b3..dbf0e12 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -87,6 +87,20 @@ pub struct AddRepositoryRequest { pub tracker_type: Option, pub tracker_owner: Option, pub tracker_repo: Option, + pub tracker_token: Option, + pub scan_schedule: Option, +} + +#[derive(Deserialize)] +pub struct UpdateRepositoryRequest { + pub name: Option, + pub default_branch: Option, + pub auth_token: Option, + pub auth_username: Option, + pub tracker_type: Option, + pub tracker_owner: Option, + pub tracker_repo: Option, + pub tracker_token: Option, pub scan_schedule: Option, } @@ -318,6 +332,7 @@ pub async fn add_repository( repo.tracker_type = req.tracker_type; repo.tracker_owner = req.tracker_owner; repo.tracker_repo = req.tracker_repo; + repo.tracker_token = req.tracker_token; repo.scan_schedule = req.scan_schedule; agent @@ -339,6 +354,61 @@ pub async fn add_repository( })) } +#[tracing::instrument(skip_all, fields(repo_id = %id))] +pub async fn update_repository( + 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)?; + + let mut set_doc = doc! { "updated_at": mongodb::bson::DateTime::now() }; + + if let Some(name) = &req.name { + set_doc.insert("name", name); + } + if let Some(branch) = &req.default_branch { + set_doc.insert("default_branch", branch); + } + if let Some(token) = &req.auth_token { + set_doc.insert("auth_token", token); + } + if let Some(username) = &req.auth_username { + set_doc.insert("auth_username", username); + } + if let Some(tracker_type) = &req.tracker_type { + set_doc.insert("tracker_type", tracker_type.to_string()); + } + if let Some(owner) = &req.tracker_owner { + set_doc.insert("tracker_owner", owner); + } + if let Some(repo) = &req.tracker_repo { + set_doc.insert("tracker_repo", repo); + } + if let Some(token) = &req.tracker_token { + set_doc.insert("tracker_token", token); + } + if let Some(schedule) = &req.scan_schedule { + set_doc.insert("scan_schedule", schedule); + } + + let result = agent + .db + .repositories() + .update_one(doc! { "_id": oid }, doc! { "$set": set_doc }) + .await + .map_err(|e| { + tracing::warn!("Failed to update repository: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if result.matched_count == 0 { + return Err(StatusCode::NOT_FOUND); + } + + Ok(Json(serde_json::json!({ "status": "updated" }))) +} + #[tracing::instrument(skip_all)] pub async fn get_ssh_public_key( Extension(agent): AgentExt, diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index bf6877a..808b5fd 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -19,7 +19,7 @@ pub fn build_router() -> Router { ) .route( "/api/v1/repositories/{id}", - delete(handlers::delete_repository), + delete(handlers::delete_repository).patch(handlers::update_repository), ) .route("/api/v1/findings", get(handlers::list_findings)) .route("/api/v1/findings/{id}", get(handlers::get_finding)) diff --git a/compliance-agent/src/trackers/gitea.rs b/compliance-agent/src/trackers/gitea.rs new file mode 100644 index 0000000..1d69a14 --- /dev/null +++ b/compliance-agent/src/trackers/gitea.rs @@ -0,0 +1,211 @@ +use compliance_core::error::CoreError; +use compliance_core::models::{TrackerIssue, TrackerType}; +use compliance_core::traits::issue_tracker::{IssueTracker, ReviewComment}; +use secrecy::{ExposeSecret, SecretString}; + +pub struct GiteaTracker { + base_url: String, + http: reqwest::Client, + token: SecretString, +} + +impl GiteaTracker { + pub fn new(base_url: String, token: SecretString) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + token, + } + } + + fn api_url(&self, path: &str) -> String { + format!("{}/api/v1{}", self.base_url, path) + } +} + +impl IssueTracker for GiteaTracker { + fn name(&self) -> &str { + "gitea" + } + + async fn create_issue( + &self, + owner: &str, + repo: &str, + title: &str, + body: &str, + labels: &[String], + ) -> Result { + let url = self.api_url(&format!("/repos/{owner}/{repo}/issues")); + + let mut payload = serde_json::json!({ + "title": title, + "body": body, + }); + if !labels.is_empty() { + // Gitea expects label IDs, but we can pass label names via the API + // For simplicity, we add labels as part of the body if they can't be resolved + payload["labels"] = serde_json::json!(labels); + } + + let resp = self + .http + .post(&url) + .header( + "Authorization", + format!("token {}", self.token.expose_secret()), + ) + .json(&payload) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Gitea create issue failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(CoreError::IssueTracker(format!( + "Gitea returned {status}: {text}" + ))); + } + + let issue: serde_json::Value = resp + .json() + .await + .map_err(|e| CoreError::IssueTracker(format!("Failed to parse Gitea response: {e}")))?; + + Ok(TrackerIssue::new( + String::new(), + TrackerType::Gitea, + issue["number"].to_string(), + issue["html_url"].as_str().unwrap_or("").to_string(), + title.to_string(), + )) + } + + async fn update_issue_status( + &self, + owner: &str, + repo: &str, + external_id: &str, + status: &str, + ) -> Result<(), CoreError> { + let url = self.api_url(&format!("/repos/{owner}/{repo}/issues/{external_id}")); + + let state = match status { + "closed" | "resolved" => "closed", + _ => "open", + }; + + self.http + .patch(&url) + .header( + "Authorization", + format!("token {}", self.token.expose_secret()), + ) + .json(&serde_json::json!({ "state": state })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Gitea update issue failed: {e}")))?; + + Ok(()) + } + + async fn add_comment( + &self, + owner: &str, + repo: &str, + external_id: &str, + body: &str, + ) -> Result<(), CoreError> { + let url = self.api_url(&format!( + "/repos/{owner}/{repo}/issues/{external_id}/comments" + )); + + self.http + .post(&url) + .header( + "Authorization", + format!("token {}", self.token.expose_secret()), + ) + .json(&serde_json::json!({ "body": body })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Gitea add comment failed: {e}")))?; + + Ok(()) + } + + async fn create_pr_review( + &self, + owner: &str, + repo: &str, + pr_number: u64, + body: &str, + comments: Vec, + ) -> Result<(), CoreError> { + let url = self.api_url(&format!("/repos/{owner}/{repo}/pulls/{pr_number}/reviews")); + + let review_comments: Vec = comments + .iter() + .map(|c| { + serde_json::json!({ + "path": c.path, + "new_position": c.line, + "body": c.body, + }) + }) + .collect(); + + self.http + .post(&url) + .header( + "Authorization", + format!("token {}", self.token.expose_secret()), + ) + .json(&serde_json::json!({ + "body": body, + "event": "COMMENT", + "comments": review_comments, + })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Gitea PR review failed: {e}")))?; + + Ok(()) + } + + async fn find_existing_issue( + &self, + owner: &str, + repo: &str, + fingerprint: &str, + ) -> Result, CoreError> { + let url = self.api_url(&format!( + "/repos/{owner}/{repo}/issues?type=issues&state=open&q={fingerprint}" + )); + + let resp = self + .http + .get(&url) + .header( + "Authorization", + format!("token {}", self.token.expose_secret()), + ) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Gitea search failed: {e}")))?; + + let issues: Vec = resp.json().await.unwrap_or_default(); + if let Some(issue) = issues.first() { + Ok(Some(TrackerIssue::new( + String::new(), + TrackerType::Gitea, + issue["number"].to_string(), + issue["html_url"].as_str().unwrap_or("").to_string(), + issue["title"].as_str().unwrap_or("").to_string(), + ))) + } else { + Ok(None) + } + } +} diff --git a/compliance-agent/src/trackers/mod.rs b/compliance-agent/src/trackers/mod.rs index b4abd57..d9bda8a 100644 --- a/compliance-agent/src/trackers/mod.rs +++ b/compliance-agent/src/trackers/mod.rs @@ -1,3 +1,4 @@ +pub mod gitea; pub mod github; pub mod gitlab; pub mod jira; diff --git a/compliance-core/src/models/issue.rs b/compliance-core/src/models/issue.rs index 3c7d670..8f1448a 100644 --- a/compliance-core/src/models/issue.rs +++ b/compliance-core/src/models/issue.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; pub enum TrackerType { GitHub, GitLab, + Gitea, Jira, } @@ -14,6 +15,7 @@ impl std::fmt::Display for TrackerType { match self { Self::GitHub => write!(f, "github"), Self::GitLab => write!(f, "gitlab"), + Self::Gitea => write!(f, "gitea"), Self::Jira => write!(f, "jira"), } } diff --git a/compliance-core/src/models/repository.rs b/compliance-core/src/models/repository.rs index 96fddf8..5fab4d0 100644 --- a/compliance-core/src/models/repository.rs +++ b/compliance-core/src/models/repository.rs @@ -28,6 +28,9 @@ pub struct TrackedRepository { pub tracker_type: Option, pub tracker_owner: Option, pub tracker_repo: Option, + /// Optional per-repo PAT for the issue tracker (GitHub/GitLab/Jira) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracker_token: Option, /// Optional auth token for HTTPS private repos (PAT or password) #[serde(default, skip_serializing_if = "Option::is_none")] pub auth_token: Option, @@ -82,6 +85,7 @@ impl TrackedRepository { tracker_type: None, tracker_owner: None, tracker_repo: None, + tracker_token: None, last_scanned_commit: None, findings_count: 0, created_at: now, diff --git a/compliance-dashboard/src/infrastructure/repositories.rs b/compliance-dashboard/src/infrastructure/repositories.rs index 6f55ae6..af68bb9 100644 --- a/compliance-dashboard/src/infrastructure/repositories.rs +++ b/compliance-dashboard/src/infrastructure/repositories.rs @@ -36,6 +36,10 @@ pub async fn add_repository( default_branch: String, auth_token: Option, auth_username: Option, + tracker_type: Option, + tracker_owner: Option, + tracker_repo: Option, + tracker_token: Option, ) -> Result<(), ServerFnError> { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; @@ -52,6 +56,18 @@ pub async fn add_repository( if let Some(username) = auth_username.filter(|u| !u.is_empty()) { body["auth_username"] = serde_json::Value::String(username); } + if let Some(tt) = tracker_type.filter(|t| !t.is_empty()) { + body["tracker_type"] = serde_json::Value::String(tt); + } + if let Some(to) = tracker_owner.filter(|t| !t.is_empty()) { + body["tracker_owner"] = serde_json::Value::String(to); + } + if let Some(tr) = tracker_repo.filter(|t| !t.is_empty()) { + body["tracker_repo"] = serde_json::Value::String(tr); + } + if let Some(tk) = tracker_token.filter(|t| !t.is_empty()) { + body["tracker_token"] = serde_json::Value::String(tk); + } let client = reqwest::Client::new(); let resp = client @@ -71,6 +87,70 @@ pub async fn add_repository( Ok(()) } +#[server] +pub async fn update_repository( + repo_id: String, + name: Option, + default_branch: Option, + auth_token: Option, + auth_username: Option, + tracker_type: Option, + tracker_owner: Option, + tracker_repo: Option, + tracker_token: Option, + scan_schedule: Option, +) -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/repositories/{repo_id}", state.agent_api_url); + + let mut body = serde_json::Map::new(); + if let Some(v) = name.filter(|s| !s.is_empty()) { + body.insert("name".into(), serde_json::Value::String(v)); + } + if let Some(v) = default_branch.filter(|s| !s.is_empty()) { + body.insert("default_branch".into(), serde_json::Value::String(v)); + } + if let Some(v) = auth_token { + body.insert("auth_token".into(), serde_json::Value::String(v)); + } + if let Some(v) = auth_username { + body.insert("auth_username".into(), serde_json::Value::String(v)); + } + if let Some(v) = tracker_type { + body.insert("tracker_type".into(), serde_json::Value::String(v)); + } + if let Some(v) = tracker_owner { + body.insert("tracker_owner".into(), serde_json::Value::String(v)); + } + if let Some(v) = tracker_repo { + body.insert("tracker_repo".into(), serde_json::Value::String(v)); + } + if let Some(v) = tracker_token { + body.insert("tracker_token".into(), serde_json::Value::String(v)); + } + if let Some(v) = scan_schedule { + body.insert("scan_schedule".into(), serde_json::Value::String(v)); + } + + let client = reqwest::Client::new(); + let resp = client + .patch(&url) + .json(&body) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(ServerFnError::new(format!( + "Failed to update repository: {text}" + ))); + } + + Ok(()) +} + #[server] pub async fn fetch_ssh_public_key() -> Result { let state: super::server_state::ServerState = diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index 6294869..7748e7e 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -1,5 +1,7 @@ use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; +#[allow(unused_imports)] +use dioxus_free_icons::icons::bs_icons::{BsGear, BsPencil}; use dioxus_free_icons::Icon; use crate::components::page_header::PageHeader; @@ -29,9 +31,22 @@ pub fn RepositoriesPage() -> Element { let mut auth_username = use_signal(String::new); let mut show_auth = use_signal(|| false); let mut ssh_public_key = use_signal(String::new); + let mut show_tracker = use_signal(|| false); + let mut tracker_type_val = use_signal(String::new); + let mut tracker_owner_val = use_signal(String::new); + let mut tracker_repo_val = use_signal(String::new); + let mut tracker_token_val = 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 edit_repo_id = use_signal(|| Option::::None); + let mut edit_name = use_signal(String::new); + let mut edit_branch = use_signal(String::new); + let mut edit_tracker_type = use_signal(String::new); + let mut edit_tracker_owner = use_signal(String::new); + 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 scanning_ids = use_signal(Vec::::new); let mut graph_repo_id = use_signal(|| Option::::None); @@ -154,6 +169,63 @@ pub fn RepositoriesPage() -> Element { } } + // Issue tracker config section + div { style: "margin-top: 8px;", + button { + class: "btn btn-ghost", + style: "font-size: 12px; padding: 4px 8px;", + onclick: move |_| show_tracker.toggle(), + if show_tracker() { "Hide tracker options" } else { "Issue tracker?" } + } + } + + if show_tracker() { + div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;", + p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;", + "Configure an issue tracker to auto-create issues from findings" + } + div { class: "form-group", + label { "Tracker Type" } + select { + value: "{tracker_type_val}", + onchange: move |e| tracker_type_val.set(e.value()), + option { value: "", "None" } + option { value: "github", "GitHub" } + option { value: "gitlab", "GitLab" } + option { value: "gitea", "Gitea" } + option { value: "jira", "Jira" } + } + } + div { class: "form-group", + label { "Owner / Namespace" } + input { + r#type: "text", + placeholder: "org-name", + value: "{tracker_owner_val}", + oninput: move |e| tracker_owner_val.set(e.value()), + } + } + div { class: "form-group", + label { "Repository / Project" } + input { + r#type: "text", + placeholder: "repo-name", + value: "{tracker_repo_val}", + oninput: move |e| tracker_repo_val.set(e.value()), + } + } + div { class: "form-group", + label { "Tracker Token (PAT)" } + input { + r#type: "password", + placeholder: "ghp_xxxx / glpat-xxxx", + value: "{tracker_token_val}", + oninput: move |e| tracker_token_val.set(e.value()), + } + } + } + } + button { class: "btn btn-primary", disabled: adding(), @@ -169,9 +241,13 @@ pub fn RepositoriesPage() -> Element { let v = auth_username(); if v.is_empty() { None } else { Some(v) } }; + let tt = { let v = tracker_type_val(); if v.is_empty() { None } else { Some(v) } }; + let t_owner = { let v = tracker_owner_val(); if v.is_empty() { None } else { Some(v) } }; + let t_repo = { let v = tracker_repo_val(); if v.is_empty() { None } else { Some(v) } }; + let t_tok = { let v = tracker_token_val(); if v.is_empty() { None } else { Some(v) } }; adding.set(true); spawn(async move { - match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr).await { + match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr, tt, t_owner, t_repo, t_tok).await { Ok(_) => { toasts.push(ToastType::Success, "Repository added"); repos.restart(); @@ -182,10 +258,15 @@ pub fn RepositoriesPage() -> Element { }); show_add_form.set(false); show_auth.set(false); + show_tracker.set(false); name.set(String::new()); git_url.set(String::new()); auth_token.set(String::new()); auth_username.set(String::new()); + tracker_type_val.set(String::new()); + tracker_owner_val.set(String::new()); + tracker_repo_val.set(String::new()); + tracker_token_val.set(String::new()); }, if adding() { "Validating..." } else { "Add" } } @@ -234,6 +315,105 @@ pub fn RepositoriesPage() -> Element { } } + // ── Edit repository dialog ── + if let Some(eid) = edit_repo_id() { + div { class: "modal-overlay", + div { class: "modal-dialog", + h3 { "Edit Repository" } + div { class: "form-group", + label { "Name" } + input { + r#type: "text", + value: "{edit_name}", + oninput: move |e| edit_name.set(e.value()), + } + } + div { class: "form-group", + label { "Default Branch" } + input { + r#type: "text", + value: "{edit_branch}", + oninput: move |e| edit_branch.set(e.value()), + } + } + h4 { style: "margin-top: 16px; margin-bottom: 8px; font-size: 14px; color: var(--text-secondary);", "Issue Tracker" } + div { class: "form-group", + label { "Tracker Type" } + select { + value: "{edit_tracker_type}", + onchange: move |e| edit_tracker_type.set(e.value()), + option { value: "", "None" } + option { value: "github", "GitHub" } + option { value: "gitlab", "GitLab" } + option { value: "jira", "Jira" } + } + } + div { class: "form-group", + label { "Owner / Namespace" } + input { + r#type: "text", + placeholder: "org-name", + value: "{edit_tracker_owner}", + oninput: move |e| edit_tracker_owner.set(e.value()), + } + } + div { class: "form-group", + label { "Repository / Project" } + input { + r#type: "text", + placeholder: "repo-name", + value: "{edit_tracker_repo}", + oninput: move |e| edit_tracker_repo.set(e.value()), + } + } + div { class: "form-group", + label { "Tracker Token (leave empty to keep existing)" } + input { + r#type: "password", + placeholder: "Enter new token to change", + value: "{edit_tracker_token}", + oninput: move |e| edit_tracker_token.set(e.value()), + } + } + div { class: "modal-actions", + button { + class: "btn btn-secondary", + onclick: move |_| edit_repo_id.set(None), + "Cancel" + } + button { + class: "btn btn-primary", + disabled: edit_saving(), + onclick: move |_| { + let id = eid.clone(); + let nm = { let v = edit_name(); if v.is_empty() { None } else { Some(v) } }; + let br = { let v = edit_branch(); if v.is_empty() { None } else { Some(v) } }; + let tt = { let v = edit_tracker_type(); if v.is_empty() { None } else { Some(v) } }; + let t_owner = { let v = edit_tracker_owner(); if v.is_empty() { None } else { Some(v) } }; + let t_repo = { let v = edit_tracker_repo(); if v.is_empty() { None } else { Some(v) } }; + let t_tok = { let v = edit_tracker_token(); if v.is_empty() { None } else { Some(v) } }; + edit_saving.set(true); + spawn(async move { + match crate::infrastructure::repositories::update_repository( + id, nm, br, None, None, tt, t_owner, t_repo, t_tok, None, + ).await { + Ok(_) => { + toasts.push(ToastType::Success, "Repository updated"); + repos.restart(); + } + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + edit_saving.set(false); + edit_repo_id.set(None); + }); + }, + if edit_saving() { "Saving..." } else { "Save" } + } + } + } + } + } + match &*repos.read() { Some(Some(resp)) => { let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1); @@ -257,7 +437,9 @@ pub fn RepositoriesPage() -> Element { let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); let repo_id_scan = repo_id.clone(); let repo_id_del = repo_id.clone(); + let repo_id_edit = repo_id.clone(); let repo_name_del = repo.name.clone(); + let edit_repo_data = repo.clone(); let is_scanning = scanning_ids().contains(&repo_id); rsx! { tr { @@ -302,6 +484,22 @@ pub fn RepositoriesPage() -> Element { }, Icon { icon: BsDiagram3, width: 16, height: 16 } } + button { + class: "btn btn-ghost", + title: "Edit repository", + onclick: move |_| { + edit_name.set(edit_repo_data.name.clone()); + edit_branch.set(edit_repo_data.default_branch.clone()); + edit_tracker_type.set( + edit_repo_data.tracker_type.as_ref().map(|t| t.to_string()).unwrap_or_default() + ); + 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_repo_id.set(Some(repo_id_edit.clone())); + }, + Icon { icon: BsPencil, width: 16, height: 16 } + } button { class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" }, title: "Trigger scan",