diff --git a/compliance-agent/src/api/handlers/chat.rs b/compliance-agent/src/api/handlers/chat.rs index aafe290..9413f99 100644 --- a/compliance-agent/src/api/handlers/chat.rs +++ b/compliance-agent/src/api/handlers/chat.rs @@ -187,7 +187,12 @@ pub async fn build_embeddings( } }; - let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path); + let creds = crate::pipeline::git::RepoCredentials { + ssh_key_path: Some(agent_clone.config.ssh_key_path.clone()), + auth_token: repo.auth_token.clone(), + auth_username: repo.auth_username.clone(), + }; + let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds); let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) { Ok(p) => p, Err(e) => { diff --git a/compliance-agent/src/api/handlers/graph.rs b/compliance-agent/src/api/handlers/graph.rs index ea12acd..bfbafec 100644 --- a/compliance-agent/src/api/handlers/graph.rs +++ b/compliance-agent/src/api/handlers/graph.rs @@ -291,7 +291,12 @@ pub async fn trigger_build( } }; - let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path); + let creds = crate::pipeline::git::RepoCredentials { + ssh_key_path: Some(agent_clone.config.ssh_key_path.clone()), + auth_token: repo.auth_token.clone(), + auth_username: repo.auth_username.clone(), + }; + let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds); let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) { Ok(p) => p, Err(e) => { diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index 183c14c..d3bb3f1 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -82,6 +82,8 @@ pub struct AddRepositoryRequest { pub git_url: String, #[serde(default = "default_branch")] pub default_branch: String, + pub auth_token: Option, + pub auth_username: Option, pub tracker_type: Option, pub tracker_owner: Option, pub tracker_repo: Option, @@ -284,9 +286,25 @@ pub async fn list_repositories( pub async fn add_repository( Extension(agent): AgentExt, Json(req): Json, -) -> Result>, StatusCode> { +) -> Result>, (StatusCode, String)> { + // Validate repository access before saving + let creds = crate::pipeline::git::RepoCredentials { + ssh_key_path: Some(agent.config.ssh_key_path.clone()), + auth_token: req.auth_token.clone(), + auth_username: req.auth_username.clone(), + }; + + if let Err(e) = crate::pipeline::git::GitOps::test_access(&req.git_url, &creds) { + return Err(( + StatusCode::BAD_REQUEST, + format!("Cannot access repository: {e}"), + )); + } + let mut repo = TrackedRepository::new(req.name, req.git_url); repo.default_branch = req.default_branch; + repo.auth_token = req.auth_token; + repo.auth_username = req.auth_username; repo.tracker_type = req.tracker_type; repo.tracker_owner = req.tracker_owner; repo.tracker_repo = req.tracker_repo; @@ -297,7 +315,7 @@ pub async fn add_repository( .repositories() .insert_one(&repo) .await - .map_err(|_| StatusCode::CONFLICT)?; + .map_err(|_| (StatusCode::CONFLICT, "Repository already exists".to_string()))?; Ok(Json(ApiResponse { data: repo, @@ -306,6 +324,14 @@ pub async fn add_repository( })) } +pub async fn get_ssh_public_key( + Extension(agent): AgentExt, +) -> Result, StatusCode> { + let public_path = format!("{}.pub", agent.config.ssh_key_path); + let public_key = std::fs::read_to_string(&public_path).map_err(|_| StatusCode::NOT_FOUND)?; + Ok(Json(serde_json::json!({ "public_key": public_key.trim() }))) +} + pub async fn trigger_scan( Extension(agent): AgentExt, Path(id): Path, diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index cd5edb7..8d42d9c 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -7,6 +7,7 @@ pub fn build_router() -> Router { Router::new() .route("/api/v1/health", get(handlers::health)) .route("/api/v1/stats/overview", get(handlers::stats_overview)) + .route("/api/v1/settings/ssh-public-key", get(handlers::get_ssh_public_key)) .route("/api/v1/repositories", get(handlers::list_repositories)) .route("/api/v1/repositories", post(handlers::add_repository)) .route( diff --git a/compliance-agent/src/config.rs b/compliance-agent/src/config.rs index 612fc7d..f166007 100644 --- a/compliance-agent/src/config.rs +++ b/compliance-agent/src/config.rs @@ -45,6 +45,8 @@ pub fn load_config() -> Result { .unwrap_or_else(|| "0 0 0 * * *".to_string()), git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH") .unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()), + ssh_key_path: env_var_opt("SSH_KEY_PATH") + .unwrap_or_else(|| "/data/compliance-scanner/ssh/id_ed25519".to_string()), keycloak_url: env_var_opt("KEYCLOAK_URL"), keycloak_realm: env_var_opt("KEYCLOAK_REALM"), }) diff --git a/compliance-agent/src/main.rs b/compliance-agent/src/main.rs index c67ac85..97ec23e 100644 --- a/compliance-agent/src/main.rs +++ b/compliance-agent/src/main.rs @@ -7,6 +7,7 @@ mod llm; mod pipeline; mod rag; mod scheduler; +mod ssh; #[allow(dead_code)] mod trackers; mod webhooks; @@ -20,6 +21,12 @@ async fn main() -> Result<(), Box> { tracing::info!("Loading configuration..."); let config = config::load_config()?; + // Ensure SSH key pair exists for cloning private repos + match ssh::ensure_ssh_key(&config.ssh_key_path) { + Ok(pubkey) => tracing::info!("SSH public key: {}", pubkey.trim()), + Err(e) => tracing::warn!("SSH key generation skipped: {e}"), + } + tracing::info!("Connecting to MongoDB..."); let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?; db.ensure_indexes().await?; diff --git a/compliance-agent/src/pipeline/git.rs b/compliance-agent/src/pipeline/git.rs index 3b6a01c..2585040 100644 --- a/compliance-agent/src/pipeline/git.rs +++ b/compliance-agent/src/pipeline/git.rs @@ -1,17 +1,82 @@ use std::path::{Path, PathBuf}; -use git2::{FetchOptions, Repository}; +use git2::{Cred, FetchOptions, RemoteCallbacks, Repository}; use crate::error::AgentError; +/// Credentials for accessing a private repository +#[derive(Debug, Clone, Default)] +pub struct RepoCredentials { + /// Path to the SSH private key (for SSH URLs) + pub ssh_key_path: Option, + /// Auth token / password (for HTTPS URLs) + pub auth_token: Option, + /// Username for HTTPS auth (defaults to "x-access-token") + pub auth_username: Option, +} + +impl RepoCredentials { + pub(crate) fn make_callbacks(&self) -> RemoteCallbacks<'_> { + let mut callbacks = RemoteCallbacks::new(); + let ssh_key = self.ssh_key_path.clone(); + let token = self.auth_token.clone(); + let username = self.auth_username.clone(); + + callbacks.credentials(move |_url, username_from_url, allowed_types| { + // SSH key authentication + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + if let Some(ref key_path) = ssh_key { + let key = Path::new(key_path); + if key.exists() { + let user = username_from_url.unwrap_or("git"); + return Cred::ssh_key(user, None, key, None); + } + } + } + + // HTTPS userpass authentication + if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { + if let Some(ref tok) = token { + let user = username + .as_deref() + .unwrap_or("x-access-token"); + return Cred::userpass_plaintext(user, tok); + } + } + + Cred::default() + }); + + callbacks + } + + fn fetch_options(&self) -> FetchOptions<'_> { + let mut fetch_opts = FetchOptions::new(); + if self.has_credentials() { + fetch_opts.remote_callbacks(self.make_callbacks()); + } + fetch_opts + } + + fn has_credentials(&self) -> bool { + self.ssh_key_path + .as_ref() + .map(|p| Path::new(p).exists()) + .unwrap_or(false) + || self.auth_token.is_some() + } +} + pub struct GitOps { base_path: PathBuf, + credentials: RepoCredentials, } impl GitOps { - pub fn new(base_path: &str) -> Self { + pub fn new(base_path: &str, credentials: RepoCredentials) -> Self { Self { base_path: PathBuf::from(base_path), + credentials, } } @@ -22,17 +87,25 @@ impl GitOps { self.fetch(&repo_path)?; } else { std::fs::create_dir_all(&repo_path)?; - Repository::clone(git_url, &repo_path)?; + self.clone_repo(git_url, &repo_path)?; tracing::info!("Cloned {git_url} to {}", repo_path.display()); } Ok(repo_path) } + fn clone_repo(&self, git_url: &str, repo_path: &Path) -> Result<(), AgentError> { + let mut builder = git2::build::RepoBuilder::new(); + let fetch_opts = self.credentials.fetch_options(); + builder.fetch_options(fetch_opts); + builder.clone(git_url, repo_path)?; + Ok(()) + } + fn fetch(&self, repo_path: &Path) -> Result<(), AgentError> { let repo = Repository::open(repo_path)?; let mut remote = repo.find_remote("origin")?; - let mut fetch_opts = FetchOptions::new(); + let mut fetch_opts = self.credentials.fetch_options(); remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?; // Fast-forward to origin/HEAD @@ -48,6 +121,15 @@ impl GitOps { Ok(()) } + /// Test that we can access a remote repository (used during add validation) + pub fn test_access(git_url: &str, credentials: &RepoCredentials) -> Result<(), AgentError> { + let mut remote = git2::Remote::create_detached(git_url)?; + let callbacks = credentials.make_callbacks(); + remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None)?; + remote.disconnect()?; + Ok(()) + } + pub fn get_head_sha(repo_path: &Path) -> Result { let repo = Repository::open(repo_path)?; let head = repo.head()?; diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index a9d1ac5..6452c09 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -11,7 +11,7 @@ use crate::error::AgentError; use crate::llm::LlmClient; use crate::pipeline::code_review::CodeReviewScanner; use crate::pipeline::cve::CveScanner; -use crate::pipeline::git::GitOps; +use crate::pipeline::git::{GitOps, RepoCredentials}; use crate::pipeline::gitleaks::GitleaksScanner; use crate::pipeline::lint::LintScanner; use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner}; @@ -117,7 +117,12 @@ impl PipelineOrchestrator { // Stage 0: Change detection tracing::info!("[{repo_id}] Stage 0: Change detection"); - let git_ops = GitOps::new(&self.config.git_clone_base_path); + let creds = RepoCredentials { + ssh_key_path: Some(self.config.ssh_key_path.clone()), + auth_token: repo.auth_token.clone(), + auth_username: repo.auth_username.clone(), + }; + let git_ops = GitOps::new(&self.config.git_clone_base_path, creds); let repo_path = git_ops.clone_or_fetch(&repo.git_url, &repo.name)?; if !GitOps::has_new_commits(&repo_path, repo.last_scanned_commit.as_deref())? { diff --git a/compliance-agent/src/ssh.rs b/compliance-agent/src/ssh.rs new file mode 100644 index 0000000..470565d --- /dev/null +++ b/compliance-agent/src/ssh.rs @@ -0,0 +1,57 @@ +use std::path::Path; + +use crate::error::AgentError; + +/// Ensure the SSH key pair exists at the given path, generating it if missing. +/// Returns the public key contents. +pub fn ensure_ssh_key(key_path: &str) -> Result { + let private_path = Path::new(key_path); + let public_path = private_path.with_extension("pub"); + + if private_path.exists() && public_path.exists() { + return std::fs::read_to_string(&public_path).map_err(|e| { + AgentError::Config(format!("Failed to read SSH public key: {e}")) + }); + } + + // Create parent directory + if let Some(parent) = private_path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Generate ed25519 key pair using ssh-keygen + let output = std::process::Command::new("ssh-keygen") + .args([ + "-t", + "ed25519", + "-f", + key_path, + "-N", + "", // no passphrase + "-C", + "compliance-scanner-agent", + ]) + .output() + .map_err(|e| AgentError::Config(format!("Failed to run ssh-keygen: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AgentError::Config(format!( + "ssh-keygen failed: {stderr}" + ))); + } + + // Set correct permissions + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(private_path, std::fs::Permissions::from_mode(0o600))?; + } + + let public_key = std::fs::read_to_string(&public_path).map_err(|e| { + AgentError::Config(format!("Failed to read generated SSH public key: {e}")) + })?; + + tracing::info!("Generated new SSH key pair at {key_path}"); + Ok(public_key) +} diff --git a/compliance-core/src/config.rs b/compliance-core/src/config.rs index aba5725..de60b26 100644 --- a/compliance-core/src/config.rs +++ b/compliance-core/src/config.rs @@ -24,6 +24,7 @@ pub struct AgentConfig { pub scan_schedule: String, pub cve_monitor_schedule: String, pub git_clone_base_path: String, + pub ssh_key_path: String, pub keycloak_url: Option, pub keycloak_realm: Option, } diff --git a/compliance-core/src/models/repository.rs b/compliance-core/src/models/repository.rs index cc43c30..6842ba2 100644 --- a/compliance-core/src/models/repository.rs +++ b/compliance-core/src/models/repository.rs @@ -28,6 +28,12 @@ pub struct TrackedRepository { pub tracker_type: Option, pub tracker_owner: Option, pub tracker_repo: Option, + /// Optional auth token for HTTPS private repos (PAT or password) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_token: Option, + /// Optional username for HTTPS auth (defaults to "x-access-token" for PATs) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_username: Option, pub last_scanned_commit: Option, #[serde(default, deserialize_with = "deserialize_findings_count")] pub findings_count: u32, @@ -64,6 +70,8 @@ impl TrackedRepository { default_branch: "main".to_string(), local_path: None, scan_schedule: None, + auth_token: None, + auth_username: None, webhook_enabled: false, tracker_type: None, tracker_owner: None, diff --git a/compliance-dashboard/src/infrastructure/repositories.rs b/compliance-dashboard/src/infrastructure/repositories.rs index 0e4fe29..bb2ce28 100644 --- a/compliance-dashboard/src/infrastructure/repositories.rs +++ b/compliance-dashboard/src/infrastructure/repositories.rs @@ -34,19 +34,29 @@ pub async fn add_repository( name: String, git_url: String, default_branch: String, + auth_token: Option, + auth_username: Option, ) -> Result<(), ServerFnError> { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; let url = format!("{}/api/v1/repositories", state.agent_api_url); + let mut body = serde_json::json!({ + "name": name, + "git_url": git_url, + "default_branch": default_branch, + }); + if let Some(token) = auth_token.filter(|t| !t.is_empty()) { + body["auth_token"] = serde_json::Value::String(token); + } + if let Some(username) = auth_username.filter(|u| !u.is_empty()) { + body["auth_username"] = serde_json::Value::String(username); + } + let client = reqwest::Client::new(); let resp = client .post(&url) - .json(&serde_json::json!({ - "name": name, - "git_url": git_url, - "default_branch": default_branch, - })) + .json(&body) .send() .await .map_err(|e| ServerFnError::new(e.to_string()))?; @@ -61,6 +71,32 @@ pub async fn add_repository( Ok(()) } +#[server] +pub async fn fetch_ssh_public_key() -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/settings/ssh-public-key", state.agent_api_url); + + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + if !resp.status().is_success() { + return Err(ServerFnError::new("SSH key not available".to_string())); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(body + .get("public_key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string()) +} + #[server] pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> { let state: super::server_state::ServerState = diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index 779305c..3f91d11 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -23,6 +23,12 @@ pub fn RepositoriesPage() -> Element { let mut name = use_signal(String::new); let mut git_url = use_signal(String::new); let mut branch = use_signal(|| "main".to_string()); + let mut auth_token = use_signal(String::new); + let mut auth_username = use_signal(String::new); + let mut show_auth = use_signal(|| false); + let mut show_ssh_key = use_signal(|| false); + let mut ssh_public_key = use_signal(String::new); + let mut adding = use_signal(|| false); let mut toasts = use_context::(); let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name) let mut scanning_ids = use_signal(Vec::::new); @@ -66,7 +72,7 @@ pub fn RepositoriesPage() -> Element { label { "Git URL" } input { r#type: "text", - placeholder: "https://github.com/org/repo.git", + placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git", value: "{git_url}", oninput: move |e| git_url.set(e.value()), } @@ -80,26 +86,105 @@ pub fn RepositoriesPage() -> Element { oninput: move |e| branch.set(e.value()), } } + + // Private repo auth section + div { style: "margin-top: 8px;", + button { + class: "btn btn-ghost", + style: "font-size: 12px; padding: 4px 8px;", + onclick: move |_| { + show_auth.toggle(); + if !show_ssh_key() { + // Fetch SSH key on first open + show_ssh_key.set(true); + spawn(async move { + match crate::infrastructure::repositories::fetch_ssh_public_key().await { + Ok(key) => ssh_public_key.set(key), + Err(_) => ssh_public_key.set("(not available)".to_string()), + } + }); + } + }, + if show_auth() { "Hide auth options" } else { "Private repository?" } + } + } + + if show_auth() { + div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;", + // SSH deploy key display + div { style: "margin-bottom: 12px;", + label { style: "font-size: 12px; color: var(--text-secondary);", + "For SSH URLs: add this deploy key (read-only) to your repository" + } + div { + style: "margin-top: 4px; padding: 8px; background: var(--bg-secondary); border-radius: 4px; font-family: monospace; font-size: 11px; word-break: break-all; user-select: all;", + if ssh_public_key().is_empty() { + "Loading..." + } else { + "{ssh_public_key}" + } + } + } + + // HTTPS auth fields + p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;", + "For HTTPS URLs: provide an access token (PAT) or username/password" + } + div { class: "form-group", + label { "Auth Token / Password" } + input { + r#type: "password", + placeholder: "ghp_xxxx or personal access token", + value: "{auth_token}", + oninput: move |e| auth_token.set(e.value()), + } + } + div { class: "form-group", + label { "Username (optional, defaults to x-access-token)" } + input { + r#type: "text", + placeholder: "x-access-token", + value: "{auth_username}", + oninput: move |e| auth_username.set(e.value()), + } + } + } + } + button { class: "btn btn-primary", + disabled: adding(), onclick: move |_| { let n = name(); let u = git_url(); let b = branch(); + let tok = { + let v = auth_token(); + if v.is_empty() { None } else { Some(v) } + }; + let usr = { + let v = auth_username(); + if v.is_empty() { None } else { Some(v) } + }; + adding.set(true); spawn(async move { - match crate::infrastructure::repositories::add_repository(n, u, b).await { + match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr).await { Ok(_) => { toasts.push(ToastType::Success, "Repository added"); repos.restart(); } Err(e) => toasts.push(ToastType::Error, e.to_string()), } + adding.set(false); }); show_add_form.set(false); + show_auth.set(false); name.set(String::new()); git_url.set(String::new()); + auth_token.set(String::new()); + auth_username.set(String::new()); }, - "Add" + if adding() { "Validating..." } else { "Add" } } } }