diff --git a/CHANGELOG.md b/CHANGELOG.md index ba64619..0ab5993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [v1.64.0] - Fixed a bug when using --redact, that broke validation - Added JDBC rule with validator +- Filter out empty 'KF_BITBUCKET_*' environment values when constructing the Bitbucket authentication configuration so blank variables no longer override valid credentials ## [v1.63.1] - Updated allocator diff --git a/README.md b/README.md index 81b38b1..1f7649a 100644 --- a/README.md +++ b/README.md @@ -882,7 +882,7 @@ KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --user johndoe --gitea-api-url htt ```bash kingfisher scan bitbucket --workspace my-team # include Bitbucket Cloud repositories from every accessible workspace -KF_BITBUCKET_USERNAME="$USER" KF_BITBUCKET_APP_PASSWORD="$APP_PASSWORD" \ +KF_BITBUCKET_TOKEN="$BITBUCKET_TOKEN" \ kingfisher scan bitbucket --all-workspaces ``` @@ -915,8 +915,7 @@ require credentials (see [Authenticate to Bitbucket](#authenticate-to-bitbucket) kingfisher scan --git-url https://bitbucket.org/hashashash/secretstest.git # Include repository issues -KF_BITBUCKET_USERNAME="user" \ -KF_BITBUCKET_APP_PASSWORD="app-password" \ +KF_BITBUCKET_TOKEN="$BITBUCKET_TOKEN" \ kingfisher scan --git-url https://bitbucket.org/workspace/project.git --repo-artifacts ``` @@ -925,7 +924,7 @@ KF_BITBUCKET_APP_PASSWORD="app-password" \ ```bash kingfisher scan bitbucket --workspace my-team --list-only # enumerate all accessible workspaces or projects -KF_BITBUCKET_USERNAME="$USER" KF_BITBUCKET_APP_PASSWORD="$APP_PASSWORD" \ +KF_BITBUCKET_TOKEN="$BITBUCKET_TOKEN" \ kingfisher scan bitbucket --all-workspaces --list-only # filter out repositories using glob patterns kingfisher scan bitbucket --workspace my-team --bitbucket-exclude my-team/**/experimental-* --list-only @@ -935,14 +934,23 @@ kingfisher scan bitbucket --workspace my-team --bitbucket-exclude my-team/**/exp Kingfisher supports Bitbucket Cloud and Bitbucket Server credentials: -- **App password or server token** – set `KF_BITBUCKET_USERNAME` and either - `KF_BITBUCKET_APP_PASSWORD`, `KF_BITBUCKET_TOKEN`, or - `KF_BITBUCKET_PASSWORD`. +- **Workspace API token (Cloud)** – set `KF_BITBUCKET_TOKEN`. `KF_BITBUCKET_USERNAME` + is optional; Kingfisher automatically uses the token for Bitbucket REST APIs + and authenticates git operations as `x-token-auth`. +- **Bitbucket Server token** – set `KF_BITBUCKET_USERNAME` and either + `KF_BITBUCKET_TOKEN` or `KF_BITBUCKET_PASSWORD`. +- **Legacy app password (Cloud)** – set `KF_BITBUCKET_USERNAME` and + `KF_BITBUCKET_APP_PASSWORD`. - **OAuth/PAT token** – set `KF_BITBUCKET_OAUTH_TOKEN`. These credentials match the options described in the [ghorg setup guide](https://github.com/gabrie30/ghorg/blob/master/README.md#bitbucket-setup). +Bitbucket no longer supports App Tokens as of September 9, 2025: +https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/ + +> As of September 9, 2025, app passwords can no longer be created. Use API tokens with scopes instead. All existing app passwords will be disabled on June 9, 2026. Migrate any integrations before then to avoid disruptions. + ### Self-hosted Bitbucket Server Use `--bitbucket-api-url` to point Kingfisher at your server's REST endpoint, for example @@ -1050,8 +1058,9 @@ KF_SLACK_TOKEN="xoxp-1234..." kingfisher scan slack "akia" \ | `KF_GITEA_USERNAME` | Username for private Gitea clones (used with `KF_GITEA_TOKEN`) | | `KF_AZURE_TOKEN` / `KF_AZURE_PAT` | Azure Repos Personal Access Token | | `KF_AZURE_USERNAME` | Username to use with Azure Repos 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_TOKEN` | Bitbucket Cloud workspace API token or Bitbucket Server PAT | +| `KF_BITBUCKET_USERNAME` | Optional Bitbucket username for legacy app passwords or server tokens | +| `KF_BITBUCKET_APP_PASSWORD` | Legacy Bitbucket app password (deprecated September 9, 2025; disabled June 9, 2026) | | `KF_BITBUCKET_OAUTH_TOKEN` | Bitbucket OAuth or PAT token | | `KF_HUGGINGFACE_TOKEN` | Hugging Face access token for API enumeration and git cloning | | `KF_HUGGINGFACE_USERNAME` | Optional username for Hugging Face git operations (defaults to `hf_user`) | diff --git a/src/bitbucket.rs b/src/bitbucket.rs index fef0f7f..6f5f374 100644 --- a/src/bitbucket.rs +++ b/src/bitbucket.rs @@ -40,18 +40,43 @@ pub struct AuthConfig { pub bearer_token: Option, } +pub(crate) fn is_bitbucket_access_token(token: &str) -> bool { + token.len() > 40 && token.starts_with("AT") && token.contains('_') +} + impl AuthConfig { pub fn from_options( username: Option, password: Option, bearer_token: Option, ) -> Self { - let username = username.or_else(|| env::var("KF_BITBUCKET_USERNAME").ok()); - let password = password - .or_else(|| env::var("KF_BITBUCKET_APP_PASSWORD").ok()) - .or_else(|| env::var("KF_BITBUCKET_TOKEN").ok()) - .or_else(|| env::var("KF_BITBUCKET_PASSWORD").ok()); - let bearer_token = bearer_token.or_else(|| env::var("KF_BITBUCKET_OAUTH_TOKEN").ok()); + fn normalized(value: Option) -> Option { + value.and_then(|v| if v.trim().is_empty() { None } else { Some(v) }) + } + + fn env_var(name: &str) -> Option { + match env::var(name) { + Ok(value) if value.trim().is_empty() => None, + Ok(value) => Some(value), + Err(_) => None, + } + } + + let username = normalized(username).or_else(|| env_var("KF_BITBUCKET_USERNAME")); + let password = normalized(password) + .or_else(|| env_var("KF_BITBUCKET_APP_PASSWORD")) + .or_else(|| env_var("KF_BITBUCKET_TOKEN")) + .or_else(|| env_var("KF_BITBUCKET_PASSWORD")); + let mut bearer_token = + normalized(bearer_token).or_else(|| env_var("KF_BITBUCKET_OAUTH_TOKEN")); + + if bearer_token.is_none() { + if let Some(password) = &password { + if is_bitbucket_access_token(password) { + bearer_token = Some(password.clone()); + } + } + } Self { username, password, bearer_token } } @@ -709,4 +734,52 @@ mod tests { Some("ws/repo") ); } + + #[test] + fn auth_config_ignores_empty_environment_values() { + temp_env::with_vars( + &[ + ("KF_BITBUCKET_USERNAME", Some("")), + ("KF_BITBUCKET_APP_PASSWORD", Some("")), + ("KF_BITBUCKET_OAUTH_TOKEN", Some(" ")), + ], + || { + let auth = AuthConfig::from_env(); + assert!(auth.username.is_none()); + assert!(auth.password.is_none()); + assert!(auth.bearer_token.is_none()); + }, + ); + } + + #[test] + fn auth_config_prefers_basic_auth_when_bearer_is_empty() { + temp_env::with_vars( + &[ + ("KF_BITBUCKET_USERNAME", Some("user")), + ("KF_BITBUCKET_APP_PASSWORD", Some("pass")), + ("KF_BITBUCKET_OAUTH_TOKEN", Some("")), + ], + || { + let auth = AuthConfig::from_env(); + assert_eq!(auth.username.as_deref(), Some("user")); + assert_eq!(auth.password.as_deref(), Some("pass")); + assert!(auth.bearer_token.is_none()); + }, + ); + } + + #[test] + fn auth_config_treats_access_token_as_bearer() { + let token = "AT1234567890_ACCESS_TOKEN_EXAMPLE_WITH_UNDERSCORE"; + temp_env::with_vars( + &[("KF_BITBUCKET_USERNAME", Some("user")), ("KF_BITBUCKET_TOKEN", Some(token))], + || { + let auth = AuthConfig::from_env(); + assert_eq!(auth.username.as_deref(), Some("user")); + assert_eq!(auth.password.as_deref(), Some(token)); + assert_eq!(auth.bearer_token.as_deref(), Some(token)); + }, + ); + } } diff --git a/src/git_binary.rs b/src/git_binary.rs index a629373..6472cfc 100644 --- a/src/git_binary.rs +++ b/src/git_binary.rs @@ -5,7 +5,7 @@ use std::{ use tracing::{debug, debug_span}; -use crate::git_url::GitUrl; +use crate::{bitbucket::is_bitbucket_access_token, git_url::GitUrl}; const BITBUCKET_CREDENTIAL_HELPER: &str = r#"credential.helper=!_bbcreds() { if [ -n "$KF_BITBUCKET_OAUTH_TOKEN" ]; then @@ -13,6 +13,11 @@ const BITBUCKET_CREDENTIAL_HELPER: &str = r#"credential.helper=!_bbcreds() { echo password="$KF_BITBUCKET_OAUTH_TOKEN"; return; fi + if [ -n "$KF_BITBUCKET_ACCESS_TOKEN" ]; then + echo username="x-token-auth"; + echo password="$KF_BITBUCKET_ACCESS_TOKEN"; + return; + fi if [ -n "$KF_BITBUCKET_USERNAME" ]; then bb_pass="${KF_BITBUCKET_APP_PASSWORD:-${KF_BITBUCKET_TOKEN:-${KF_BITBUCKET_PASSWORD:-}}}"; if [ -n "$bb_pass" ]; then @@ -95,6 +100,7 @@ fn summarize_output(output: &[u8]) -> Option { pub struct Git { credentials: Vec, ignore_certs: bool, + bitbucket_access_token: Option, } impl Git { @@ -112,14 +118,18 @@ impl Git { matches!(std::env::var("KF_GITEA_TOKEN"), Ok(token) if !token.is_empty()); let has_bitbucket_username = matches!(std::env::var("KF_BITBUCKET_USERNAME"), Ok(value) if !value.is_empty()); + let bitbucket_access_token = std::env::var("KF_BITBUCKET_TOKEN") + .ok() + .filter(|value| !value.is_empty() && is_bitbucket_access_token(value)); let has_bitbucket_password = ["KF_BITBUCKET_APP_PASSWORD", "KF_BITBUCKET_TOKEN", "KF_BITBUCKET_PASSWORD"] .iter() .any(|key| matches!(std::env::var(key), Ok(value) if !value.is_empty())); let has_bitbucket_oauth_token = matches!(std::env::var("KF_BITBUCKET_OAUTH_TOKEN"), Ok(value) if !value.is_empty()); - let has_bitbucket_credentials = - has_bitbucket_oauth_token || (has_bitbucket_username && has_bitbucket_password); + let has_bitbucket_credentials = has_bitbucket_oauth_token + || bitbucket_access_token.is_some() + || (has_bitbucket_username && has_bitbucket_password); let has_azure_token = ["KF_AZURE_TOKEN", "KF_AZURE_PAT"] .iter() .any(|key| matches!(std::env::var(key), Ok(value) if !value.is_empty())); @@ -176,7 +186,7 @@ impl Git { credentials.push(HUGGINGFACE_CREDENTIAL_HELPER.into()); } - Self { credentials, ignore_certs } + Self { credentials, ignore_certs, bitbucket_access_token } } /// Create a basic `git` `Command` with environment variables set to @@ -191,6 +201,9 @@ impl Git { if self.ignore_certs { cmd.env("GIT_SSL_NO_VERIFY", "1"); } + if let Some(token) = &self.bitbucket_access_token { + cmd.env("KF_BITBUCKET_ACCESS_TOKEN", token); + } cmd.args(&self.credentials); cmd.stdin(Stdio::null()); cmd @@ -302,6 +315,7 @@ mod tests { let git = Git::new(false); assert!(!git.ignore_certs); assert!(git.credentials.is_empty()); + assert!(git.bitbucket_access_token.is_none()); temp_env::with_var("KF_GITHUB_TOKEN", Some("test_token"), || { let git = Git::new(false); @@ -315,6 +329,7 @@ mod tests { let git = Git::new(false); assert_eq!(git.credentials.len(), 4); assert!(git.credentials.iter().any(|value| value == BITBUCKET_CREDENTIAL_HELPER)); + assert!(git.bitbucket_access_token.is_none()); }); } @@ -329,10 +344,22 @@ mod tests { let git = Git::new(false); assert_eq!(git.credentials.len(), 4); assert!(git.credentials.iter().any(|value| value == BITBUCKET_CREDENTIAL_HELPER)); + assert!(git.bitbucket_access_token.is_none()); }, ); } + #[test] + fn test_git_new_bitbucket_access_token() { + let token = "AT1234567890_ACCESS_TOKEN_EXAMPLE_WITH_UNDERSCORE"; + temp_env::with_var("KF_BITBUCKET_TOKEN", Some(token), || { + let git = Git::new(false); + assert_eq!(git.credentials.len(), 4); + assert!(git.credentials.iter().any(|value| value == BITBUCKET_CREDENTIAL_HELPER)); + assert_eq!(git.bitbucket_access_token.as_deref(), Some(token)); + }); + } + #[test] fn test_clone_mode_arg() { assert_eq!(CloneMode::Bare.arg(), Some("--bare")); diff --git a/src/rules/rule.rs b/src/rules/rule.rs index 37fd05d..74adab9 100644 --- a/src/rules/rule.rs +++ b/src/rules/rule.rs @@ -610,12 +610,7 @@ impl RuleSyntax { let context = e.location().map_or(String::new(), |loc| { format!(" at line {} column {}", loc.line(), loc.column()) }); - anyhow!( - "Failed to parse YAML from {}{}: {}", - path.display(), - context, - e - ) + anyhow!("Failed to parse YAML from {}{}: {}", path.display(), context, e) })?; Ok(match parsed {