diff --git a/README.md b/README.md index 63ca0b2..31f6c9d 100644 --- a/README.md +++ b/README.md @@ -717,6 +717,20 @@ kingfisher revoke --rule slack "xoxb-..." # Revoke a GitHub PAT kingfisher revoke --rule github "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +# Revoke a GitLab personal access token (self revoke) +kingfisher revoke --rule gitlab "glpat-xxxxxxxxxxxxxxxxxxxx" + +# Revoke an Atlassian API token (requires account_id, tokenId, admin access token) +kingfisher revoke --rule atlassian --arg "" --arg "" "" + +# Revoke AWS credentials (sets access key to Inactive) +kingfisher revoke --rule aws --arg "AKIAIOSFODNN7EXAMPLE" "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + +# Revoke a GCP service account key (JSON key file) +kingfisher revoke --rule gcp '{"type":"service_account","project_id":"example","private_key_id":"abcd1234","private_key":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n","client_email":"example@project.iam.gserviceaccount.com","token_uri":"https://oauth2.googleapis.com/token"}' + +kingfisher revoke --rule gcp "$(cat service-account.json)" + # JSON output for scripting kingfisher revoke --rule slack "xoxb-..." --format json ``` diff --git a/crates/kingfisher-rules/data/rules/aws.yml b/crates/kingfisher-rules/data/rules/aws.yml index 6015285..f8667c9 100644 --- a/crates/kingfisher-rules/data/rules/aws.yml +++ b/crates/kingfisher-rules/data/rules/aws.yml @@ -63,6 +63,8 @@ rules: }, validation: type: AWS + revocation: + type: AWS depends_on_rule: - rule_id: kingfisher.aws.1 variable: AKID diff --git a/crates/kingfisher-rules/data/rules/buildkite.yml b/crates/kingfisher-rules/data/rules/buildkite.yml index 045fa94..650aa32 100644 --- a/crates/kingfisher-rules/data/rules/buildkite.yml +++ b/crates/kingfisher-rules/data/rules/buildkite.yml @@ -30,4 +30,16 @@ rules: status: [200] - type: WordMatch words: ['"uuid"', '"user"'] + revocation: + type: Http + content: + request: + method: DELETE + url: https://api.buildkite.com/v2/access-token + headers: + Authorization: "Bearer {{ TOKEN }}" + response_matcher: + - report_response: true + - type: StatusMatch + status: [204] \ No newline at end of file diff --git a/crates/kingfisher-rules/data/rules/gcp.yml b/crates/kingfisher-rules/data/rules/gcp.yml index 30d4d1d..cdd7a0e 100644 --- a/crates/kingfisher-rules/data/rules/gcp.yml +++ b/crates/kingfisher-rules/data/rules/gcp.yml @@ -36,6 +36,8 @@ rules: } validation: type: GCP + revocation: + type: GCP - name: GCP Private Key ID id: kingfisher.gcp.3 pattern: | diff --git a/crates/kingfisher-rules/data/rules/gitlab.yml b/crates/kingfisher-rules/data/rules/gitlab.yml index ad1a1e7..894bed3 100644 --- a/crates/kingfisher-rules/data/rules/gitlab.yml +++ b/crates/kingfisher-rules/data/rules/gitlab.yml @@ -35,6 +35,18 @@ rules: words: - '"id"' url: https://gitlab.com/api/v4/personal_access_tokens/self + revocation: + type: Http + content: + request: + headers: + PRIVATE-TOKEN: '{{ TOKEN }}' + method: DELETE + response_matcher: + - report_response: true + - type: StatusMatch + status: [204] + url: https://gitlab.com/api/v4/personal_access_tokens/self - name: GitLab Runner Registration Token id: kingfisher.gitlab.2 @@ -167,3 +179,15 @@ rules: words: - '"id"' url: https://gitlab.com/api/v4/personal_access_tokens/self + revocation: + type: Http + content: + request: + headers: + PRIVATE-TOKEN: '{{ TOKEN }}' + method: DELETE + response_matcher: + - report_response: true + - type: StatusMatch + status: [204] + url: https://gitlab.com/api/v4/personal_access_tokens/self diff --git a/crates/kingfisher-rules/src/rule.rs b/crates/kingfisher-rules/src/rule.rs index 7c52e73..e305587 100644 --- a/crates/kingfisher-rules/src/rule.rs +++ b/crates/kingfisher-rules/src/rule.rs @@ -58,6 +58,8 @@ pub enum Validation { #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] #[serde(tag = "type", content = "content")] pub enum Revocation { + AWS, + GCP, Http(HttpValidation), } diff --git a/docs/RULES.md b/docs/RULES.md index 3116dbf..408c6d6 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -81,6 +81,21 @@ rules: - report_response: true - type: StatusMatch status: [200, 202] + +``` + +AWS access key revocation can use: + +```yaml +revocation: + type: AWS +``` + +GCP service account key revocation can use: + +```yaml +revocation: + type: GCP ``` | Field | What it does | @@ -95,7 +110,7 @@ rules: | depends_on_rule | Chain rules: use captures from one rule in another's validation | | pattern_requirements | Require character types and/or exclude placeholder words from matches | | validation | Configure HTTP, AWS, GCP, etc. checks to verify live validity | -| revocation | Configure HTTP calls to revoke a detected secret | +| revocation | Configure HTTP or AWS revocation actions for a detected secret | *responser_matcher* variants. Multiple can be used diff --git a/src/cli/commands/inputs.rs b/src/cli/commands/inputs.rs index ab72881..e1beef7 100644 --- a/src/cli/commands/inputs.rs +++ b/src/cli/commands/inputs.rs @@ -590,6 +590,7 @@ fn warn_deprecated_provider(provider: &str, guidance: &str) { pub struct ContentFilteringArgs { /// Ignore files larger than the given size in MB #[arg( + global = true, long = "max-file-size", visible_alias = "max-filesize", // also show in --help default_value_t = 256.0, @@ -599,19 +600,19 @@ pub struct ContentFilteringArgs { /// Skip any file or directory whose path matches this glob pattern. Multiple /// patterns may be provided by repeating the flag. - #[arg(long, value_name = "PATTERN")] + #[arg(global = true, long, value_name = "PATTERN")] pub exclude: Vec, /// If true, do NOT extract archive files - #[arg(long = "no-extract-archives", default_value_t = false)] + #[arg(global = true, long = "no-extract-archives", default_value_t = false)] pub no_extract_archives: bool, /// Maximum allowed depth for extracting nested archives - #[arg(long = "extraction-depth", default_value_t = 2, value_parser = clap::value_parser!(u8).range(1..=25))] + #[arg(global = true, long = "extraction-depth", default_value_t = 2, value_parser = clap::value_parser!(u8).range(1..=25))] pub extraction_depth: u8, /// If true, do NOT scan binary files - #[arg(long = "no-binary", default_value_t = false)] + #[arg(global = true, long = "no-binary", default_value_t = false)] pub no_binary: bool, } diff --git a/src/cli/commands/rules.rs b/src/cli/commands/rules.rs index e204e17..1193d8b 100644 --- a/src/cli/commands/rules.rs +++ b/src/cli/commands/rules.rs @@ -14,17 +14,17 @@ pub struct RuleSpecifierArgs { /// /// Directories are walked recursively for YAML files. This option /// can be repeated. - #[arg(long, alias="rules", value_hint=ValueHint::AnyPath)] + #[arg(global = true, long, alias="rules", value_hint=ValueHint::AnyPath)] pub rules_path: Vec, /// Enable the ruleset with the given ID (e.g. `all`, `default`, or custom) /// /// Repeating this disables the default set unless `default` is explicitly included. - #[arg(long, default_values_t=["all".to_string()])] + #[arg(global = true, long, default_values_t=["all".to_string()])] pub rule: Vec, /// Load built-in rules - #[arg(long, default_value_t=true, action=ArgAction::Set)] + #[arg(global = true, long, default_value_t=true, action=ArgAction::Set)] pub load_builtins: bool, } diff --git a/src/cli/commands/scan.rs b/src/cli/commands/scan.rs index 22f2ee0..4cc6612 100644 --- a/src/cli/commands/scan.rs +++ b/src/cli/commands/scan.rs @@ -112,15 +112,15 @@ pub struct ScanArgs { // #[arg(long, value_name = "PATH")] // pub access_map_html: Option, /// Display only validated findings - #[arg(long, default_value_t = false)] + #[arg(global = true, long, default_value_t = false)] pub only_valid: bool, /// Override the default minimum entropy threshold - #[arg(long, short = 'e')] + #[arg(global = true, long, short = 'e')] pub min_entropy: Option, /// Show performance statistics for each rule - #[arg(long, default_value_t = false)] + #[arg(global = true, long, default_value_t = false)] pub rule_stats: bool, /// Display every occurrence of a finding diff --git a/src/direct_revoke.rs b/src/direct_revoke.rs index 6b8d79a..7102bc8 100644 --- a/src/direct_revoke.rs +++ b/src/direct_revoke.rs @@ -11,7 +11,7 @@ use std::{ use anyhow::{anyhow, bail, Context, Result}; use liquid::Object; -use liquid_core::Value; +use liquid_core::{Value, ValueView}; use regex::Regex; use reqwest::Client; use serde::Serialize; @@ -22,6 +22,8 @@ use crate::{ liquid_filters::register_all, rule_loader::RuleLoader, rules::{rule::Rule, HttpValidation, Revocation}, + validation::aws::{revoke_aws_access_key, validate_aws_credentials_input}, + validation::gcp::revoke_gcp_service_account_key, validation::httpvalidation::{build_request_builder, retry_request, validate_response}, validation::GLOBAL_USER_AGENT, }; @@ -94,6 +96,13 @@ fn extract_revocation_vars(revocation: &Revocation) -> BTreeSet { let mut vars = BTreeSet::new(); match revocation { + Revocation::AWS => { + vars.insert("AKID".to_string()); + vars.insert("TOKEN".to_string()); + } + Revocation::GCP => { + vars.insert("TOKEN".to_string()); + } Revocation::Http(http) => { vars.extend(extract_template_vars(&http.request.url)); for (key, value) in &http.request.headers { @@ -109,6 +118,11 @@ fn extract_revocation_vars(revocation: &Revocation) -> BTreeSet { vars } +/// Extract a string value from the globals object. +fn get_global_var(globals: &Object, name: &str) -> Option { + globals.get(name).and_then(|v| v.to_kstr().to_string().into()) +} + /// Build the globals object for Liquid template rendering. fn build_globals( secret: &str, @@ -324,6 +338,62 @@ pub async fn run_direct_revocation( } let mut result = match revocation { + Revocation::AWS => { + let akid = get_global_var(&globals, "AKID") + .or_else(|| get_global_var(&globals, "ACCESS_KEY_ID")) + .ok_or_else(|| { + anyhow!( + "AWS revocation requires AKID variable. Use: --var AKID= " + ) + })?; + + if let Err(err) = validate_aws_credentials_input(&akid, &secret) { + DirectRevocationResult { + rule_id: String::new(), + rule_name: String::new(), + revoked: false, + status_code: None, + message: format!("Invalid AWS credentials: {}", err), + } + } else { + match revoke_aws_access_key(&akid, &secret).await { + Ok((revoked, message)) => DirectRevocationResult { + rule_id: String::new(), + rule_name: String::new(), + revoked, + status_code: None, + message, + }, + Err(e) => DirectRevocationResult { + rule_id: String::new(), + rule_name: String::new(), + revoked: false, + status_code: None, + message: format!("AWS revocation error: {}", e), + }, + } + } + } + Revocation::GCP => { + let key_id_override = get_global_var(&globals, "KEY_ID") + .or_else(|| get_global_var(&globals, "PRIVATE_KEY_ID")); + match revoke_gcp_service_account_key(&secret, key_id_override.as_deref()).await { + Ok(outcome) => DirectRevocationResult { + rule_id: String::new(), + rule_name: String::new(), + revoked: outcome.revoked, + status_code: outcome.status_code, + message: outcome.message, + }, + Err(e) => DirectRevocationResult { + rule_id: String::new(), + rule_name: String::new(), + revoked: false, + status_code: None, + message: format!("GCP revocation error: {}", e), + }, + } + } Revocation::Http(http_revocation) => { execute_http_revocation( http_revocation, diff --git a/src/validation/aws.rs b/src/validation/aws.rs index d1d844a..b597d82 100644 --- a/src/validation/aws.rs +++ b/src/validation/aws.rs @@ -3,6 +3,10 @@ use std::{collections::HashSet, sync::RwLock, time::Duration}; use anyhow::{anyhow, Result}; use aws_config::{retry::RetryConfig, BehaviorVersion, SdkConfig}; use aws_credential_types::Credentials; +use aws_sdk_iam::{ + config::Builder as IamConfigBuilder, error::SdkError as IamSdkError, + operation::update_access_key::UpdateAccessKeyError, types::StatusType, Client as IamClient, +}; use aws_sdk_sts::{ config::Builder as StsConfigBuilder, error::SdkError, operation::get_caller_identity::GetCallerIdentityError, Client as StsClient, @@ -209,6 +213,83 @@ fn is_throttling_or_transient(e: &SdkError) -> bool { } } +fn is_iam_throttling_or_transient(e: &IamSdkError) -> bool { + match e { + IamSdkError::ServiceError(ctx) => { + let code = ctx.err().meta().code().unwrap_or_default(); + let status: StatusCode = ctx.raw().status().into(); + code.contains("Throttl") + || status == StatusCode::TOO_MANY_REQUESTS + || status == StatusCode::SERVICE_UNAVAILABLE + } + IamSdkError::DispatchFailure(df) => df.is_timeout() || df.is_io(), + IamSdkError::ResponseError(ctx) => { + let status: StatusCode = ctx.raw().status().into(); + status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE + } + _ => false, + } +} + +pub async fn revoke_aws_access_key( + aws_access_key_id: &str, + aws_secret_access_key: &str, +) -> Result<(bool, String)> { + // Create static credentials + let credentials = Credentials::new( + aws_access_key_id, + aws_secret_access_key, + None, // session token + None, // expiry + "static", // provider name + ); + let config = build_base_config(credentials).await; + + // Create IAM client + let iam_config = IamConfigBuilder::from(&config).interceptor(UaInterceptor).build(); + let iam_client = IamClient::from_conf(iam_config); + + const MAX_ATTEMPTS: usize = 3; + const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(5); + + for attempt in 1..=MAX_ATTEMPTS { + let result = timeout( + ATTEMPT_TIMEOUT, + iam_client + .update_access_key() + .access_key_id(aws_access_key_id) + .status(StatusType::Inactive) + .send(), + ) + .await; + + match result { + Ok(Ok(_)) => { + return Ok((true, "AWS access key set to Inactive".to_string())); + } + Ok(Err(e)) => { + if is_iam_throttling_or_transient(&e) { + if attempt == MAX_ATTEMPTS { + return Err(anyhow!("AWS revocation failed: {}", e)); + } + } else { + return Ok((false, e.to_string())); + } + } + Err(_) => { + if attempt == MAX_ATTEMPTS { + return Err(anyhow!("AWS revocation timed out")); + } + } + } + + let max_delay = 100u64 * 2u64.pow((attempt - 1) as u32); + let sleep_ms = rng().random_range(0..=max_delay); + sleep(Duration::from_millis(sleep_ms)).await; + } + + Err(anyhow!("AWS revocation failed")) +} pub async fn validate_aws_credentials( aws_access_key_id: &str, aws_secret_access_key: &str, diff --git a/src/validation/gcp.rs b/src/validation/gcp.rs index 44fcaf4..cc2d1fe 100644 --- a/src/validation/gcp.rs +++ b/src/validation/gcp.rs @@ -6,6 +6,7 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{Duration as ChronoDuration, Utc}; use once_cell::sync::OnceCell; use pem::parse; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use reqwest::{Client, Proxy}; use ring::{rand, signature}; use serde_json::Value as JsonValue; @@ -27,6 +28,14 @@ pub struct GcpTokenContext { pub client_email: String, } +/// Result of a GCP service account key revocation attempt. +#[derive(Debug, Clone)] +pub struct GcpRevocationOutcome { + pub revoked: bool, + pub status_code: Option, + pub message: String, +} + impl GcpValidator { pub fn global() -> Result<&'static Self> { GLOBAL_VALIDATOR.get_or_try_init(Self::new) @@ -81,6 +90,59 @@ impl GcpValidator { } } +/// Revoke a GCP service account key using the IAM API. +pub async fn revoke_gcp_service_account_key( + gcp_json: &str, + key_id_override: Option<&str>, +) -> Result { + let validator = GcpValidator::global()?; + let token_info: JsonValue = serde_json::from_str(gcp_json)?; + + let project_id = token_info["project_id"].as_str().unwrap_or("").to_string(); + let client_email = token_info["client_email"].as_str().unwrap_or("").to_string(); + let mut key_id = token_info["private_key_id"].as_str().unwrap_or("").to_string(); + if let Some(override_id) = key_id_override { + if !override_id.trim().is_empty() { + key_id = override_id.trim().to_string(); + } + } + + if project_id.is_empty() || client_email.is_empty() || key_id.is_empty() { + return Err(anyhow!( + "Missing required GCP fields: project_id/client_email/private_key_id" + )); + } + + let ctx = validator.get_access_token_from_sa_json(gcp_json).await?; + let encode = |value: &str| utf8_percent_encode(value, NON_ALPHANUMERIC).to_string(); + let url = format!( + "https://iam.googleapis.com/v1/projects/{}/serviceAccounts/{}/keys/{}", + encode(&project_id), + encode(&client_email), + encode(&key_id), + ); + + let response = validator + .client() + .delete(url) + .bearer_auth(&ctx.access_token) + .send() + .await?; + + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|e| format!("Failed to read response body: {}", e)); + let message = if body.trim().is_empty() { status.to_string() } else { body }; + + Ok(GcpRevocationOutcome { + revoked: status.is_success(), + status_code: Some(status.as_u16()), + message, + }) +} + /// Generate a standardized cache key for GCP validation attempts. pub fn generate_gcp_cache_key(gcp_json: &str) -> String { use sha1::{Digest, Sha1};