forked from mirrors/kingfisher
1059 lines
39 KiB
Rust
1059 lines
39 KiB
Rust
//! Direct secret validation without pattern matching.
|
|
//!
|
|
//! This module provides functionality to validate a known secret directly against
|
|
//! a rule's validator, bypassing the normal pattern-matching detection phase.
|
|
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet},
|
|
io::{self, Read},
|
|
sync::Arc,
|
|
time::Duration,
|
|
};
|
|
|
|
use anyhow::{anyhow, bail, Context, Result};
|
|
use crossbeam_skiplist::SkipMap;
|
|
use liquid::Object;
|
|
use liquid_core::{Value, ValueView};
|
|
use regex::Regex;
|
|
use reqwest::Client;
|
|
use serde::Serialize;
|
|
use tracing::debug;
|
|
|
|
use crate::{
|
|
cli::{commands::validate::ValidateArgs, global::GlobalArgs},
|
|
liquid_filters::register_all,
|
|
rule_loader::RuleLoader,
|
|
rules::{rule::Rule, HttpValidation, Validation},
|
|
validation::{
|
|
aws::validate_aws_credentials,
|
|
azure::validate_azure_storage_credentials,
|
|
coinbase::validate_cdp_api_key,
|
|
gcp::GcpValidator,
|
|
httpvalidation::validate_response,
|
|
httpvalidation::{build_request_builder, retry_request},
|
|
jdbc::validate_jdbc,
|
|
jwt::validate_jwt,
|
|
mongodb::validate_mongodb,
|
|
mysql::validate_mysql,
|
|
postgres::validate_postgres,
|
|
GLOBAL_USER_AGENT,
|
|
},
|
|
validation_body,
|
|
validation_rate_limit::{should_rate_limit_validation, ValidationRateLimiter},
|
|
};
|
|
|
|
use crate::grpc_validation;
|
|
|
|
fn preview_body_for_display(body: &str, max_bytes: usize) -> String {
|
|
if body.len() <= max_bytes {
|
|
return body.to_string();
|
|
}
|
|
|
|
// `String` slicing must be on a UTF-8 char boundary to avoid panics.
|
|
let mut end = max_bytes.min(body.len());
|
|
while end > 0 && !body.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
|
|
format!("{}...", &body[..end])
|
|
}
|
|
|
|
/// Result of a direct validation attempt.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct DirectValidationResult {
|
|
/// The rule ID that was used for validation.
|
|
pub rule_id: String,
|
|
/// The rule name.
|
|
pub rule_name: String,
|
|
/// Whether the secret was validated as valid.
|
|
pub is_valid: bool,
|
|
/// HTTP status code from the validation request (if applicable).
|
|
pub status_code: Option<u16>,
|
|
/// Response body or error message.
|
|
pub message: String,
|
|
}
|
|
|
|
/// Find all rules matching an ID or prefix.
|
|
///
|
|
/// Returns all matching rules, or an error if no rules match.
|
|
fn find_rules_by_selector<'a>(
|
|
selector: &str,
|
|
rules: &'a BTreeMap<String, Rule>,
|
|
) -> Result<Vec<&'a Rule>> {
|
|
let mut matches: Vec<&Rule> = Vec::new();
|
|
|
|
// Try the selector as-is first, then with "kingfisher." prefix as fallback.
|
|
// This allows users to pass `--rule aws` instead of `--rule kingfisher.aws`.
|
|
let selectors_to_try: Vec<std::borrow::Cow<'_, str>> = if selector.starts_with("kingfisher.") {
|
|
vec![std::borrow::Cow::Borrowed(selector)]
|
|
} else {
|
|
vec![
|
|
std::borrow::Cow::Borrowed(selector),
|
|
std::borrow::Cow::Owned(format!("kingfisher.{}", selector)),
|
|
]
|
|
};
|
|
|
|
for try_selector in &selectors_to_try {
|
|
for (id, rule) in rules {
|
|
// Exact match OR "selector." is a prefix of id
|
|
if id == try_selector.as_ref()
|
|
|| (id.starts_with(try_selector.as_ref())
|
|
&& id.as_bytes().get(try_selector.len()) == Some(&b'.'))
|
|
{
|
|
matches.push(rule);
|
|
}
|
|
}
|
|
// If we matched with this selector, no need to try the fallback
|
|
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)
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
/// Extract Liquid template variable names from a string.
|
|
/// Matches patterns like {{ VAR }} or {{ VAR | filter }}.
|
|
fn extract_template_vars(text: &str) -> BTreeSet<String> {
|
|
// Match {{ VAR }} or {{ VAR | filter }} patterns
|
|
// Variable names are alphanumeric with underscores
|
|
let re = Regex::new(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:\|[^}]*)?\}\}").unwrap();
|
|
re.captures_iter(text).filter_map(|cap| cap.get(1).map(|m| m.as_str().to_uppercase())).collect()
|
|
}
|
|
|
|
/// Extract all template variables used in a validation configuration.
|
|
fn extract_validation_vars(validation: &Validation) -> BTreeSet<String> {
|
|
let mut vars = BTreeSet::new();
|
|
|
|
match validation {
|
|
Validation::Http(http) => {
|
|
// Extract from URL
|
|
vars.extend(extract_template_vars(&http.request.url));
|
|
|
|
// Extract from headers
|
|
for (key, value) in &http.request.headers {
|
|
vars.extend(extract_template_vars(key));
|
|
vars.extend(extract_template_vars(value));
|
|
}
|
|
|
|
// Extract from body
|
|
if let Some(body) = &http.request.body {
|
|
vars.extend(extract_template_vars(body));
|
|
}
|
|
}
|
|
Validation::Grpc(grpc) => {
|
|
// Extract from URL
|
|
vars.extend(extract_template_vars(&grpc.request.url));
|
|
|
|
// Extract from headers
|
|
for (key, value) in &grpc.request.headers {
|
|
vars.extend(extract_template_vars(key));
|
|
vars.extend(extract_template_vars(value));
|
|
}
|
|
|
|
// Extract from body
|
|
if let Some(body) = &grpc.request.body {
|
|
vars.extend(extract_template_vars(body));
|
|
}
|
|
}
|
|
// Non-HTTP validators typically use fixed variable names
|
|
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::JWT => {
|
|
vars.insert("TOKEN".to_string());
|
|
}
|
|
Validation::AzureStorage => {
|
|
vars.insert("TOKEN".to_string());
|
|
// AZURENAME matches the depends_on_rule variable in azurestorage.yml
|
|
// STORAGE_ACCOUNT is kept for backward compatibility
|
|
vars.insert("AZURENAME".to_string());
|
|
}
|
|
Validation::Coinbase => {
|
|
vars.insert("TOKEN".to_string());
|
|
vars.insert("CRED_NAME".to_string());
|
|
}
|
|
Validation::Raw(_) => {
|
|
vars.insert("TOKEN".to_string());
|
|
}
|
|
}
|
|
|
|
vars
|
|
}
|
|
|
|
/// Build the globals object for Liquid template rendering.
|
|
///
|
|
/// - `secret`: The main secret value, assigned to TOKEN
|
|
/// - `args`: Unnamed values to auto-assign to template variables (excluding TOKEN)
|
|
/// - `variables`: Named variables in NAME=VALUE format (explicit overrides)
|
|
/// - `template_vars`: Set of variable names used in the validation template
|
|
fn build_globals(
|
|
secret: &str,
|
|
args: &[String],
|
|
variables: &[String],
|
|
template_vars: &BTreeSet<String>,
|
|
) -> Result<Object> {
|
|
let mut globals = Object::new();
|
|
|
|
// Set TOKEN to the provided secret
|
|
globals.insert("TOKEN".into(), Value::scalar(secret.to_string()));
|
|
|
|
// Get non-TOKEN variables in alphabetical order for auto-assignment
|
|
let auto_assign_vars: Vec<&String> = template_vars.iter().filter(|v| *v != "TOKEN").collect();
|
|
|
|
// Auto-assign --arg values to template variables
|
|
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()));
|
|
}
|
|
}
|
|
|
|
// Parse and add any --var overrides (these take precedence)
|
|
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));
|
|
}
|
|
|
|
Ok(globals)
|
|
}
|
|
|
|
/// Read the secret value from the provided argument or stdin.
|
|
fn read_secret(secret_arg: Option<&str>) -> Result<String> {
|
|
match secret_arg {
|
|
Some("-") => {
|
|
// Read from stdin
|
|
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.")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render the validation URL using Liquid templates.
|
|
async fn render_and_parse_url(
|
|
parser: &liquid::Parser,
|
|
globals: &Object,
|
|
url_template: &str,
|
|
) -> Result<reqwest::Url> {
|
|
let template =
|
|
parser.parse(url_template).map_err(|e| anyhow!("Failed to parse URL template: {}", e))?;
|
|
|
|
let rendered =
|
|
template.render(globals).map_err(|e| anyhow!("Failed to render URL template: {}", e))?;
|
|
|
|
reqwest::Url::parse(&rendered).map_err(|e| anyhow!("Invalid URL '{}': {}", rendered, e))
|
|
}
|
|
|
|
/// Execute HTTP validation against the provided rule.
|
|
async fn execute_http_validation(
|
|
http_validation: &HttpValidation,
|
|
globals: &Object,
|
|
client: &Client,
|
|
parser: &liquid::Parser,
|
|
timeout: Duration,
|
|
retries: u32,
|
|
allow_internal_ips: bool,
|
|
) -> Result<DirectValidationResult> {
|
|
// Render the URL
|
|
let url = render_and_parse_url(parser, globals, &http_validation.request.url).await?;
|
|
|
|
// SSRF check: verify the resolved IP is public before making the request
|
|
crate::validation::utils::check_url_resolvable(&url, allow_internal_ips)
|
|
.await
|
|
.map_err(|e| anyhow!("URL resolution failed: {}", e))?;
|
|
|
|
debug!("Validating against URL: {}", url);
|
|
|
|
// Build the request
|
|
let request_builder = build_request_builder(
|
|
client,
|
|
&http_validation.request.method,
|
|
&url,
|
|
&http_validation.request.headers,
|
|
&http_validation.request.body,
|
|
timeout,
|
|
parser,
|
|
globals,
|
|
)
|
|
.map_err(|e| anyhow!("Failed to build request: {}", e))?;
|
|
|
|
// Execute the request with retries
|
|
let backoff_min = Duration::from_millis(100);
|
|
let backoff_max = Duration::from_secs(2);
|
|
|
|
let response = retry_request(request_builder, retries, backoff_min, backoff_max)
|
|
.await
|
|
.map_err(|e| anyhow!("Request failed: {}", e))?;
|
|
|
|
let status = response.status();
|
|
let headers = response.headers().clone();
|
|
let body =
|
|
response.text().await.unwrap_or_else(|e| format!("Failed to read response body: {}", e));
|
|
|
|
// Truncate body for display if too long
|
|
let display_body = preview_body_for_display(&body, 500);
|
|
|
|
// Validate the response
|
|
let matchers = http_validation.request.response_matcher.as_deref().unwrap_or(&[]);
|
|
let html_allowed = http_validation.request.response_is_html;
|
|
let is_valid = validate_response(matchers, &body, &status, &headers, html_allowed);
|
|
|
|
Ok(DirectValidationResult {
|
|
rule_id: String::new(), // Will be filled in by caller
|
|
rule_name: String::new(),
|
|
is_valid,
|
|
status_code: Some(status.as_u16()),
|
|
message: display_body,
|
|
})
|
|
}
|
|
|
|
/// Execute gRPC validation against the provided rule.
|
|
async fn execute_grpc_validation(
|
|
grpc_validation_cfg: &kingfisher_rules::GrpcValidation,
|
|
globals: &Object,
|
|
parser: &liquid::Parser,
|
|
timeout: Duration,
|
|
allow_internal_ips: bool,
|
|
) -> Result<DirectValidationResult> {
|
|
// Render the URL
|
|
let url = render_and_parse_url(parser, globals, &grpc_validation_cfg.request.url).await?;
|
|
|
|
// SSRF check: verify the resolved IP is public before making the request
|
|
crate::validation::utils::check_url_resolvable(&url, allow_internal_ips)
|
|
.await
|
|
.map_err(|e| anyhow!("URL resolution failed: {}", e))?;
|
|
|
|
debug!("Validating against gRPC URL: {}", url);
|
|
|
|
let res = grpc_validation::grpc_unary_call_from_rule(
|
|
&url,
|
|
&grpc_validation_cfg.request.headers,
|
|
&grpc_validation_cfg.request.body,
|
|
parser,
|
|
globals,
|
|
timeout,
|
|
)
|
|
.await
|
|
.map_err(|e| anyhow!("gRPC request failed: {e}"))?;
|
|
|
|
let status = res.http_status;
|
|
let headers = res.headers;
|
|
let mut body = String::from_utf8_lossy(&res.body_bytes).to_string();
|
|
let grpc_status =
|
|
headers.get("grpc-status").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
|
|
let grpc_message =
|
|
headers.get("grpc-message").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
|
|
if grpc_status == "0" {
|
|
body = "grpc-status=0".to_string();
|
|
} else if body.trim().is_empty() && (!grpc_status.is_empty() || !grpc_message.is_empty()) {
|
|
body = format!("grpc-status={grpc_status} grpc-message={grpc_message}");
|
|
} else if body.as_bytes().contains(&0) {
|
|
body = format!("grpc-status={grpc_status} grpc-message={grpc_message}");
|
|
}
|
|
|
|
// Truncate body for display if too long
|
|
let display_body = preview_body_for_display(&body, 500);
|
|
|
|
// Validate the response
|
|
let matchers = grpc_validation_cfg.request.response_matcher.as_deref().unwrap_or(&[]);
|
|
let is_valid = validate_response(matchers, &body, &status, &headers, false);
|
|
|
|
Ok(DirectValidationResult {
|
|
rule_id: String::new(), // Will be filled in by caller
|
|
rule_name: String::new(),
|
|
is_valid,
|
|
status_code: Some(status.as_u16()),
|
|
message: display_body,
|
|
})
|
|
}
|
|
|
|
/// Run direct validation of a secret against one or more rules.
|
|
///
|
|
/// If the rule selector matches multiple rules, all matching rules are tried.
|
|
/// Returns results for all rules that have validation defined.
|
|
pub async fn run_direct_validation(
|
|
args: &ValidateArgs,
|
|
global_args: &GlobalArgs,
|
|
) -> Result<Vec<DirectValidationResult>> {
|
|
// Read the secret
|
|
let secret = read_secret(args.secret.as_deref())?;
|
|
|
|
if secret.is_empty() {
|
|
bail!("Secret cannot be empty");
|
|
}
|
|
|
|
// Load rules
|
|
let loader = RuleLoader::new()
|
|
.load_builtins(!args.no_builtins)
|
|
.additional_rule_load_paths(&args.rules_path);
|
|
|
|
// Create minimal scan args for rule loading
|
|
let scan_args = create_minimal_scan_args();
|
|
let loaded = loader.load(&scan_args)?;
|
|
|
|
// Find all matching rules
|
|
let matching_rules = find_rules_by_selector(&args.rule, loaded.id_to_rule())?;
|
|
let num_matching_rules = matching_rules.len();
|
|
|
|
if num_matching_rules > 1 {
|
|
debug!("Rule selector '{}' matches {} rules, trying all", args.rule, num_matching_rules);
|
|
}
|
|
|
|
// Determine if we should use lax TLS for non-HTTP validators
|
|
// For direct validation (explicit user command), lax mode applies globally
|
|
let use_lax_tls = matches!(
|
|
global_args.tls_mode,
|
|
crate::cli::global::TlsMode::Off | crate::cli::global::TlsMode::Lax
|
|
);
|
|
|
|
// Build HTTP client with SSRF-safe redirect policy when applicable
|
|
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(if global_args.allow_internal_ips {
|
|
reqwest::redirect::Policy::default()
|
|
} else {
|
|
crate::validation::ssrf_safe_redirect_policy()
|
|
})
|
|
.gzip(true)
|
|
.deflate(true)
|
|
.brotli(true)
|
|
.build()
|
|
.context("Failed to build HTTP client")?;
|
|
|
|
// Build Liquid parser
|
|
let parser = register_all(liquid::ParserBuilder::with_stdlib()).build()?;
|
|
|
|
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();
|
|
|
|
// Try each matching rule
|
|
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);
|
|
|
|
// Check if the rule has validation
|
|
let validation = match rule.syntax().validation.as_ref() {
|
|
Some(v) => v,
|
|
None => {
|
|
debug!("Rule '{}' has no validation defined, skipping", rule_id);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Extract template variables from validation and build globals
|
|
let template_vars = extract_validation_vars(validation);
|
|
|
|
// Check if --arg values can be assigned to this rule's variables
|
|
let non_token_vars: Vec<&String> = template_vars.iter().filter(|v| *v != "TOKEN").collect();
|
|
|
|
// If more --arg values than variables, skip this rule when trying multiple rules
|
|
if args.args.len() > non_token_vars.len() {
|
|
if num_matching_rules > 1 {
|
|
debug!(
|
|
"Rule '{}' expects {} variable(s) but {} --arg value(s) provided, skipping",
|
|
rule_id,
|
|
non_token_vars.len(),
|
|
args.args.len()
|
|
);
|
|
continue;
|
|
} else {
|
|
// Single rule match - give a clear error
|
|
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(&secret, &args.args, &args.variables, &template_vars)?;
|
|
|
|
// Log auto-assignment info for debugging
|
|
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
|
|
);
|
|
}
|
|
|
|
// Check for missing variables and provide helpful error messages
|
|
let missing_vars: Vec<&String> =
|
|
template_vars.iter().filter(|var| globals.get(var.as_str()).is_none()).collect();
|
|
|
|
if !missing_vars.is_empty() {
|
|
// Build a map from variable name to the rule it depends on
|
|
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() {
|
|
if should_rate_limit_validation(validation) {
|
|
limiter.wait_for_rule(&rule_id).await;
|
|
}
|
|
}
|
|
|
|
// Execute validation based on type
|
|
let mut result = match validation {
|
|
Validation::Http(http_validation) => {
|
|
execute_http_validation(
|
|
http_validation,
|
|
&globals,
|
|
&client,
|
|
&parser,
|
|
timeout,
|
|
args.retries,
|
|
global_args.allow_internal_ips,
|
|
)
|
|
.await?
|
|
}
|
|
Validation::Grpc(grpc_validation_cfg) => {
|
|
execute_grpc_validation(
|
|
grpc_validation_cfg,
|
|
&globals,
|
|
&parser,
|
|
timeout,
|
|
global_args.allow_internal_ips,
|
|
)
|
|
.await?
|
|
}
|
|
|
|
Validation::AWS => {
|
|
// AWS needs AKID and TOKEN (secret access key)
|
|
let akid = get_global_var(&globals, "AKID")
|
|
.or_else(|| get_global_var(&globals, "ACCESS_KEY_ID"))
|
|
.ok_or_else(|| anyhow!(
|
|
"AWS validation requires AKID variable. Use: --var AKID=<access_key_id> <secret_access_key>"
|
|
))?;
|
|
|
|
match validate_aws_credentials(&akid, &secret).await {
|
|
Ok((is_valid, message)) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid,
|
|
status_code: None,
|
|
message,
|
|
},
|
|
Err(e) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: format!("AWS validation error: {}", e),
|
|
},
|
|
}
|
|
}
|
|
|
|
Validation::GCP => {
|
|
// GCP expects the full service account JSON as the secret
|
|
match GcpValidator::new() {
|
|
Ok(validator) => {
|
|
match validator.validate_gcp_credentials(secret.as_bytes()).await {
|
|
Ok((is_valid, metadata)) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid,
|
|
status_code: None,
|
|
message: if metadata.is_empty() {
|
|
"GCP credential validation completed".to_string()
|
|
} else {
|
|
metadata.join(", ")
|
|
},
|
|
},
|
|
Err(e) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: format!("GCP validation error: {}", e),
|
|
},
|
|
}
|
|
}
|
|
Err(e) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: format!("Failed to initialize GCP validator: {}", e),
|
|
},
|
|
}
|
|
}
|
|
|
|
Validation::MongoDB => {
|
|
// MongoDB expects a connection URI as the secret
|
|
match validate_mongodb(&secret, use_lax_tls).await {
|
|
Ok((is_valid, message)) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid,
|
|
status_code: None,
|
|
message,
|
|
},
|
|
Err(e) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: format!("MongoDB validation error: {}", e),
|
|
},
|
|
}
|
|
}
|
|
|
|
Validation::MySQL => {
|
|
// MySQL expects a connection URL as the secret
|
|
match validate_mysql(&secret, use_lax_tls).await {
|
|
Ok((is_valid, metadata)) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid,
|
|
status_code: None,
|
|
message: if metadata.is_empty() {
|
|
"MySQL validation completed".to_string()
|
|
} else {
|
|
metadata.join(", ")
|
|
},
|
|
},
|
|
Err(e) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: format!("MySQL validation error: {}", e),
|
|
},
|
|
}
|
|
}
|
|
|
|
Validation::Postgres => {
|
|
// Postgres expects a connection URL as the secret
|
|
match validate_postgres(&secret, use_lax_tls).await {
|
|
Ok((is_valid, metadata)) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid,
|
|
status_code: None,
|
|
message: if metadata.is_empty() {
|
|
"Postgres validation completed".to_string()
|
|
} else {
|
|
metadata.join(", ")
|
|
},
|
|
},
|
|
Err(e) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: format!("Postgres validation error: {}", e),
|
|
},
|
|
}
|
|
}
|
|
|
|
Validation::Jdbc => {
|
|
// JDBC expects a JDBC connection string as the secret
|
|
match validate_jdbc(&secret, use_lax_tls).await {
|
|
Ok(outcome) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: outcome.valid,
|
|
status_code: Some(outcome.status.as_u16()),
|
|
message: outcome.message,
|
|
},
|
|
Err(e) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: format!("JDBC validation error: {}", e),
|
|
},
|
|
}
|
|
}
|
|
|
|
Validation::JWT => {
|
|
// JWT expects a JWT token as the secret
|
|
match validate_jwt(&secret, use_lax_tls, global_args.allow_internal_ips).await {
|
|
Ok((is_valid, message)) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid,
|
|
status_code: None,
|
|
message,
|
|
},
|
|
Err(e) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: format!("JWT validation error: {}", e),
|
|
},
|
|
}
|
|
}
|
|
|
|
Validation::AzureStorage => {
|
|
// Azure Storage expects JSON with storage_account and storage_key
|
|
// Or use --var AZURENAME=xxx (or STORAGE_ACCOUNT for backward compat) and pass the storage key as the secret
|
|
let azure_json = if secret.starts_with('{') {
|
|
// Secret is already JSON
|
|
secret.clone()
|
|
} else {
|
|
// Build JSON from variables
|
|
// AZURENAME matches the depends_on_rule variable in azurestorage.yml
|
|
// STORAGE_ACCOUNT is kept for backward compatibility
|
|
let storage_account = get_global_var(&globals, "AZURENAME")
|
|
.or_else(|| get_global_var(&globals, "STORAGE_ACCOUNT"))
|
|
.ok_or_else(|| anyhow!(
|
|
"Azure Storage validation requires either JSON input or --var AZURENAME=<account_name> <storage_key>"
|
|
))?;
|
|
serde_json::json!({
|
|
"storage_account": storage_account,
|
|
"storage_key": secret
|
|
})
|
|
.to_string()
|
|
};
|
|
|
|
let cache: Arc<SkipMap<String, crate::validation::CachedResponse>> =
|
|
Arc::new(SkipMap::new());
|
|
match validate_azure_storage_credentials(&azure_json, &cache).await {
|
|
Ok((is_valid, body)) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid,
|
|
status_code: None,
|
|
message: validation_body::clone_as_string(&body),
|
|
},
|
|
Err(e) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: format!("Azure Storage validation error: {}", e),
|
|
},
|
|
}
|
|
}
|
|
|
|
Validation::Coinbase => {
|
|
// Coinbase needs credential name and private key PEM
|
|
let cred_name = get_global_var(&globals, "CRED_NAME")
|
|
.or_else(|| get_global_var(&globals, "KEY_ID"))
|
|
.ok_or_else(|| anyhow!(
|
|
"Coinbase validation requires CRED_NAME variable. Use: --var CRED_NAME=<key_id> <private_key_pem>"
|
|
))?;
|
|
|
|
let cache: Arc<SkipMap<String, crate::validation::CachedResponse>> =
|
|
Arc::new(SkipMap::new());
|
|
match validate_cdp_api_key(&cred_name, &secret, &client, &parser, &cache).await {
|
|
Ok((is_valid, body)) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid,
|
|
status_code: None,
|
|
message: validation_body::clone_as_string(&body),
|
|
},
|
|
Err(e) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: format!("Coinbase validation error: {}", e),
|
|
},
|
|
}
|
|
}
|
|
|
|
Validation::Raw(_) => DirectValidationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
is_valid: false,
|
|
status_code: None,
|
|
message: "Raw validation type is not supported via direct validation.".to_string(),
|
|
},
|
|
};
|
|
|
|
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)
|
|
}
|
|
|
|
/// Create minimal scan args for rule loading.
|
|
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::RuleSpecifierArgs,
|
|
scan::{ConfidenceLevel, ScanArgs},
|
|
};
|
|
use url::Url;
|
|
|
|
ScanArgs {
|
|
num_jobs: 1,
|
|
rules: RuleSpecifierArgs {
|
|
rules_path: Vec::new(),
|
|
rule: vec!["all".into()],
|
|
load_builtins: true,
|
|
},
|
|
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_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,
|
|
gitlab_user: Vec::new(),
|
|
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_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_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(),
|
|
docker_image: 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, // Load all rules regardless of confidence
|
|
no_validate: true,
|
|
access_map: false,
|
|
rule_stats: false,
|
|
only_valid: false,
|
|
min_entropy: None,
|
|
redact: false,
|
|
git_repo_timeout: 1800,
|
|
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(),
|
|
validation_timeout: 10,
|
|
validation_retries: 1,
|
|
validation_rps: None,
|
|
validation_rps_rule: Vec::new(),
|
|
full_validation_response: false,
|
|
max_validation_response_length: 2048,
|
|
}
|
|
}
|
|
|
|
/// Print validation results to stdout.
|
|
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!(); // Separator between results
|
|
}
|
|
|
|
let valid_str = 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if any result is valid.
|
|
pub fn any_valid(results: &[DirectValidationResult]) -> bool {
|
|
results.iter().any(|r| r.is_valid)
|
|
}
|