From 69dc42f5bbbd1abc1b2351764c96b4a80fed11f9 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Sat, 4 Oct 2025 23:12:28 -0700 Subject: [PATCH] Added first-class Azure Repos support, including CLI commands, enumeration, and documentation updates --- CHANGELOG.md | 3 +- Cargo.toml | 2 +- README.md | 43 +++ data/rules/azuredevops.yml | 30 +- src/azure.rs | 576 ++++++++++++++++++++++++++++++++++ src/cli/commands/azure.rs | 98 ++++++ src/cli/commands/inputs.rs | 36 +++ src/cli/commands/mod.rs | 1 + src/cli/global.rs | 8 +- src/lib.rs | 1 + src/main.rs | 28 +- src/reporter.rs | 7 + src/reporter/json_format.rs | 8 + src/scanner/mod.rs | 3 +- src/scanner/repos.rs | 75 ++++- src/scanner/runner.rs | 11 +- tests/int_allowlist.rs | 7 + tests/int_base64.rs | 1 - tests/int_bitbucket.rs | 8 + tests/int_dedup.rs | 8 + tests/int_github.rs | 8 + tests/int_gitlab.rs | 15 + tests/int_redact.rs | 7 + tests/int_slack.rs | 13 + tests/int_validation_cache.rs | 8 + tests/int_vulnerable_files.rs | 15 + 26 files changed, 1003 insertions(+), 17 deletions(-) create mode 100644 src/azure.rs create mode 100644 src/cli/commands/azure.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f59997..4e1349d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ All notable changes to this project will be documented in this file. -## [Unreleased] +## [v1.55.0] +- Added first-class Azure Repos support, including CLI commands, enumeration, and documentation updates - Improved performance of tree-sitter parsing - Updated Windows build script to ensure static binary is produced diff --git a/Cargo.toml b/Cargo.toml index b743646..579d4e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ publish = false [package] name = "kingfisher" -version = "1.54.0" +version = "1.55.0" description = "MongoDB's blazingly fast secret scanning and validation tool" edition.workspace = true rust-version.workspace = true diff --git a/README.md b/README.md index ec89f63..4a5e62b 100644 --- a/README.md +++ b/README.md @@ -574,6 +574,47 @@ kingfisher gitlab repos list --group my-group --include-subgroups kingfisher gitlab repos list --group my-group --gitlab-exclude my-group/**/legacy-* ``` +## Scanning Azure Repos + +### Scan Azure DevOps organization or collection (requires `KF_AZURE_TOKEN` or `KF_AZURE_PAT`) + +```bash +kingfisher scan --azure-organization my-org + +# Azure DevOps Server example +KF_AZURE_PAT="pat" kingfisher scan --azure-organization DefaultCollection --azure-base-url https://ado.internal.example/tfs/ +``` + +### Scan specific Azure DevOps projects + +Projects are specified as `ORGANIZATION/PROJECT`. Repeat the flag for multiple projects. + +```bash +kingfisher scan --azure-project my-org/payments --azure-project my-org/core-platform +``` + +### Skip specific Azure repositories during enumeration + +Repeat `--azure-exclude` to ignore repositories when scanning organizations or projects. +Use identifiers like `ORGANIZATION/PROJECT/REPOSITORY` or gitignore-style patterns such as +`my-org/*/archive-*`. + +```bash +kingfisher scan --azure-organization my-org \ + --azure-exclude my-org/payments/legacy-service \ + --azure-exclude my-org/**/archive-* +``` + +### List Azure repositories + +```bash +kingfisher azure repos list --organization my-org +# list repositories for specific projects +kingfisher azure repos list --project my-org/app --project my-org/api +# skip specific repositories while listing (supports glob patterns) +kingfisher azure repos list --organization my-org --azure-exclude my-org/**/experimental-* +``` + ## Scanning Gitea ### Scan Gitea organization (requires `KF_GITEA_TOKEN`) @@ -769,6 +810,8 @@ KF_SLACK_TOKEN="xoxp-1234..." kingfisher scan \ | `KF_GITLAB_TOKEN` | GitLab Personal Access Token | | `KF_GITEA_TOKEN` | Gitea Personal Access Token | | `KF_GITEA_USERNAME` | Username for private Gitea clones (used with `KF_GITEA_TOKEN`) | +| `KF_AZURE_TOKEN` / `KF_AZURE_PAT` | Azure DevOps Personal Access Token | +| `KF_AZURE_USERNAME` | Username to use with Azure DevOps PATs (defaults to `pat` when unset) | | `KF_BITBUCKET_USERNAME` | Bitbucket username for basic authentication | | `KF_BITBUCKET_APP_PASSWORD` / `KF_BITBUCKET_TOKEN` | Bitbucket app password or server token | | `KF_BITBUCKET_OAUTH_TOKEN` | Bitbucket OAuth or PAT token | diff --git a/data/rules/azuredevops.yml b/data/rules/azuredevops.yml index 4188999..a607bc9 100644 --- a/data/rules/azuredevops.yml +++ b/data/rules/azuredevops.yml @@ -1,13 +1,27 @@ rules: - - name: Azure DevOps Personal Access Token + - name: Azure DevOps Organization id: kingfisher.azure.devops.1 pattern: | (?xi) \b - azure - (?:.|[\n\r]){0,32}? + dev\.azure\.com/ ( - [a-z0-9]{75}AZDO[a-z0-9]{5} + [a-z0-9][a-z0-9-]{0,61}[a-z0-9] + ) + confidence: medium + min_entropy: 2.5 + visible: false + examples: + - https://dev.azure.com/contoso + - dev.azure.com/somebody123 + + - name: Azure DevOps Personal Access Token + id: kingfisher.azure.devops.2 + pattern: | + (?xi) + \b + ( + [a-z0-9]{75,76}AZDO[a-z0-9]{4,5} ) \b min_entropy: 3 @@ -17,16 +31,20 @@ rules: references: - https://learn.microsoft.com/en-us/rest/api/azure/devops/profile/profiles/get?view=azure-devops-rest-7.1&tabs=HTTP - https://learn.microsoft.com/en-us/azure/devops/release-notes/2024/general/sprint-241-update + depends_on_rule: + - rule_id: kingfisher.azure.devops.1 + variable: AZURE_DEVOPS_ORG validation: type: Http content: request: headers: Authorization: 'Basic {{ ":" | append: TOKEN | b64enc }}' + Accept: application/json method: GET - url: https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1-preview.1 + url: "https://dev.azure.com/{{ AZURE_DEVOPS_ORG | split: '/' | last }}/_apis/projects?api-version=7.1-preview.1" response_matcher: - report_response: true - type: StatusMatch status: - - 200 + - 200 \ No newline at end of file diff --git a/src/azure.rs b/src/azure.rs new file mode 100644 index 0000000..e9dea55 --- /dev/null +++ b/src/azure.rs @@ -0,0 +1,576 @@ +use std::{ + collections::{HashMap, HashSet}, + env, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; + +// NOTE: We continue to issue the small number of Azure DevOps Git REST calls we need +// directly through `reqwest` instead of depending on the `azure_devops_rust_api` +// crate. The SDK does not yet expose stable coverage for wiki repositories or the +// preview API surfaces we rely on, while the raw requests keep the binary lean and +// let us opt into newer API versions as Microsoft rolls them out. + +use anyhow::{anyhow, Context, Result}; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use indicatif::{ProgressBar, ProgressStyle}; +use serde::Deserialize; +use tracing::warn; +use url::{form_urlencoded, Url}; + +use crate::{findings_store, git_url::GitUrl}; + +const API_VERSION: &str = "7.1-preview.1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepoType { + All, + Source, + Fork, +} + +impl RepoType { + fn allows(self, is_fork: bool) -> bool { + match self { + RepoType::All => true, + RepoType::Source => !is_fork, + RepoType::Fork => is_fork, + } + } +} + +#[derive(Debug, Clone)] +pub struct RepoSpecifiers { + pub organization: Vec, + pub project: Vec, + pub all_projects: bool, + pub repo_filter: RepoType, + pub exclude_repos: Vec, +} + +impl RepoSpecifiers { + pub fn is_empty(&self) -> bool { + self.organization.is_empty() && self.project.is_empty() + } +} + +#[derive(Debug)] +struct ExcludeMatcher { + exact: HashSet, + globs: Option, +} + +impl ExcludeMatcher { + fn matches(&self, name: &str) -> bool { + if self.exact.contains(name) { + return true; + } + if let Some(globs) = &self.globs { + return globs.is_match(name); + } + false + } + + fn is_empty(&self) -> bool { + self.exact.is_empty() && self.globs.is_none() + } +} + +fn looks_like_glob(pattern: &str) -> bool { + pattern.contains('*') || pattern.contains('?') || pattern.contains('[') +} + +fn encode_segment(segment: &str) -> String { + form_urlencoded::byte_serialize(segment.as_bytes()).collect::() +} + +fn normalize_repo_identifier(parts: &[String]) -> Option { + if parts.len() < 3 { + return None; + } + let repo = parts.last()?.trim().trim_matches('/'); + let project = parts.get(parts.len() - 2)?.trim().trim_matches('/'); + if repo.is_empty() || project.is_empty() { + return None; + } + let owner_segments = &parts[..parts.len() - 2]; + let mut normalized: Vec = + owner_segments.iter().map(|s| s.trim().trim_matches('/').to_lowercase()).collect(); + normalized.retain(|s| !s.is_empty()); + normalized.push(project.to_lowercase()); + normalized.push(repo.trim_end_matches(".git").to_lowercase()); + if normalized.is_empty() { + None + } else { + Some(normalized.join("/")) + } +} + +fn parse_repo_identifier_from_path(path: &str) -> Option { + let segments: Vec = path + .trim_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + if segments.len() < 3 { + return None; + } + + // Case 1: Azure URL-style with "_git" marker: ...//_git/ + if segments[segments.len().saturating_sub(2)] == "_git" { + let mut trimmed = segments.clone(); + let repo = trimmed.pop()?; // + trimmed.pop()?; // drop "_git" + trimmed.push(repo); // ...// + return normalize_repo_identifier(&trimmed); + } + + // Case 2: Simple path (and glob-friendly): ...// + // Accept as-is so things like "org/*/repo" work. + normalize_repo_identifier(&segments) +} + +fn parse_repo_identifier_from_url(remote_url: &str) -> Option { + let url = Url::parse(remote_url).ok()?; + if let Some(path) = url.path_segments() { + let segments: Vec = + path.filter(|segment| !segment.is_empty()).map(|segment| segment.to_string()).collect(); + if segments.len() < 3 { + return None; + } + let mut trimmed = segments.clone(); + let repo = trimmed.pop()?; + let marker = trimmed.pop()?; + if marker != "_git" { + return None; + } + trimmed.push(repo); + normalize_repo_identifier(&trimmed) + } else { + None + } +} + +fn parse_excluded_repo(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if let Some(name) = parse_repo_identifier_from_url(trimmed) { + return Some(name); + } + + if let Some(idx) = trimmed.rfind(':') { + if let Some(name) = parse_repo_identifier_from_path(&trimmed[idx + 1..]) { + return Some(name); + } + } + + parse_repo_identifier_from_path(trimmed) +} + +fn build_exclude_matcher(exclude_repos: &[String]) -> ExcludeMatcher { + let mut exact = HashSet::new(); + let mut glob_builder = GlobSetBuilder::new(); + let mut has_glob = false; + + for raw in exclude_repos { + match parse_excluded_repo(raw) { + Some(name) => { + if looks_like_glob(&name) { + match Glob::new(&name) { + Ok(glob) => { + glob_builder.add(glob); + has_glob = true; + } + Err(err) => { + warn!("Ignoring invalid Azure exclusion pattern '{raw}': {err}"); + exact.insert(name); + } + } + } else { + exact.insert(name); + } + } + None => { + warn!("Ignoring invalid Azure exclusion '{raw}' (expected organization/project/repository)"); + } + } + } + + let globs = if has_glob { + match glob_builder.build() { + Ok(set) => Some(set), + Err(err) => { + warn!("Failed to build Azure exclusion patterns: {err}"); + None + } + } + } else { + None + }; + + ExcludeMatcher { exact, globs } +} + +fn should_exclude_repo(repo_url: &str, excludes: &ExcludeMatcher) -> bool { + if excludes.is_empty() { + return false; + } + if let Some(name) = parse_repo_identifier_from_url(repo_url) { + return excludes.matches(&name); + } + false +} + +#[derive(Debug, Deserialize, Default)] +struct AzureRepository { + #[serde(rename = "remoteUrl")] + remote_url: Option, + #[serde(rename = "webUrl")] + web_url: Option, + #[serde(rename = "isFork", default)] + is_fork: bool, + #[serde(default)] + project: AzureProjectRef, +} + +#[derive(Debug, Deserialize, Default)] +struct AzureProjectRef { + name: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct AzureListResponse { + value: Vec, +} + +struct AzureAuth { + username: Option, + token: Option, +} + +impl AzureAuth { + fn from_environment() -> Self { + let token = env::var("KF_AZURE_TOKEN").or_else(|_| env::var("KF_AZURE_PAT")).ok(); + let username = env::var("KF_AZURE_USERNAME").ok(); + Self { username, token } + } + + fn apply(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if let Some(token) = &self.token { + let username = self.username.as_deref().unwrap_or("pat"); + request.basic_auth(username, Some(token)) + } else { + request + } + } +} + +fn sanitize_remote_url(raw: &str) -> Option { + let mut url = Url::parse(raw).ok()?; + if !url.username().is_empty() { + url.set_username("").ok()?; + } + if url.password().is_some() { + url.set_password(None).ok()?; + } + Some(url.to_string()) +} + +async fn fetch_repositories_for_org( + client: &reqwest::Client, + base_url: &Url, + organization: &str, + auth: &AzureAuth, +) -> Result> { + let base = base_url.as_str().trim_end_matches('/'); + let encoded_org = encode_segment(organization); + let url = format!("{base}/{encoded_org}/_apis/git/repositories?api-version={API_VERSION}"); + let request = auth.apply(client.get(&url)); + let response = request.send().await?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!( + "Azure Repos API request failed for organization '{organization}' ({status}): {body}" + )); + } + let payload: AzureListResponse = response.json().await?; + Ok(payload.value) +} + +fn parse_project_specifiers(projects: &[String]) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for raw in projects { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let parts: Vec<&str> = trimmed.split('/').filter(|segment| !segment.is_empty()).collect(); + if parts.len() < 2 { + warn!( + "Ignoring Azure project specifier '{raw}' (expected format ORGANIZATION/PROJECT)" + ); + continue; + } + let project = parts.last().unwrap().to_lowercase(); + let organization = parts[..parts.len() - 1].join("/").to_lowercase(); + map.entry(organization).or_default().insert(project); + } + map +} + +fn canonicalize_organizations(spec: &RepoSpecifiers) -> HashMap { + let mut org_lookup: HashMap = HashMap::new(); + for org in &spec.organization { + let key = org.to_lowercase(); + org_lookup.entry(key).or_insert_with(|| org.clone()); + } + let project_map = parse_project_specifiers(&spec.project); + for (org_lower, _projects) in project_map { + org_lookup.entry(org_lower.clone()).or_insert(org_lower); + } + org_lookup +} + +pub async fn enumerate_repo_urls( + repo_specifiers: &RepoSpecifiers, + base_url: Url, + ignore_certs: bool, + mut progress: Option<&mut ProgressBar>, +) -> Result> { + let auth = AzureAuth::from_environment(); + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(ignore_certs) + .timeout(Duration::from_secs(30)) + .build()?; + + let exclude_matcher = build_exclude_matcher(&repo_specifiers.exclude_repos); + let project_filters = parse_project_specifiers(&repo_specifiers.project); + let has_project_filters = !project_filters.is_empty(); + + let org_lookup = canonicalize_organizations(repo_specifiers); + if org_lookup.is_empty() { + return Ok(Vec::new()); + } + + let mut organizations: Vec = org_lookup.values().cloned().collect(); + organizations.sort(); + organizations.dedup(); + + let mut repo_urls = Vec::new(); + + for org in organizations { + if let Some(pb) = &mut progress { + pb.set_message(format!("Fetching Azure repositories for {org}...")); + } + let repos = + fetch_repositories_for_org(&client, &base_url, &org, &auth).await.with_context( + || format!("Failed to fetch repositories for Azure organization '{org}'"), + )?; + + let org_key = org.to_lowercase(); + let project_filter = project_filters.get(&org_key); + + for repo in repos { + if !repo_specifiers.repo_filter.allows(repo.is_fork) { + continue; + } + + let project_name = repo + .project + .name + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or(""); + + if !repo_specifiers.all_projects { + if let Some(filters) = project_filter { + if project_name.is_empty() || !filters.contains(&project_name.to_lowercase()) { + continue; + } + } else if has_project_filters + && !repo_specifiers + .organization + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(&org)) + { + // Organization derived solely from project filters without an explicit match + continue; + } + } + + let remote = repo + .remote_url + .as_deref() + .or(repo.web_url.as_deref()) + .ok_or_else(|| anyhow!("Missing remote URL for Azure repository"))?; + let sanitized = match sanitize_remote_url(remote) { + Some(url) => url, + None => { + warn!("Skipping Azure repository with unparsable URL: {remote}"); + continue; + } + }; + if should_exclude_repo(&sanitized, &exclude_matcher) { + continue; + } + repo_urls.push(sanitized); + } + } + + repo_urls.sort(); + repo_urls.dedup(); + Ok(repo_urls) +} + +pub async fn list_repositories( + base_url: Url, + ignore_certs: bool, + progress_enabled: bool, + organizations: &[String], + projects: &[String], + all_projects: bool, + exclude_repos: &[String], + repo_filter: RepoType, +) -> Result<()> { + let repo_specifiers = RepoSpecifiers { + organization: organizations.to_vec(), + project: projects.to_vec(), + all_projects, + repo_filter, + exclude_repos: exclude_repos.to_vec(), + }; + + if repo_specifiers.is_empty() { + anyhow::bail!("Provide at least one --organization or --project to enumerate Azure Repos"); + } + + let mut progress = if progress_enabled { + let style = ProgressStyle::with_template("{spinner} {msg} [{elapsed_precise}]") + .expect("progress bar style template should compile"); + let pb = ProgressBar::new_spinner() + .with_style(style) + .with_message("Fetching Azure repositories"); + pb.enable_steady_tick(Duration::from_millis(500)); + pb + } else { + ProgressBar::hidden() + }; + + let repo_urls = + enumerate_repo_urls(&repo_specifiers, base_url, ignore_certs, Some(&mut progress)).await?; + + for url in repo_urls { + println!("{}", url); + } + + Ok(()) +} + +fn parse_repo(repo_url: &GitUrl) -> Option { + Url::parse(repo_url.as_str()).ok() +} + +pub fn wiki_url(repo_url: &GitUrl) -> Option { + let url = parse_repo(repo_url)?; + let mut segments: Vec = url + .path_segments() + .map(|segments| segments.filter(|s| !s.is_empty()).map(|s| s.to_string()).collect()) + .unwrap_or_default(); + if segments.len() < 3 { + return None; + } + let mut repo_name = segments.pop()?; + if repo_name.ends_with(".wiki") { + return None; + } + let marker = segments.pop()?; + if marker != "_git" { + return None; + } + repo_name.push_str(".wiki"); + segments.push("_git".to_string()); + segments.push(repo_name); + let mut new_url = url.clone(); + { + let mut path_segments = new_url.path_segments_mut().ok()?; + path_segments.clear(); + for segment in segments { + path_segments.push(&segment); + } + } + GitUrl::try_from(new_url).ok() +} + +pub async fn fetch_repo_items( + _repo_url: &GitUrl, + _ignore_certs: bool, + _output_root: &Path, + _datastore: &Arc>, +) -> Result> { + // Azure DevOps exposes work items and wiki content via additional APIs. For now we + // skip fetching extra artifacts and simply return an empty set so callers can rely + // on the function existing just like the other git host modules. + Ok(Vec::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn sanitize_remote_url_strips_username() { + let raw = "https://example@dev.azure.com/example/project/_git/repo"; + let sanitized = sanitize_remote_url(raw).expect("sanitize"); + assert_eq!(sanitized, "https://dev.azure.com/example/project/_git/repo"); + } + + #[test] + fn parse_repo_identifier_from_url_handles_basic_path() { + let remote = "https://dev.azure.com/org/project/_git/repo"; + let ident = parse_repo_identifier_from_url(remote).expect("identifier"); + assert_eq!(ident, "org/project/repo"); + } + + #[test] + fn parse_repo_identifier_from_url_handles_nested_org() { + let remote = "https://ado.example.com/collection/team/project/_git/repo"; + let ident = parse_repo_identifier_from_url(remote).expect("identifier"); + assert_eq!(ident, "collection/team/project/repo"); + } + + #[test] + fn parse_excluded_repo_accepts_url() { + let raw = "https://dev.azure.com/org/project/_git/repo"; + let ident = parse_excluded_repo(raw).expect("identifier"); + assert_eq!(ident, "org/project/repo"); + } + + #[test] + fn parse_excluded_repo_accepts_path() { + let raw = "org/project/repo"; + let ident = parse_excluded_repo(raw).expect("identifier"); + assert_eq!(ident, "org/project/repo"); + } + + #[test] + fn exclude_matcher_matches_glob() { + let matcher = build_exclude_matcher(&["org/*/repo".to_string()]); + assert!(should_exclude_repo("https://dev.azure.com/org/project/_git/repo", &matcher)); + } + + #[test] + fn wiki_url_appends_suffix() { + let url = GitUrl::from_str("https://dev.azure.com/org/project/_git/repo").unwrap(); + let wiki = wiki_url(&url).expect("wiki url"); + assert_eq!(wiki.as_str(), "https://dev.azure.com/org/project/_git/repo.wiki"); + } +} diff --git a/src/cli/commands/azure.rs b/src/cli/commands/azure.rs new file mode 100644 index 0000000..28e240e --- /dev/null +++ b/src/cli/commands/azure.rs @@ -0,0 +1,98 @@ +use clap::{Args, Subcommand, ValueEnum, ValueHint}; +use strum_macros::Display; +use url::Url; + +use crate::cli::commands::output::OutputArgs; + +#[derive(Args, Debug)] +pub struct AzureArgs { + #[command(subcommand)] + pub command: AzureCommand, + + /// Override Azure DevOps base URL (e.g. for Azure DevOps Server) + #[arg(global = true, long, default_value = "https://dev.azure.com/", value_hint = ValueHint::Url)] + pub azure_base_url: Url, +} + +#[derive(Subcommand, Debug)] +pub enum AzureCommand { + /// Interact with Azure DevOps repositories + #[command(subcommand)] + Repos(AzureReposCommand), +} + +#[derive(Subcommand, Debug)] +pub enum AzureReposCommand { + /// List repositories for organizations or projects + List(AzureReposListArgs), +} + +#[derive(Args, Debug, Clone)] +pub struct AzureReposListArgs { + #[command(flatten)] + pub repo_specifiers: AzureRepoSpecifiers, + + #[command(flatten)] + pub output_args: OutputArgs, +} + +#[derive(Args, Debug, Clone)] +pub struct AzureRepoSpecifiers { + /// Repositories belonging to these Azure DevOps organizations or collections + #[arg(long = "azure-organization", alias = "organization", value_name = "ORGANIZATION")] + pub organization: Vec, + + /// Repositories belonging to the specified Azure DevOps projects (format: ORGANIZATION/PROJECT) + #[arg(long = "azure-project", alias = "project", value_name = "ORGANIZATION/PROJECT")] + pub project: Vec, + + /// Include repositories from all projects within the specified organizations + #[arg(long = "azure-all-projects", alias = "all-azure-projects")] + pub all_projects: bool, + + /// Skip repositories when enumerating Azure sources (format: ORGANIZATION/PROJECT/REPOSITORY) + #[arg( + long = "azure-exclude", + alias = "azure-exclude-repo", + value_name = "ORGANIZATION/PROJECT/REPOSITORY" + )] + pub exclude_repos: Vec, + + /// Filter by repository type + #[arg(long = "azure-repo-type", default_value_t = AzureRepoType::Source)] + pub repo_type: AzureRepoType, +} + +impl AzureRepoSpecifiers { + pub fn is_empty(&self) -> bool { + self.organization.is_empty() && self.project.is_empty() + } +} + +#[derive(Copy, Clone, Debug, Display, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +#[strum(serialize_all = "kebab-case")] +pub enum AzureRepoType { + Source, + Fork, + All, +} + +impl From for crate::azure::RepoType { + fn from(value: AzureRepoType) -> Self { + match value { + AzureRepoType::Source => crate::azure::RepoType::Source, + AzureRepoType::Fork => crate::azure::RepoType::Fork, + AzureRepoType::All => crate::azure::RepoType::All, + } + } +} + +#[derive(Copy, Clone, Debug, ValueEnum, Display)] +#[strum(serialize_all = "kebab-case")] +pub enum AzureOutputFormat { + Pretty, + Json, + Jsonl, + Bson, + Sarif, +} diff --git a/src/cli/commands/inputs.rs b/src/cli/commands/inputs.rs index 6c6f81b..4bab9d1 100644 --- a/src/cli/commands/inputs.rs +++ b/src/cli/commands/inputs.rs @@ -5,6 +5,7 @@ use url::Url; use crate::{ cli::commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -30,11 +31,14 @@ pub struct InputSpecifierArgs { "bitbucket_user", "bitbucket_workspace", "bitbucket_project", + "azure_organization", + "azure_project", "git_url", "all_github_organizations", "all_gitlab_groups", "all_gitea_organizations", "all_bitbucket_workspaces", + "all_azure_projects", "jira_url", "confluence_url", "docker_image", @@ -176,6 +180,38 @@ pub struct InputSpecifierArgs { #[command(flatten)] pub bitbucket_auth: BitbucketAuthArgs, + // Azure DevOps Options + /// Scan repositories belonging to the specified Azure DevOps organizations or collections + #[arg(long = "azure-organization")] + pub azure_organization: Vec, + + /// Scan repositories belonging to the specified Azure DevOps projects (format: ORGANIZATION/PROJECT) + #[arg(long = "azure-project", value_name = "ORGANIZATION/PROJECT")] + pub azure_project: Vec, + + /// Skip repositories when enumerating Azure Repos sources (format: ORGANIZATION/PROJECT/REPOSITORY) + #[arg( + long = "azure-exclude", + alias = "azure-exclude-repo", + value_name = "ORGANIZATION/PROJECT/REPOSITORY" + )] + pub azure_exclude: Vec, + + /// Include repositories from every project within the specified Azure organizations + #[arg(long = "all-azure-projects")] + pub all_azure_projects: bool, + + /// Use the specified base URL for Azure DevOps (e.g. Azure DevOps Server) + #[arg( + long = "azure-base-url", + default_value = "https://dev.azure.com/", + value_hint = ValueHint::Url + )] + pub azure_base_url: Url, + + #[arg(long = "azure-repo-type", default_value_t = AzureRepoType::Source)] + pub azure_repo_type: AzureRepoType, + /// Jira base URL (e.g. https://jira.example.com) #[arg(long, value_hint = ValueHint::Url, requires = "jql")] pub jira_url: Option, diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index b7717bd..0434af9 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod azure; pub mod bitbucket; pub mod gitea; pub mod github; diff --git a/src/cli/global.rs b/src/cli/global.rs index edd79dc..a03d3d4 100644 --- a/src/cli/global.rs +++ b/src/cli/global.rs @@ -7,8 +7,8 @@ use sysinfo::{MemoryRefreshKind, RefreshKind, System}; use tracing::Level; use crate::cli::commands::{ - bitbucket::BitbucketArgs, gitea::GiteaArgs, github::GitHubArgs, gitlab::GitLabArgs, - rules::RulesArgs, scan::ScanArgs, + azure::AzureArgs, bitbucket::BitbucketArgs, gitea::GiteaArgs, github::GitHubArgs, + gitlab::GitLabArgs, rules::RulesArgs, scan::ScanArgs, }; #[deny(missing_docs)] @@ -77,6 +77,10 @@ pub enum Command { #[command(name = "bitbucket")] Bitbucket(BitbucketArgs), + /// Interact with the Azure DevOps API + #[command(name = "azure")] + Azure(AzureArgs), + /// Manage rules #[command(alias = "rule")] Rules(RulesArgs), diff --git a/src/lib.rs b/src/lib.rs index 598c278..3ceed02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod azure; pub mod baseline; pub mod binary; pub mod bitbucket; diff --git a/src/main.rs b/src/main.rs index d73bcc1..b6bb1fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,7 @@ use std::{ use anyhow::{Context, Result}; use kingfisher::{ - bitbucket, + azure, bitbucket, cli::{ self, commands::{ @@ -71,6 +71,7 @@ use tracing_subscriber::{ use url::Url; use crate::cli::commands::{ + azure::{AzureCommand, AzureRepoType, AzureReposCommand}, bitbucket::{BitbucketAuthArgs, BitbucketCommand, BitbucketRepoType, BitbucketReposCommand}, gitea::{GiteaCommand, GiteaRepoType, GiteaReposCommand}, gitlab::{GitLabCommand, GitLabRepoType, GitLabReposCommand}, @@ -91,6 +92,7 @@ fn main() -> anyhow::Result<()> { Command::GitLab(_) => num_cpus::get(), // Default for GitLab commands Command::Bitbucket(_) => num_cpus::get(), // Default for Bitbucket commands Command::Gitea(_) => num_cpus::get(), // Default for Gitea commands + Command::Azure(_) => num_cpus::get(), // Default for Azure commands Command::Rules(_) => num_cpus::get(), // Default for Rules commands }; @@ -267,6 +269,23 @@ async fn async_main(args: CommandLineArgs) -> Result<()> { } }, }, + Command::Azure(azure_args) => match azure_args.command { + AzureCommand::Repos(repos_command) => match repos_command { + AzureReposCommand::List(list_args) => { + azure::list_repositories( + azure_args.azure_base_url.clone(), + global_args.ignore_certs, + global_args.use_progress(), + &list_args.repo_specifiers.organization, + &list_args.repo_specifiers.project, + list_args.repo_specifiers.all_projects, + &list_args.repo_specifiers.exclude_repos, + list_args.repo_specifiers.repo_type.into(), + ) + .await?; + } + }, + }, Command::Gitea(gitea_args) => match gitea_args.command { GiteaCommand::Repos(repos_command) => match repos_command { GiteaReposCommand::List(list_args) => { @@ -364,6 +383,13 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs { bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, + jira_url: None, jql: None, confluence_url: None, diff --git a/src/reporter.rs b/src/reporter.rs index caa6aa8..764dcfd 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -667,6 +667,7 @@ mod tests { cli::commands::output::OutputArgs, cli::commands::scan::{ConfidenceLevel, ScanArgs}, cli::commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -789,6 +790,12 @@ mod tests { bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(), bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, jira_url: None, jql: None, confluence_url: None, diff --git a/src/reporter/json_format.rs b/src/reporter/json_format.rs index 4149469..8b4f59c 100644 --- a/src/reporter/json_format.rs +++ b/src/reporter/json_format.rs @@ -39,6 +39,7 @@ mod tests { use crate::util::intern; use crate::{ blob::BlobId, + cli::commands::azure::AzureRepoType, cli::commands::bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, cli::commands::gitea::GiteaRepoType, cli::commands::github::GitHubRepoType, @@ -109,6 +110,13 @@ mod tests { bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(), bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + // Azure DevOps + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, // Jira options jira_url: None, jql: None, diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index d80160c..a6e0b6a 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -2,7 +2,8 @@ pub(crate) use docker::save_docker_images; pub(crate) use enumerate::enumerate_filesystem_inputs; pub(crate) use repos::{ - clone_or_update_git_repos, enumerate_bitbucket_repos, enumerate_github_repos, + clone_or_update_git_repos, enumerate_azure_repos, enumerate_bitbucket_repos, + enumerate_github_repos, }; pub use runner::{load_and_record_rules, run_async_scan, run_scan}; pub(crate) use validation::run_secret_validation; diff --git a/src/scanner/repos.rs b/src/scanner/repos.rs index 95144a7..eb4ad10 100644 --- a/src/scanner/repos.rs +++ b/src/scanner/repos.rs @@ -11,7 +11,7 @@ use url::Url; use crate::blob::BlobIdMap; use crate::{ - bitbucket, + azure, bitbucket, blob::BlobMetadata, cli::{ commands::{github::GitCloneMode, github::GitHistoryMode, scan}, @@ -370,6 +370,69 @@ pub async fn enumerate_bitbucket_repos( Ok(repo_urls) } +pub async fn enumerate_azure_repos( + args: &scan::ScanArgs, + global_args: &global::GlobalArgs, +) -> Result> { + let repo_specifiers = azure::RepoSpecifiers { + organization: args.input_specifier_args.azure_organization.clone(), + project: args.input_specifier_args.azure_project.clone(), + all_projects: args.input_specifier_args.all_azure_projects, + repo_filter: args.input_specifier_args.azure_repo_type.into(), + exclude_repos: args.input_specifier_args.azure_exclude.clone(), + }; + + let mut repo_urls = args.input_specifier_args.git_url.clone(); + if !repo_specifiers.is_empty() { + let mut progress = if global_args.use_progress() { + let style = + ProgressStyle::with_template("{spinner} {msg} {human_len} [{elapsed_precise}]") + .expect("progress bar style template should compile"); + let pb = ProgressBar::new_spinner() + .with_style(style) + .with_message("Enumerating Azure Repos repositories..."); + pb.enable_steady_tick(Duration::from_millis(500)); + pb + } else { + ProgressBar::hidden() + }; + + let mut num_found: u64 = 0; + let base_url = args.input_specifier_args.azure_base_url.clone(); + let repo_strings = azure::enumerate_repo_urls( + &repo_specifiers, + base_url, + global_args.ignore_certs, + Some(&mut progress), + ) + .await + .context("Failed to enumerate Azure repositories")?; + + for repo_string in repo_strings { + match GitUrl::from_str(&repo_string) { + Ok(repo_url) => { + repo_urls.push(repo_url); + num_found += 1; + } + Err(e) => { + progress.suspend(|| { + error!("Failed to parse repo URL from {repo_string}: {e}"); + }); + } + } + } + + progress.finish_with_message(format!( + "Found {} repositories from Azure Repos", + HumanCount(num_found) + )); + } + + repo_urls.sort(); + repo_urls.dedup(); + Ok(repo_urls) +} + pub async fn fetch_jira_issues( args: &scan::ScanArgs, global_args: &global::GlobalArgs, @@ -519,6 +582,16 @@ pub async fn fetch_git_host_artifacts( ) .await?, ); + } else if host.contains("dev.azure") || host.contains("visualstudio.com") { + dirs.extend( + azure::fetch_repo_items( + repo_url, + global_args.ignore_certs, + &output_root, + datastore, + ) + .await?, + ); } } Ok(dirs) diff --git a/src/scanner/runner.rs b/src/scanner/runner.rs index 9d394dc..9de4a00 100644 --- a/src/scanner/runner.rs +++ b/src/scanner/runner.rs @@ -7,7 +7,7 @@ use tokio::time::{Duration, Instant}; use tracing::{debug, error, error_span, info, trace}; use crate::{ - bitbucket, + azure, bitbucket, cli::{commands::scan, global}, findings_store, findings_store::{FindingsStore, FindingsStoreMessage}, @@ -20,8 +20,8 @@ use crate::{ rules_database::RulesDatabase, safe_list, scanner::{ - clone_or_update_git_repos, enumerate_bitbucket_repos, enumerate_filesystem_inputs, - enumerate_github_repos, + clone_or_update_git_repos, enumerate_azure_repos, enumerate_bitbucket_repos, + enumerate_filesystem_inputs, enumerate_github_repos, repos::{ enumerate_gitea_repos, enumerate_gitlab_repos, fetch_confluence_pages, fetch_git_host_artifacts, fetch_jira_issues, fetch_s3_objects, fetch_slack_messages, @@ -75,11 +75,13 @@ pub async fn run_async_scan( let gitlab_repo_urls = enumerate_gitlab_repos(args, global_args).await?; let gitea_repo_urls = enumerate_gitea_repos(args, global_args).await?; let bitbucket_repo_urls = enumerate_bitbucket_repos(args, global_args).await?; + let azure_repo_urls = enumerate_azure_repos(args, global_args).await?; // Combine repository URLs repo_urls.extend(gitlab_repo_urls); repo_urls.extend(gitea_repo_urls); repo_urls.extend(bitbucket_repo_urls); + repo_urls.extend(azure_repo_urls); repo_urls.sort(); repo_urls.dedup(); @@ -99,6 +101,9 @@ pub async fn run_async_scan( if let Some(w) = bitbucket::wiki_url(url) { wiki_urls.push(w); } + if let Some(w) = azure::wiki_url(url) { + wiki_urls.push(w); + } } repo_urls.extend(wiki_urls); repo_urls.sort(); diff --git a/tests/int_allowlist.rs b/tests/int_allowlist.rs index 5e119f3..72bd950 100644 --- a/tests/int_allowlist.rs +++ b/tests/int_allowlist.rs @@ -7,6 +7,7 @@ use anyhow::Result; use kingfisher::{ cli::{ commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -85,6 +86,12 @@ fn run_skiplist(skip_regex: Vec, skip_skipword: Vec) -> Result anyhow::Result<()> { "scan", dir.path().to_str().unwrap(), "--no-binary", - "--no-base64", "--confidence=low", "--format", "json", diff --git a/tests/int_bitbucket.rs b/tests/int_bitbucket.rs index 092ab19..373f11b 100644 --- a/tests/int_bitbucket.rs +++ b/tests/int_bitbucket.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Result}; use kingfisher::{ cli::{ commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -83,6 +84,13 @@ fn test_bitbucket_remote_scan() -> Result<()> { bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/")?, + azure_repo_type: AzureRepoType::Source, + jira_url: None, jql: None, confluence_url: None, diff --git a/tests/int_dedup.rs b/tests/int_dedup.rs index 0e243f8..cd83a7f 100644 --- a/tests/int_dedup.rs +++ b/tests/int_dedup.rs @@ -11,6 +11,7 @@ use anyhow::Result; use kingfisher::{ cli::{ commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -100,6 +101,13 @@ rules: bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, + jira_url: None, jql: None, confluence_url: None, diff --git a/tests/int_github.rs b/tests/int_github.rs index 180a441..06c67a7 100644 --- a/tests/int_github.rs +++ b/tests/int_github.rs @@ -8,6 +8,7 @@ use anyhow::{Context, Result}; use kingfisher::{ cli::{ commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -87,6 +88,13 @@ fn test_github_remote_scan() -> Result<()> { bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, + jira_url: None, jql: None, confluence_url: None, diff --git a/tests/int_gitlab.rs b/tests/int_gitlab.rs index d295660..e55655a 100644 --- a/tests/int_gitlab.rs +++ b/tests/int_gitlab.rs @@ -8,6 +8,7 @@ use anyhow::{Context, Result}; use kingfisher::{ cli::{ commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -86,6 +87,13 @@ fn test_gitlab_remote_scan() -> Result<()> { bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/")?, + azure_repo_type: AzureRepoType::Source, + jira_url: None, jql: None, confluence_url: None, @@ -216,6 +224,13 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> { bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/")?, + azure_repo_type: AzureRepoType::Source, + jira_url: None, jql: None, confluence_url: None, diff --git a/tests/int_redact.rs b/tests/int_redact.rs index 1e7f9b5..48247af 100644 --- a/tests/int_redact.rs +++ b/tests/int_redact.rs @@ -8,6 +8,7 @@ use anyhow::Result; use kingfisher::{ cli::{ commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -68,6 +69,12 @@ async fn test_redact_hashes_finding_values() -> Result<()> { bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(), bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, jira_url: None, jql: None, confluence_url: None, diff --git a/tests/int_slack.rs b/tests/int_slack.rs index d7b3118..2575a3c 100644 --- a/tests/int_slack.rs +++ b/tests/int_slack.rs @@ -7,6 +7,7 @@ use anyhow::Result; use kingfisher::{ cli::{ commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -75,6 +76,12 @@ impl TestContext { bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(), bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, jira_url: None, jql: None, confluence_url: None, @@ -191,6 +198,12 @@ async fn test_scan_slack_messages() -> Result<()> { bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(), bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, jira_url: None, jql: None, confluence_url: None, diff --git a/tests/int_validation_cache.rs b/tests/int_validation_cache.rs index 28c7bda..ea1c809 100644 --- a/tests/int_validation_cache.rs +++ b/tests/int_validation_cache.rs @@ -11,6 +11,7 @@ use anyhow::Result; use kingfisher::{ cli::{ commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -143,6 +144,13 @@ async fn test_validation_cache_and_depvars() -> Result<()> { bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, + jira_url: None, jql: None, confluence_url: None, diff --git a/tests/int_vulnerable_files.rs b/tests/int_vulnerable_files.rs index 6141037..b87d721 100644 --- a/tests/int_vulnerable_files.rs +++ b/tests/int_vulnerable_files.rs @@ -9,6 +9,7 @@ use anyhow::{Context, Result}; use kingfisher::{ cli::{ commands::{ + azure::AzureRepoType, bitbucket::{BitbucketAuthArgs, BitbucketRepoType}, gitea::GiteaRepoType, github::{GitCloneMode, GitHistoryMode, GitHubRepoType}, @@ -86,6 +87,13 @@ impl TestContext { bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, + jira_url: None, jql: None, confluence_url: None, @@ -189,6 +197,13 @@ impl TestContext { bitbucket_repo_type: BitbucketRepoType::Source, bitbucket_auth: BitbucketAuthArgs::default(), + azure_organization: Vec::new(), + azure_project: Vec::new(), + azure_exclude: Vec::new(), + all_azure_projects: false, + azure_base_url: Url::parse("https://dev.azure.com/").unwrap(), + azure_repo_type: AzureRepoType::Source, + jira_url: None, jql: None, confluence_url: None,