From 17e0ca35946bf1724e0509ade72ac3661114b7cd Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Thu, 20 Nov 2025 16:33:28 -0800 Subject: [PATCH] - Updating to support Bitbucket App Passwords - Improved boundaries for several rules - Added more rules --- CHANGELOG.md | 5 ++ Cargo.toml | 2 +- data/rules/anthropic.yml | 6 +- data/rules/eraserio.yml | 34 ++++++++ data/rules/github.yml | 10 +-- data/rules/monday.yml | 37 +++++++++ data/rules/nvidia.yml | 1 - data/rules/openai.yml | 3 - data/rules/sentry.yml | 6 +- data/rules/supabase.yml | 4 - src/git_binary.rs | 174 ++++++++++++++++++++++++++++++++++++--- 11 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 data/rules/eraserio.yml create mode 100644 data/rules/monday.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index fd69ef5..60adaf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## [v1.66.0] +- Updating to support Bitbucket App Passwords +- Improved boundaries for several rules +- Added more rules + ## [v1.65.0] - Skip reporting MongoDB and Postgres findings when their connection strings cannot be parsed, even when validation is disabled. - Improve MySQL detection by broadening URI coverage and adding live validation that skips clearly invalid connection strings. diff --git a/Cargo.toml b/Cargo.toml index d6b0a0e..2776297 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ publish = false [package] name = "kingfisher" -version = "1.65.0" +version = "1.66.0" description = "MongoDB's blazingly fast and accurate secret scanning and validation tool" edition.workspace = true rust-version.workspace = true diff --git a/data/rules/anthropic.yml b/data/rules/anthropic.yml index 8907056..860de92 100644 --- a/data/rules/anthropic.yml +++ b/data/rules/anthropic.yml @@ -2,16 +2,14 @@ rules: - name: Anthropic API Key id: kingfisher.anthropic.1 pattern: | - (?xi) - \b - ( + (?xi) + ( sk-ant-api \d{2,4} - [\w\-]{93} AA ) - \b pattern_requirements: min_digits: 2 min_uppercase: 1 diff --git a/data/rules/eraserio.yml b/data/rules/eraserio.yml new file mode 100644 index 0000000..f56612f --- /dev/null +++ b/data/rules/eraserio.yml @@ -0,0 +1,34 @@ +rules: + - name: Eraser API Key + id: kingfisher.eraser.1 + pattern: | + (?xi) + \b + eraser + (?:[^A-Za-z0-9]{0,16})? + (?:SECRET|PRIVATE|ACCESS|KEY|TOKEN) + (?:[^A-Za-z0-9]{0,16})? + \b + ( + [A-Za-z0-9]{20} + ) + \b + min_entropy: 3.5 + confidence: medium + examples: + - eraser_token = Q7MD4J9L2X0B6R3T8W1P + references: + - https://eraser.io/docs/api/authentication + validation: + type: Http + content: + request: + method: GET + url: https://app.eraser.io/api/reports/usage?rangeDays=1 + headers: + Authorization: "Bearer {{ TOKEN }}" + accept: application/json + response_matcher: + - report_response: true + - type: StatusMatch + status: [200, 403] \ No newline at end of file diff --git a/data/rules/github.yml b/data/rules/github.yml index 3aa8d7d..96ebdd8 100644 --- a/data/rules/github.yml +++ b/data/rules/github.yml @@ -3,7 +3,7 @@ rules: id: kingfisher.github.1 pattern: | (?xi) - ( + ( github_pat_ [A-Z0-9_+]{82,84} ) @@ -39,8 +39,7 @@ rules: - name: GitHub Personal Access Token id: kingfisher.github.2 pattern: | - (?xi) - \b + (?xi) ( ghp_(?P[A-Z0-9]{30})(?P[A-Z0-9]{6}) ) @@ -85,9 +84,8 @@ rules: - name: GitHub OAuth Access Token id: kingfisher.github.3 pattern: | - (?xi) - \b - ( + (?xi) + ( gho_(?P[A-Z0-9]{30})(?P[A-Z0-9]{6}) ) pattern_requirements: diff --git a/data/rules/monday.yml b/data/rules/monday.yml new file mode 100644 index 0000000..8459472 --- /dev/null +++ b/data/rules/monday.yml @@ -0,0 +1,37 @@ +rules: + - name: Monday.com API Key + id: kingfisher.monday.1 + pattern: | + (?xi) + \b + monday + (?:.|[\n\r]){0,40}? + (?:SECRET|PRIVATE|ACCESS|KEY|TOKEN) + (?:.|[\n\r]){0,40}? + \b + ( + eyJ[A-Za-z0-9-_]{10,200}\.eyJ[A-Za-z0-9-_]{50,1000}\.[A-Za-z0-9-_]{20,500} + ) + \b + min_entropy: 3.3 + confidence: medium + examples: + - monday SECRET_TOKEN=eyJhbGciOiJIUzI1TiJ9.eyJ0aWQiOjU7OTC4MzIwMywiYWFpIjoxMSwidWlkIjo5NjYwMzk5MCwiaWBkIjoiMjAyNS0xMS0yMVQwMDoyNjoxMy43OCVaIiwicGVyIjoibWU6d3JpdGUiLCJhY3RpZCI6MzI2MDI5MTIsInJnbiI6InVzZTEifQ.wQtV6psL1JqFHdXgRB2J7-qslSyS2I4TYJHtkX9ofvk + validation: + type: Http + content: + request: + url: https://api.monday.com/v2 + method: POST + headers: + Content-Type: application/json + Authorization: '{{ TOKEN }}' + body: | + {"query": "query { me { id name } }"} + response_matcher: + - report_response: true + - type: StatusMatch + status: [200] + - type: WordMatch + words: ["data", "me", "id"] + match_all_words: true \ No newline at end of file diff --git a/data/rules/nvidia.yml b/data/rules/nvidia.yml index 3143a80..1329314 100644 --- a/data/rules/nvidia.yml +++ b/data/rules/nvidia.yml @@ -3,7 +3,6 @@ rules: id: kingfisher.nvidia.nim.1 pattern: | (?xi) - \b ( nvapi-[A-Z0-9_-]{60,70} ) diff --git a/data/rules/openai.yml b/data/rules/openai.yml index b6f90e0..3e8d7d8 100644 --- a/data/rules/openai.yml +++ b/data/rules/openai.yml @@ -3,11 +3,9 @@ rules: id: kingfisher.openai.1 pattern: | (?xi) - \b ( sk-[A-Z0-9]{48} ) - \b pattern_requirements: min_digits: 2 min_entropy: 3.3 @@ -35,7 +33,6 @@ rules: id: kingfisher.openai.2 pattern: | (?xi) - \b ( (sk-(?:proj|svcacct|None)-[A-Z0-9_-]{100,}) ) diff --git a/data/rules/sentry.yml b/data/rules/sentry.yml index a53b647..a76f51a 100644 --- a/data/rules/sentry.yml +++ b/data/rules/sentry.yml @@ -3,14 +3,13 @@ rules: id: kingfisher.sentry.1 pattern: | (?xi) - \b sentry (?:.|[\n\r]){0,32}? (?:SECRET|PRIVATE|ACCESS|KEY|TOKEN) (?:.|[\n\r]){0,32}? \b ( - [a-f0-9]{64} + [a-f0-9]{64} ) \b pattern_requirements: @@ -44,7 +43,6 @@ rules: ( sntrys_eyJpYXQiO[a-zA-Z0-9+/]{10,200}(?:LCJyZWdpb25fdXJs|InJlZ2lvbl91cmwi|cmVnaW9uX3VybCI6)[a-zA-Z0-9+/]{10,200}={0,2}_[a-zA-Z0-9+/]{43} ) - \b pattern_requirements: min_digits: 2 min_entropy: 4.2 @@ -73,11 +71,9 @@ rules: id: kingfisher.sentry.3 pattern: | (?xi) - \b ( sntryu_[a-f0-9]{64} ) - \b pattern_requirements: min_digits: 2 min_entropy: 3.5 diff --git a/data/rules/supabase.yml b/data/rules/supabase.yml index b2d7c32..6232195 100644 --- a/data/rules/supabase.yml +++ b/data/rules/supabase.yml @@ -3,11 +3,9 @@ rules: id: kingfisher.supabase.1 pattern: | (?xi) - \b ( sbp_[a-z0-9_-]{40} ) - \b pattern_requirements: min_digits: 2 min_entropy: 3.5 @@ -34,11 +32,9 @@ rules: id: kingfisher.supabase.2 pattern: | (?xi) - \b ( sb_secret_[a-z0-9_-]{31} ) - \b pattern_requirements: min_digits: 2 min_entropy: 4.0 diff --git a/src/git_binary.rs b/src/git_binary.rs index 6472cfc..15728f6 100644 --- a/src/git_binary.rs +++ b/src/git_binary.rs @@ -4,6 +4,7 @@ use std::{ }; use tracing::{debug, debug_span}; +use url::Url; use crate::{bitbucket::is_bitbucket_access_token, git_url::GitUrl}; @@ -101,6 +102,8 @@ pub struct Git { credentials: Vec, ignore_certs: bool, bitbucket_access_token: Option, + bitbucket_env: Vec<(String, String)>, + bitbucket_basic_auth: Option<(String, String)>, } impl Git { @@ -110,23 +113,60 @@ impl Git { pub fn new(ignore_certs: bool) -> Self { let mut credentials = Vec::new(); + fn normalized_env_var(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + } + + let bitbucket_username = normalized_env_var("KF_BITBUCKET_USERNAME"); + let bitbucket_app_password = normalized_env_var("KF_BITBUCKET_APP_PASSWORD"); + let bitbucket_token = normalized_env_var("KF_BITBUCKET_TOKEN"); + let bitbucket_password = normalized_env_var("KF_BITBUCKET_PASSWORD"); + let bitbucket_oauth_token = normalized_env_var("KF_BITBUCKET_OAUTH_TOKEN"); + + let mut bitbucket_env = Vec::new(); + for (key, value) in [ + ("KF_BITBUCKET_USERNAME", bitbucket_username.as_ref()), + ("KF_BITBUCKET_APP_PASSWORD", bitbucket_app_password.as_ref()), + ("KF_BITBUCKET_TOKEN", bitbucket_token.as_ref()), + ("KF_BITBUCKET_PASSWORD", bitbucket_password.as_ref()), + ("KF_BITBUCKET_OAUTH_TOKEN", bitbucket_oauth_token.as_ref()), + ] { + if let Some(value) = value { + bitbucket_env.push((key.to_string(), value.to_string())); + } + } + let has_github_token = matches!(std::env::var("KF_GITHUB_TOKEN"), Ok(token) if !token.is_empty()); let has_gitlab_token = matches!(std::env::var("KF_GITLAB_TOKEN"), Ok(token) if !token.is_empty()); let has_gitea_token = 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 bitbucket_access_token = + bitbucket_token.as_ref().filter(|token| is_bitbucket_access_token(token)).cloned(); + let bitbucket_basic_password = bitbucket_app_password + .clone() + .or(bitbucket_token.clone()) + .or(bitbucket_password.clone()); + let bitbucket_basic_auth = if let Some(token) = bitbucket_oauth_token.clone() { + Some(("x-token-auth".to_string(), token)) + } else if let Some(token) = bitbucket_access_token.clone() { + Some(("x-token-auth".to_string(), token)) + } else if let (Some(username), Some(password)) = + (bitbucket_username.clone(), bitbucket_basic_password) + { + Some((username, password)) + } else { + None + }; + let has_bitbucket_username = bitbucket_username.is_some(); + let has_bitbucket_password = bitbucket_app_password.is_some() + || bitbucket_token.is_some() + || bitbucket_password.is_some(); + let has_bitbucket_oauth_token = bitbucket_oauth_token.is_some(); let has_bitbucket_credentials = has_bitbucket_oauth_token || bitbucket_access_token.is_some() || (has_bitbucket_username && has_bitbucket_password); @@ -186,7 +226,13 @@ impl Git { credentials.push(HUGGINGFACE_CREDENTIAL_HELPER.into()); } - Self { credentials, ignore_certs, bitbucket_access_token } + Self { + credentials, + ignore_certs, + bitbucket_access_token, + bitbucket_env, + bitbucket_basic_auth, + } } /// Create a basic `git` `Command` with environment variables set to @@ -201,6 +247,9 @@ impl Git { if self.ignore_certs { cmd.env("GIT_SSL_NO_VERIFY", "1"); } + for (key, value) in &self.bitbucket_env { + cmd.env(key, value); + } if let Some(token) = &self.bitbucket_access_token { cmd.env("KF_BITBUCKET_ACCESS_TOKEN", token); } @@ -268,11 +317,31 @@ impl Git { cmd.arg("--quiet"); cmd.arg("-c"); cmd.arg("remote.origin.fetch=+refs/*:refs/remotes/origin/*"); - cmd.arg(repo_url.as_str()); + cmd.arg(self.repo_arg_for_clone(repo_url)); cmd.arg(output_dir); debug!("{cmd:#?}"); self.run_cmd(cmd) } + + fn repo_arg_for_clone(&self, repo_url: &GitUrl) -> String { + if let Some((username, password)) = &self.bitbucket_basic_auth { + if let Ok(mut url) = Url::parse(repo_url.as_str()) { + if url + .host_str() + .map(|host| host.eq_ignore_ascii_case("bitbucket.org")) + .unwrap_or(false) + { + if url.set_username(username).is_ok() + && url.set_password(Some(password)).is_ok() + { + return url.into(); + } + } + } + } + + repo_url.as_str().to_string() + } } impl Default for Git { @@ -349,6 +418,61 @@ mod tests { ); } + #[test] + fn test_repo_arg_for_clone_includes_bitbucket_app_password() { + let url = + GitUrl::try_from(url::Url::parse("https://bitbucket.org/workspace/demo.git").unwrap()) + .unwrap(); + + temp_env::with_vars( + &[ + ("KF_BITBUCKET_USERNAME", Some("user")), + ("KF_BITBUCKET_APP_PASSWORD", Some("secret")), + ], + || { + let git = Git::new(false); + assert_eq!( + git.repo_arg_for_clone(&url), + "https://user:secret@bitbucket.org/workspace/demo.git" + ); + }, + ); + } + + #[test] + fn test_repo_arg_for_clone_uses_token_auth_when_available() { + let url = + GitUrl::try_from(url::Url::parse("https://bitbucket.org/workspace/demo.git").unwrap()) + .unwrap(); + + temp_env::with_vars(&[("KF_BITBUCKET_OAUTH_TOKEN", Some("token123"))], || { + let git = Git::new(false); + assert_eq!( + git.repo_arg_for_clone(&url), + "https://x-token-auth:token123@bitbucket.org/workspace/demo.git" + ); + }); + } + + #[test] + fn test_repo_arg_for_clone_leaves_non_bitbucket_urls_untouched() { + let url = GitUrl::try_from( + url::Url::parse("https://github.com/octocat/Hello-World.git").unwrap(), + ) + .unwrap(); + + temp_env::with_vars( + &[ + ("KF_BITBUCKET_USERNAME", Some("user")), + ("KF_BITBUCKET_APP_PASSWORD", Some("secret")), + ], + || { + let git = Git::new(false); + assert_eq!(git.repo_arg_for_clone(&url), url.as_str()); + }, + ); + } + #[test] fn test_git_new_bitbucket_access_token() { let token = "AT1234567890_ACCESS_TOKEN_EXAMPLE_WITH_UNDERSCORE"; @@ -360,6 +484,30 @@ mod tests { }); } + #[test] + fn test_git_new_bitbucket_trims_whitespace() { + let trimmed_token = "AT1234567890_ACCESS_TOKEN_EXAMPLE_WITH_UNDERSCORE"; + let token = format!(" {trimmed_token} \n"); + + temp_env::with_vars( + &[("KF_BITBUCKET_USERNAME", Some(" user\n")), ("KF_BITBUCKET_TOKEN", Some(&token))], + || { + let git = Git::new(false); + + assert_eq!( + git.bitbucket_env, + vec![ + ("KF_BITBUCKET_USERNAME".to_string(), "user".to_string()), + ("KF_BITBUCKET_TOKEN".to_string(), trimmed_token.to_string(),), + ], + ); + 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(trimmed_token)); + }, + ); + } + #[test] fn test_clone_mode_arg() { assert_eq!(CloneMode::Bare.arg(), Some("--bare"));