use std::{
collections::{BTreeMap, BTreeSet},
io::{self, Read},
sync::Arc,
time::Duration,
};
use anyhow::{Context, Result, anyhow, bail};
use kingfisher_core::ValidationOutcome;
use liquid::Object;
use liquid_core::{Value, ValueView};
use reqwest::Client;
use serde::Serialize;
use tracing::debug;
use crate::{
cli::{commands::validate::ValidateArgs, global::GlobalArgs},
liquid_filters::register_all,
provider_endpoints::{ProviderEndpointOverrides, hydrate_endpoint_globals_for_rule},
rule_loader::RuleLoader,
rules::{Validation, rule::Rule},
template_vars::extract_template_vars,
validation::{GLOBAL_USER_AGENT, httpvalidation::is_auto_provided_request_var},
validation_rate_limit::{ValidationRateLimiter, should_rate_limit_validation},
};
fn preview_body_for_display(body: &str, max_bytes: usize) -> String {
if body.len() <= max_bytes {
return body.to_string();
}
let mut end = max_bytes.min(body.len());
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &body[..end])
}
#[derive(Debug, Clone, Serialize)]
pub struct DirectValidationResult {
pub rule_id: String,
pub rule_name: String,
pub is_valid: bool,
pub validation_outcome: ValidationOutcome,
pub status_code: Option<u16>,
pub message: String,
}
pub(crate) fn find_rules_by_selector<'a>(
selector: &str,
rules: &'a BTreeMap<String, Rule>,
) -> Result<Vec<&'a Rule>> {
let mut matches: Vec<&Rule> = Vec::new();
let mut selectors_to_try = vec![std::borrow::Cow::Borrowed(selector)];
if !selector.starts_with("betterleaks.") && !selector.starts_with("kingfisher.") {
selectors_to_try.push(std::borrow::Cow::Owned(format!("betterleaks.{selector}")));
selectors_to_try.push(std::borrow::Cow::Owned(format!("kingfisher.{selector}")));
}
for exact in &selectors_to_try {
if let Some(rule) = rules.get(exact.as_ref()) {
return Ok(vec![rule]);
}
}
for try_selector in &selectors_to_try {
for (id, rule) in rules {
if id == try_selector.as_ref()
|| (id.starts_with(try_selector.as_ref())
&& matches!(id.as_bytes().get(try_selector.len()), Some(b'.' | b'-')))
{
matches.push(rule);
}
}
if !matches.is_empty() {
break;
}
}
if matches.is_empty() {
bail!(
"No rule found matching '{}'. Use `kingfisher rules list` to see available rules.",
selector
);
}
Ok(matches)
}
fn find_validation_rule<'a>(selector: &str, rules: &'a BTreeMap<String, Rule>) -> Result<&'a Rule> {
let matches = find_rules_by_selector(selector, rules)?;
if matches.len() > 1 {
bail!(
"Ambiguous rule selector '{}': {}. Use a full rule id.",
selector,
matches.iter().map(|rule| rule.id()).collect::<Vec<_>>().join(", ")
);
}
Ok(matches[0])
}
pub(crate) fn get_global_var(globals: &Object, name: &str) -> Option<String> {
globals.get(name).and_then(|v| v.to_kstr().to_string().into())
}
pub(crate) fn extract_validation_vars(validation: &Validation) -> BTreeSet<String> {
let mut vars = BTreeSet::new();
match validation {
Validation::Assumed => {}
Validation::Ethereum(_) => {
vars.insert("TOKEN".to_string());
}
Validation::Http(http) => {
vars.extend(extract_template_vars(&http.request.url));
for (key, value) in &http.request.headers {
vars.extend(extract_template_vars(key));
vars.extend(extract_template_vars(value));
}
if let Some(body) = &http.request.body {
vars.extend(extract_template_vars(body));
}
}
Validation::Grpc(grpc) => {
vars.extend(extract_template_vars(&grpc.request.url));
for (key, value) in &grpc.request.headers {
vars.extend(extract_template_vars(key));
vars.extend(extract_template_vars(value));
}
if let Some(body) = &grpc.request.body {
vars.extend(extract_template_vars(body));
}
}
Validation::Betterleaks(validation) => {
vars.insert("TOKEN".to_string());
vars.extend(validation.components.values().cloned());
}
Validation::AWS => {
vars.insert("AKID".to_string());
vars.insert("TOKEN".to_string());
}
Validation::GCP => {
vars.insert("TOKEN".to_string());
}
Validation::MongoDB => {
vars.insert("TOKEN".to_string());
}
Validation::MySQL => {
vars.insert("TOKEN".to_string());
}
Validation::Postgres => {
vars.insert("TOKEN".to_string());
}
Validation::Jdbc => {
vars.insert("TOKEN".to_string());
}
Validation::CredentialUri => {
vars.insert("TOKEN".to_string());
}
Validation::JWT => {
vars.insert("TOKEN".to_string());
}
Validation::AzureStorage => {
vars.insert("TOKEN".to_string());
vars.insert("AZURENAME".to_string());
}
Validation::Coinbase => {
vars.insert("TOKEN".to_string());
vars.insert("CRED_NAME".to_string());
}
Validation::Raw(raw) => {
vars.extend(kingfisher_scanner::validation::raw::required_vars(raw));
}
}
vars.retain(|var| !is_auto_provided_request_var(var));
vars
}
pub(crate) fn build_globals(
rule_id: &str,
secret: &str,
args: &[String],
variables: &[String],
template_vars: &BTreeSet<String>,
endpoint_overrides: &ProviderEndpointOverrides,
) -> Result<Object> {
let mut globals = Object::new();
globals.insert("TOKEN".into(), Value::scalar(secret.to_string()));
endpoint_overrides.apply_defaults(&mut globals);
let auto_assign_vars: Vec<&String> = template_vars
.iter()
.filter(|v| *v != "TOKEN" && !globals.contains_key(v.as_str()))
.collect();
for (i, arg_value) in args.iter().enumerate() {
if i < auto_assign_vars.len() {
let var_name = auto_assign_vars[i];
debug!("Auto-assigning --arg '{}' to variable '{}'", arg_value, var_name);
globals.insert(var_name.clone().into(), Value::scalar(arg_value.clone()));
}
}
for var in variables {
let (name, value) = var
.split_once('=')
.ok_or_else(|| anyhow!("Invalid variable format '{}'. Expected NAME=VALUE", var))?;
let name = name.trim().to_uppercase();
let value = value.trim().to_string();
if name.is_empty() {
bail!("Variable name cannot be empty in '{}'", var);
}
globals.insert(name.into(), Value::scalar(value));
}
hydrate_endpoint_globals_for_rule(rule_id, &mut globals);
Ok(globals)
}
pub(crate) fn read_secret(secret_arg: Option<&str>) -> Result<String> {
match secret_arg {
Some("-") => {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer).context("Failed to read secret from stdin")?;
Ok(buffer.trim().to_string())
}
Some(s) => Ok(s.to_string()),
None => {
bail!("No secret provided. Pass a secret as an argument or use '-' to read from stdin.")
}
}
}
pub async fn run_direct_validation(
args: &ValidateArgs,
global_args: &GlobalArgs,
) -> Result<Vec<DirectValidationResult>> {
let secret = read_secret(args.secret.as_deref())?;
if secret.is_empty() {
bail!("Secret cannot be empty");
}
let loader = RuleLoader::new()
.load_builtins(!args.no_builtins)
.additional_rule_load_paths(&args.rules_path);
let scan_args = create_minimal_scan_args();
let loaded = loader.load(&scan_args)?;
let matching_rules = vec![find_validation_rule(&args.rule, loaded.id_to_rule())?];
let use_lax_tls = matches!(
global_args.tls_mode,
crate::cli::global::TlsMode::Off | crate::cli::global::TlsMode::Lax
);
let client = Client::builder()
.danger_accept_invalid_certs(use_lax_tls)
.timeout(Duration::from_secs(args.timeout))
.user_agent(GLOBAL_USER_AGENT.as_str())
.redirect(reqwest::redirect::Policy::none())
.gzip(true)
.deflate(true)
.brotli(true)
.build()
.context("Failed to build HTTP client")?;
let credential_uri_client = crate::validation::build_credential_uri_client(
Duration::from_secs(args.timeout),
use_lax_tls,
)
.context("Failed to build credential URI HTTP client")?;
let parser = register_all(liquid::ParserBuilder::with_stdlib()).build()?;
let endpoint_overrides = ProviderEndpointOverrides::from_global_args(global_args)?;
let timeout = Duration::from_secs(args.timeout);
let rate_limiter =
ValidationRateLimiter::from_cli(args.validation_rps, &args.validation_rps_rule)?
.map(Arc::new);
let mut results = Vec::new();
for rule in matching_rules {
let rule_id = rule.id().to_string();
let rule_name = rule.name().to_string();
debug!("Trying rule: {} ({})", rule_name, rule_id);
if !rule.syntax().is_authoritative() {
results.push(DirectValidationResult {
rule_id,
rule_name,
is_valid: false,
validation_outcome: ValidationOutcome::NotAttempted,
status_code: None,
message: "Generic API key validation is not authoritative".to_string(),
});
continue;
}
let validation = match rule.syntax().validation.as_ref() {
Some(v) => v,
None => {
debug!("Rule '{}' has no validation defined, skipping", rule_id);
continue;
}
};
let mut template_vars = extract_validation_vars(validation);
if matches!(validation, Validation::AWS)
&& crate::validation::is_aws_session_token_rule(rule)
{
template_vars.insert("AWS_SECRET_ACCESS_KEY".to_string());
}
let non_token_vars: Vec<&String> = template_vars.iter().filter(|v| *v != "TOKEN").collect();
if args.args.len() > non_token_vars.len() {
let var_list = if non_token_vars.is_empty() {
"none".to_string()
} else {
non_token_vars.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
};
bail!(
"Too many --arg values provided. Rule '{}' expects {} additional variable(s): {}",
rule_id,
non_token_vars.len(),
var_list
);
}
let globals = build_globals(
&rule_id,
&secret,
&args.args,
&args.variables,
&template_vars,
&endpoint_overrides,
)?;
if !non_token_vars.is_empty() && !args.args.is_empty() {
debug!(
"Rule '{}' uses variables: {:?}, auto-assigned from --arg: {:?}",
rule_id, non_token_vars, args.args
);
}
let missing_vars: Vec<&String> =
template_vars.iter().filter(|var| globals.get(var.as_str()).is_none()).collect();
if !missing_vars.is_empty() {
let depends_on_map: BTreeMap<String, &str> = rule
.syntax()
.depends_on_rule
.iter()
.flatten()
.map(|dep| (dep.variable.to_uppercase(), dep.rule_id.as_str()))
.collect();
let mut error_parts = Vec::new();
let mut var_hints = Vec::new();
for var in &missing_vars {
if let Some(source_rule) = depends_on_map.get(*var) {
error_parts
.push(format!(" {} (normally captured from rule '{}')", var, source_rule));
} else {
error_parts.push(format!(" {}", var));
}
var_hints.push(format!("--var {}=<value>", var));
}
bail!(
"Rule '{}' requires the following variable(s):\n{}\n\nProvide them using: kingfisher validate --rule {} {} <secret>",
rule_id,
error_parts.join("\n"),
rule_id,
var_hints.join(" ")
);
}
if let Some(limiter) = rate_limiter.as_deref()
&& should_rate_limit_validation(validation)
{
limiter.wait_for_rule(&rule_id).await;
}
let checked = kingfisher_scanner::validation::ValidationEngine::new(&client, &parser)
.credential_uri_client(&credential_uri_client)
.timeout(timeout)
.retries(args.retries)
.allow_internal_ips(global_args.allow_internal_ips)
.use_lax_tls(use_lax_tls)
.validate(rule, &globals)
.await;
let mut result = DirectValidationResult {
rule_id: String::new(),
rule_name: String::new(),
is_valid: checked.outcome.is_verified_active()
|| checked.outcome == ValidationOutcome::Assumed,
validation_outcome: checked.outcome,
status_code: checked.http_status,
message: if checked.response_body.is_empty() {
checked
.reason
.map(|reason| format!("Validation {:?}", reason))
.unwrap_or_else(|| checked.outcome.display_name().to_string())
} else {
preview_body_for_display(&checked.response_body, 4096)
},
};
result.rule_id = rule_id;
result.rule_name = rule_name;
results.push(result);
}
if results.is_empty() {
bail!(
"No rules with validation found matching '{}'. \
Use `kingfisher rules list` to see available rules.",
args.rule
);
}
Ok(results)
}
pub(crate) fn create_minimal_scan_args() -> crate::cli::commands::scan::ScanArgs {
use crate::cli::commands::{
azure::AzureRepoType,
bitbucket::BitbucketAuthArgs,
bitbucket::BitbucketRepoType,
gitea::GiteaRepoType,
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
gitlab::GitLabRepoType,
inputs::{ContentFilteringArgs, InputSpecifierArgs},
output::{OutputArgs, ReportOutputFormat},
rules::{RuleCacheArgs, RuleSpecifierArgs},
scan::{ConfidenceLevel, ScanArgs},
};
use url::Url;
ScanArgs {
num_jobs: 1,
rules: RuleSpecifierArgs {
rules_path: Vec::new(),
rule: vec!["all".into()],
exclude_rule: Vec::new(),
load_builtins: true,
},
rule_cache: RuleCacheArgs::default(),
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: Vec::new(),
git_clone_dir: None,
keep_clones: false,
repo_clone_limit: None,
include_contributors: false,
github_user: Vec::new(),
github_include_gists: false,
github_organization: Vec::new(),
github_exclude: Vec::new(),
all_github_organizations: false,
github_api_url: Url::parse("https://api.github.com/").unwrap(),
github_repo_type: GitHubRepoType::Source,
github_event_user: Vec::new(),
github_event_lookback_hours: 24,
gitlab_user: Vec::new(),
gitlab_include_snippets: false,
gitlab_group: Vec::new(),
gitlab_exclude: Vec::new(),
all_gitlab_groups: false,
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::All,
gitlab_include_subgroups: false,
huggingface_user: Vec::new(),
huggingface_organization: Vec::new(),
huggingface_model: Vec::new(),
huggingface_dataset: Vec::new(),
huggingface_space: Vec::new(),
huggingface_bucket: Vec::new(),
huggingface_exclude: Vec::new(),
gitea_user: Vec::new(),
gitea_organization: Vec::new(),
gitea_exclude: Vec::new(),
all_gitea_organizations: false,
gitea_api_url: Url::parse("https://gitea.com/api/v1/").unwrap(),
gitea_repo_type: GiteaRepoType::Source,
bitbucket_user: Vec::new(),
bitbucket_include_snippets: false,
bitbucket_workspace: Vec::new(),
bitbucket_project: Vec::new(),
bitbucket_exclude: Vec::new(),
all_bitbucket_workspaces: false,
bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(),
bitbucket_repo_type: BitbucketRepoType::Source,
bitbucket_auth: BitbucketAuthArgs::default(),
azure_organization: Vec::new(),
azure_project: Vec::new(),
azure_exclude: Vec::new(),
all_azure_projects: false,
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
azure_repo_type: AzureRepoType::Source,
jira_url: None,
jql: None,
jira_include_comments: false,
jira_include_changelog: false,
confluence_url: None,
cql: None,
max_results: 100,
s3_bucket: None,
s3_prefix: None,
role_arn: None,
aws_local_profile: None,
gcs_bucket: None,
gcs_prefix: None,
gcs_service_account: None,
slack_query: None,
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
docker_image: Vec::new(),
docker_archive: Vec::new(),
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
since_commit: None,
branch: None,
branch_root: false,
branch_root_commit: None,
staged: false,
},
extra_ignore_comments: Vec::new(),
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,
no_extract_archives: true,
extraction_depth: 2,
exclude: Vec::new(),
no_binary: true,
},
confidence: ConfidenceLevel::Low, disk_offload: false,
no_validate: true,
access_map: false,
rule_stats: false,
only_valid: false,
validation_filter: None,
include_hidden_findings: false,
min_entropy: None,
redact: false,
git_repo_timeout: 1800,
audit_log: None,
no_dedup: false,
view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
skip_word: Vec::new(),
skip_aws_account: Vec::new(),
skip_aws_account_file: None,
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_base64: false,
turbo: false,
no_inline_ignore: false,
no_ignore_if_contains: false,
view_report_port: 7890,
view_report_address: "127.0.0.1".to_string(),
alert_webhook: Vec::new(),
alert_format: None,
alert_on: crate::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: crate::alerts::AlertDetail::Auto,
alert_finding_filter: crate::alerts::AlertFindingFilter::All,
alert_prevent_empty: false,
alert_dry_run: false,
config_webhook_overrides: Vec::new(),
validation_timeout: 10,
validation_retries: 1,
validation_rps: None,
validation_rps_rule: Vec::new(),
full_validation_response: false,
max_validation_response_length: 2048,
}
}
pub fn print_results(results: &[DirectValidationResult], format: &str, use_color: bool) {
match format {
"json" => {
if results.len() == 1 {
println!("{}", serde_json::to_string_pretty(&results[0]).unwrap());
} else {
println!("{}", serde_json::to_string_pretty(results).unwrap());
}
}
"toon" => {
let value = if results.len() == 1 {
serde_json::to_value(&results[0]).unwrap()
} else {
serde_json::to_value(results).unwrap()
};
println!("{}", crate::toon::encode_llm_friendly(&value).unwrap());
}
_ => {
for (i, result) in results.iter().enumerate() {
if i > 0 {
println!(); }
let valid_str = if result.validation_outcome == ValidationOutcome::Assumed {
if use_color {
"\x1b[94m🔒 Assumed Valid (Not Live-Validated)\x1b[0m"
} else {
"Assumed Valid (Not Live-Validated)"
}
} else if result.validation_outcome == ValidationOutcome::LocallyDerived {
if use_color { "\x1b[36mâ—‡ LOCALLY DERIVED\x1b[0m" } else { "LOCALLY DERIVED" }
} else if result.validation_outcome == ValidationOutcome::InvalidMaterial {
if use_color {
"\x1b[31m✗ INVALID MATERIAL\x1b[0m"
} else {
"INVALID MATERIAL"
}
} else if result.is_valid {
if use_color { "\x1b[32m✓ VALID\x1b[0m" } else { "VALID" }
} else if use_color {
"\x1b[31m✗ INVALID\x1b[0m"
} else {
"INVALID"
};
println!("Rule: {} ({})", result.rule_name, result.rule_id);
println!("Result: {}", valid_str);
if let Some(status) = result.status_code {
println!("Status: {}", status);
}
if !result.message.is_empty() {
println!("Response: {}", result.message);
}
}
}
}
}
pub fn any_actionable(results: &[DirectValidationResult]) -> bool {
results.iter().any(|r| r.validation_outcome.is_actionable())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn direct_http_credential_uri_preserves_sanitized_validator_errors() {
let mut syntax = selector_test_rule("private.uri").syntax().clone();
syntax.validation = Some(Validation::CredentialUri);
let rule = Rule::new(syntax);
let client = Client::new();
let parser = register_all(liquid::ParserBuilder::with_stdlib()).build().unwrap();
let globals = Object::from_iter([(
"TOKEN".into(),
Value::scalar("http://alice:hunter2@example.com"),
)]);
let result = kingfisher_scanner::validation::ValidationEngine::new(&client, &parser)
.validate(&rule, &globals)
.await;
assert_eq!(result.outcome, ValidationOutcome::Unavailable);
assert!(!result.response_body.contains("hunter2"));
assert!(!format!("{result:?}").contains("hunter2"));
}
fn selector_test_rule(id: &str) -> Rule {
Rule::new(kingfisher_rules::RuleSyntax {
name: id.to_string(),
id: id.to_string(),
pattern: r"\btest\b".to_string(),
min_entropy: 0.0,
confidence: Default::default(),
visible: true,
examples: Vec::new(),
negative_examples: Vec::new(),
references: Vec::new(),
validation: None,
revocation: None,
depends_on_rule: Vec::new(),
pattern_requirements: None,
tls_mode: None,
path: None,
betterleaks_filter: None,
betterleaks_secret_group: None,
authoritative: true,
vectorscan_compatible: true,
})
}
#[test]
fn ambiguous_selector_lists_matches_and_exact_id_wins() {
let rules = BTreeMap::from_iter([
("betterleaks.github-pat".into(), selector_test_rule("betterleaks.github-pat")),
(
"betterleaks.github-pat-extra".into(),
selector_test_rule("betterleaks.github-pat-extra"),
),
]);
let error = find_validation_rule("github", &rules).unwrap_err().to_string();
assert!(error.contains("Ambiguous rule selector"));
assert!(error.contains("betterleaks.github-pat-extra"));
assert_eq!(
find_rules_by_selector("github-pat", &rules).unwrap()[0].id(),
"betterleaks.github-pat"
);
}
#[test]
fn dotted_legacy_short_selector_resolves_kingfisher_rule() {
let rules = BTreeMap::from_iter([(
"kingfisher.github.1".to_string(),
selector_test_rule("kingfisher.github.1"),
)]);
let matches = find_rules_by_selector("github.1", &rules).unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].id(), "kingfisher.github.1");
}
#[test]
fn qualified_and_unqualified_betterleaks_selectors_resolve() {
let loaded =
RuleLoader::new().load_builtins(true).load(&create_minimal_scan_args()).unwrap();
for selector in ["betterleaks.aws-access-token", "aws-access-token"] {
let matches = find_rules_by_selector(selector, loaded.id_to_rule()).unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].id(), "betterleaks.aws-access-token");
}
}
#[test]
fn generated_betterleaks_components_use_neutral_variable_names() {
let loaded =
RuleLoader::new().load_builtins(true).load(&create_minimal_scan_args()).unwrap();
let rule = loaded.id_to_rule().get("betterleaks.aws-access-token").unwrap();
let Validation::Betterleaks(validation) = rule.syntax().validation.as_ref().unwrap() else {
panic!("AWS rule should use Betterleaks validation");
};
assert_eq!(validation.components["aws-secret-access-key"], "AWS_SECRET_ACCESS_KEY");
}
#[tokio::test]
async fn direct_betterleaks_validation_uses_neutral_components_and_skips_canaries() {
let args = ValidateArgs {
rule: "betterleaks.aws-access-token".to_string(),
secret: Some("AKIAXYZDQCEN4B6JSJQI".to_string()),
args: Vec::new(),
variables: vec!["AWS_SECRET_ACCESS_KEY=not-a-real-secret-key".to_string()],
timeout: 1,
retries: 0,
validation_rps: None,
validation_rps_rule: Vec::new(),
rules_path: Vec::new(),
no_builtins: false,
format: "json".to_string(),
};
let results = run_direct_validation(&args, &GlobalArgs::default()).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].validation_outcome, ValidationOutcome::Skipped);
assert!(results[0].message.contains("(skip list entry)"));
}
}