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
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:
@@ -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) {
|
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -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) {
|
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ pub struct AddRepositoryRequest {
|
|||||||
pub git_url: String,
|
pub git_url: String,
|
||||||
#[serde(default = "default_branch")]
|
#[serde(default = "default_branch")]
|
||||||
pub default_branch: String,
|
pub default_branch: String,
|
||||||
|
pub auth_token: Option<String>,
|
||||||
|
pub auth_username: Option<String>,
|
||||||
pub tracker_type: Option<TrackerType>,
|
pub tracker_type: Option<TrackerType>,
|
||||||
pub tracker_owner: Option<String>,
|
pub tracker_owner: Option<String>,
|
||||||
pub tracker_repo: Option<String>,
|
pub tracker_repo: Option<String>,
|
||||||
@@ -284,9 +286,25 @@ pub async fn list_repositories(
|
|||||||
pub async fn add_repository(
|
pub async fn add_repository(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Json(req): Json<AddRepositoryRequest>,
|
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);
|
let mut repo = TrackedRepository::new(req.name, req.git_url);
|
||||||
repo.default_branch = req.default_branch;
|
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_type = req.tracker_type;
|
||||||
repo.tracker_owner = req.tracker_owner;
|
repo.tracker_owner = req.tracker_owner;
|
||||||
repo.tracker_repo = req.tracker_repo;
|
repo.tracker_repo = req.tracker_repo;
|
||||||
@@ -297,7 +315,7 @@ pub async fn add_repository(
|
|||||||
.repositories()
|
.repositories()
|
||||||
.insert_one(&repo)
|
.insert_one(&repo)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::CONFLICT)?;
|
.map_err(|_| (StatusCode::CONFLICT, "Repository already exists".to_string()))?;
|
||||||
|
|
||||||
Ok(Json(ApiResponse {
|
Ok(Json(ApiResponse {
|
||||||
data: repo,
|
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(
|
pub async fn trigger_scan(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub fn build_router() -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/v1/health", get(handlers::health))
|
.route("/api/v1/health", get(handlers::health))
|
||||||
.route("/api/v1/stats/overview", get(handlers::stats_overview))
|
.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", get(handlers::list_repositories))
|
||||||
.route("/api/v1/repositories", post(handlers::add_repository))
|
.route("/api/v1/repositories", post(handlers::add_repository))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
|||||||
.unwrap_or_else(|| "0 0 0 * * *".to_string()),
|
.unwrap_or_else(|| "0 0 0 * * *".to_string()),
|
||||||
git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH")
|
git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH")
|
||||||
.unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()),
|
.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_url: env_var_opt("KEYCLOAK_URL"),
|
||||||
keycloak_realm: env_var_opt("KEYCLOAK_REALM"),
|
keycloak_realm: env_var_opt("KEYCLOAK_REALM"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod llm;
|
|||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod rag;
|
mod rag;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
|
mod ssh;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod trackers;
|
mod trackers;
|
||||||
mod webhooks;
|
mod webhooks;
|
||||||
@@ -20,6 +21,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
tracing::info!("Loading configuration...");
|
tracing::info!("Loading configuration...");
|
||||||
let config = config::load_config()?;
|
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...");
|
tracing::info!("Connecting to MongoDB...");
|
||||||
let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||||
db.ensure_indexes().await?;
|
db.ensure_indexes().await?;
|
||||||
|
|||||||
@@ -1,17 +1,82 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use git2::{FetchOptions, Repository};
|
use git2::{Cred, FetchOptions, RemoteCallbacks, Repository};
|
||||||
|
|
||||||
use crate::error::AgentError;
|
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 {
|
pub struct GitOps {
|
||||||
base_path: PathBuf,
|
base_path: PathBuf,
|
||||||
|
credentials: RepoCredentials,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GitOps {
|
impl GitOps {
|
||||||
pub fn new(base_path: &str) -> Self {
|
pub fn new(base_path: &str, credentials: RepoCredentials) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_path: PathBuf::from(base_path),
|
base_path: PathBuf::from(base_path),
|
||||||
|
credentials,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,17 +87,25 @@ impl GitOps {
|
|||||||
self.fetch(&repo_path)?;
|
self.fetch(&repo_path)?;
|
||||||
} else {
|
} else {
|
||||||
std::fs::create_dir_all(&repo_path)?;
|
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());
|
tracing::info!("Cloned {git_url} to {}", repo_path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(repo_path)
|
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> {
|
fn fetch(&self, repo_path: &Path) -> Result<(), AgentError> {
|
||||||
let repo = Repository::open(repo_path)?;
|
let repo = Repository::open(repo_path)?;
|
||||||
let mut remote = repo.find_remote("origin")?;
|
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)?;
|
remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?;
|
||||||
|
|
||||||
// Fast-forward to origin/HEAD
|
// Fast-forward to origin/HEAD
|
||||||
@@ -48,6 +121,15 @@ impl GitOps {
|
|||||||
Ok(())
|
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> {
|
pub fn get_head_sha(repo_path: &Path) -> Result<String, AgentError> {
|
||||||
let repo = Repository::open(repo_path)?;
|
let repo = Repository::open(repo_path)?;
|
||||||
let head = repo.head()?;
|
let head = repo.head()?;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::error::AgentError;
|
|||||||
use crate::llm::LlmClient;
|
use crate::llm::LlmClient;
|
||||||
use crate::pipeline::code_review::CodeReviewScanner;
|
use crate::pipeline::code_review::CodeReviewScanner;
|
||||||
use crate::pipeline::cve::CveScanner;
|
use crate::pipeline::cve::CveScanner;
|
||||||
use crate::pipeline::git::GitOps;
|
use crate::pipeline::git::{GitOps, RepoCredentials};
|
||||||
use crate::pipeline::gitleaks::GitleaksScanner;
|
use crate::pipeline::gitleaks::GitleaksScanner;
|
||||||
use crate::pipeline::lint::LintScanner;
|
use crate::pipeline::lint::LintScanner;
|
||||||
use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner};
|
use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner};
|
||||||
@@ -117,7 +117,12 @@ impl PipelineOrchestrator {
|
|||||||
|
|
||||||
// Stage 0: Change detection
|
// Stage 0: Change detection
|
||||||
tracing::info!("[{repo_id}] 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)?;
|
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())? {
|
if !GitOps::has_new_commits(&repo_path, repo.last_scanned_commit.as_deref())? {
|
||||||
|
|||||||
57
compliance-agent/src/ssh.rs
Normal file
57
compliance-agent/src/ssh.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ pub struct AgentConfig {
|
|||||||
pub scan_schedule: String,
|
pub scan_schedule: String,
|
||||||
pub cve_monitor_schedule: String,
|
pub cve_monitor_schedule: String,
|
||||||
pub git_clone_base_path: String,
|
pub git_clone_base_path: String,
|
||||||
|
pub ssh_key_path: String,
|
||||||
pub keycloak_url: Option<String>,
|
pub keycloak_url: Option<String>,
|
||||||
pub keycloak_realm: Option<String>,
|
pub keycloak_realm: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ pub struct TrackedRepository {
|
|||||||
pub tracker_type: Option<TrackerType>,
|
pub tracker_type: Option<TrackerType>,
|
||||||
pub tracker_owner: Option<String>,
|
pub tracker_owner: Option<String>,
|
||||||
pub tracker_repo: Option<String>,
|
pub tracker_repo: Option<String>,
|
||||||
|
/// Optional auth token for HTTPS private repos (PAT or password)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auth_token: Option<String>,
|
||||||
|
/// Optional username for HTTPS auth (defaults to "x-access-token" for PATs)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auth_username: Option<String>,
|
||||||
pub last_scanned_commit: Option<String>,
|
pub last_scanned_commit: Option<String>,
|
||||||
#[serde(default, deserialize_with = "deserialize_findings_count")]
|
#[serde(default, deserialize_with = "deserialize_findings_count")]
|
||||||
pub findings_count: u32,
|
pub findings_count: u32,
|
||||||
@@ -64,6 +70,8 @@ impl TrackedRepository {
|
|||||||
default_branch: "main".to_string(),
|
default_branch: "main".to_string(),
|
||||||
local_path: None,
|
local_path: None,
|
||||||
scan_schedule: None,
|
scan_schedule: None,
|
||||||
|
auth_token: None,
|
||||||
|
auth_username: None,
|
||||||
webhook_enabled: false,
|
webhook_enabled: false,
|
||||||
tracker_type: None,
|
tracker_type: None,
|
||||||
tracker_owner: None,
|
tracker_owner: None,
|
||||||
|
|||||||
@@ -34,19 +34,29 @@ pub async fn add_repository(
|
|||||||
name: String,
|
name: String,
|
||||||
git_url: String,
|
git_url: String,
|
||||||
default_branch: String,
|
default_branch: String,
|
||||||
|
auth_token: Option<String>,
|
||||||
|
auth_username: Option<String>,
|
||||||
) -> Result<(), ServerFnError> {
|
) -> Result<(), ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
let url = format!("{}/api/v1/repositories", state.agent_api_url);
|
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 client = reqwest::Client::new();
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.json(&serde_json::json!({
|
.json(&body)
|
||||||
"name": name,
|
|
||||||
"git_url": git_url,
|
|
||||||
"default_branch": default_branch,
|
|
||||||
}))
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
@@ -61,6 +71,32 @@ pub async fn add_repository(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> {
|
||||||
|
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]
|
#[server]
|
||||||
pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> {
|
pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
let mut name = use_signal(String::new);
|
let mut name = use_signal(String::new);
|
||||||
let mut git_url = use_signal(String::new);
|
let mut git_url = use_signal(String::new);
|
||||||
let mut branch = use_signal(|| "main".to_string());
|
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::<Toasts>();
|
let mut toasts = use_context::<Toasts>();
|
||||||
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
||||||
let mut scanning_ids = use_signal(Vec::<String>::new);
|
let mut scanning_ids = use_signal(Vec::<String>::new);
|
||||||
@@ -66,7 +72,7 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
label { "Git URL" }
|
label { "Git URL" }
|
||||||
input {
|
input {
|
||||||
r#type: "text",
|
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}",
|
value: "{git_url}",
|
||||||
oninput: move |e| git_url.set(e.value()),
|
oninput: move |e| git_url.set(e.value()),
|
||||||
}
|
}
|
||||||
@@ -80,26 +86,105 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
oninput: move |e| branch.set(e.value()),
|
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 {
|
button {
|
||||||
class: "btn btn-primary",
|
class: "btn btn-primary",
|
||||||
|
disabled: adding(),
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let n = name();
|
let n = name();
|
||||||
let u = git_url();
|
let u = git_url();
|
||||||
let b = branch();
|
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 {
|
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(_) => {
|
Ok(_) => {
|
||||||
toasts.push(ToastType::Success, "Repository added");
|
toasts.push(ToastType::Success, "Repository added");
|
||||||
repos.restart();
|
repos.restart();
|
||||||
}
|
}
|
||||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||||
}
|
}
|
||||||
|
adding.set(false);
|
||||||
});
|
});
|
||||||
show_add_form.set(false);
|
show_add_form.set(false);
|
||||||
|
show_auth.set(false);
|
||||||
name.set(String::new());
|
name.set(String::new());
|
||||||
git_url.set(String::new());
|
git_url.set(String::new());
|
||||||
|
auth_token.set(String::new());
|
||||||
|
auth_username.set(String::new());
|
||||||
},
|
},
|
||||||
"Add"
|
if adding() { "Validating..." } else { "Add" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user