feat: add private repository support with SSH key and HTTPS token auth
Some checks failed
CI / Clippy (push) Failing after 2m39s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Failing after 3s
CI / Clippy (pull_request) Failing after 2m33s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Detect Changes (pull_request) 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 / Format (push) Failing after 4s
CI / Deploy MCP (pull_request) Has been skipped
CI / Deploy MCP (push) 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

- Generate SSH ed25519 key pair on agent startup for cloning private repos via SSH
- Add GET /api/v1/settings/ssh-public-key endpoint to expose deploy key
- Add auth_token and auth_username fields to TrackedRepository model
- Wire git2 credential callbacks for both SSH and HTTPS authentication
- Validate repository access before saving (test-connect on add)
- Update dashboard add form with optional auth section showing deploy key and token fields
- Show error toast if private repo cannot be accessed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-09 11:53:17 +01:00
parent 23ba52276b
commit 492a93a83e
13 changed files with 338 additions and 18 deletions

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -82,6 +82,8 @@ pub struct AddRepositoryRequest {
pub git_url: String,
#[serde(default = "default_branch")]
pub default_branch: String,
pub auth_token: Option<String>,
pub auth_username: Option<String>,
pub tracker_type: Option<TrackerType>,
pub tracker_owner: Option<String>,
pub tracker_repo: Option<String>,
@@ -284,9 +286,25 @@ pub async fn list_repositories(
pub async fn add_repository(
Extension(agent): AgentExt,
Json(req): Json<AddRepositoryRequest>,
) -> Result<Json<ApiResponse<TrackedRepository>>, StatusCode> {
) -> Result<Json<ApiResponse<TrackedRepository>>, (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<Json<serde_json::Value>, 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<String>,

View File

@@ -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(

View File

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

View File

@@ -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<dyn std::error::Error>> {
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?;

View File

@@ -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<String>,
/// Auth token / password (for HTTPS URLs)
pub auth_token: Option<String>,
/// Username for HTTPS auth (defaults to "x-access-token")
pub auth_username: Option<String>,
}
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<String, AgentError> {
let repo = Repository::open(repo_path)?;
let head = repo.head()?;

View File

@@ -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())? {

View File

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