forked from mirrors/kingfisher
Some checks are pending
ClusterFuzzLite PR fuzzing / PR (address) (pull_request) Waiting to run
CI Pull Request / Linux x64 (pull_request) Waiting to run
CI Pull Request / Linux arm64 (pull_request) Waiting to run
CI Pull Request / macOS arm64 (pull_request) Waiting to run
CI Pull Request / Windows arm64 (pull_request) Waiting to run
CI Pull Request / Windows x64 (pull_request) Waiting to run
When scanning a self-hosted Gitea/Forgejo instance, the API may be
reachable at a different hostname than the git clone endpoint (e.g.,
internal API vs. public clone URL behind a reverse proxy). The
--clone-url-base flag rewrites the scheme, host, and port of clone
URLs returned by the API, preserving the path.
Example:
kingfisher scan gitea \
--api-url https://forge.internal.example.com/api/v1/ \
--clone-url-base https://forge.internal.example.com/ \
--user eblume
This avoids routing clone traffic through an external proxy when the
API and git endpoints share the same internal host but the instance's
ROOT_URL points to the public endpoint.
Includes unit tests for the URL rewriting function and an integration
test using wiremock to verify the full enumeration path.
1060 lines
39 KiB
Rust
1060 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_clone_url_base: None,
|
|
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)
|
|
}
|