forked from mirrors/kingfisher
ensured more CLI arguments are global
This commit is contained in:
parent
8be7941333
commit
aee1050620
13 changed files with 297 additions and 12 deletions
14
README.md
14
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 "<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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ rules:
|
|||
},
|
||||
validation:
|
||||
type: AWS
|
||||
revocation:
|
||||
type: AWS
|
||||
depends_on_rule:
|
||||
- rule_id: kingfisher.aws.1
|
||||
variable: AKID
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
@ -36,6 +36,8 @@ rules:
|
|||
}
|
||||
validation:
|
||||
type: GCP
|
||||
revocation:
|
||||
type: GCP
|
||||
- name: GCP Private Key ID
|
||||
id: kingfisher.gcp.3
|
||||
pattern: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue