ensured more CLI arguments are global

This commit is contained in:
Mick Grove 2026-01-30 08:04:15 -08:00
commit aee1050620
13 changed files with 297 additions and 12 deletions

View file

@ -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 "<account_id>" --arg "<token_id>" "<admin_access_token>"
# 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
```

View file

@ -63,6 +63,8 @@ rules:
},
validation:
type: AWS
revocation:
type: AWS
depends_on_rule:
- rule_id: kingfisher.aws.1
variable: AKID

View file

@ -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]

View file

@ -36,6 +36,8 @@ rules:
}
validation:
type: GCP
revocation:
type: GCP
- name: GCP Private Key ID
id: kingfisher.gcp.3
pattern: |

View file

@ -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

View file

@ -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),
}

View file

@ -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

View file

@ -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<String>,
/// 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,
}

View file

@ -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<PathBuf>,
/// 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<String>,
/// 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,
}

View file

@ -112,15 +112,15 @@ pub struct ScanArgs {
// #[arg(long, value_name = "PATH")]
// pub access_map_html: Option<PathBuf>,
/// 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<f32>,
/// 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

View file

@ -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<String> {
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<String> {
vars
}
/// Extract a string value from the globals object.
fn get_global_var(globals: &Object, name: &str) -> Option<String> {
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=<access_key_id> <secret_access_key>"
)
})?;
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,

View file

@ -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<GetCallerIdentityError>) -> bool {
}
}
fn is_iam_throttling_or_transient(e: &IamSdkError<UpdateAccessKeyError>) -> 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,

View file

@ -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<u16>,
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<GcpRevocationOutcome> {
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};