forked from mirrors/kingfisher
1350 lines
48 KiB
Rust
1350 lines
48 KiB
Rust
//! Direct secret revocation without pattern matching.
|
|
//!
|
|
//! This module provides functionality to revoke a known secret directly against
|
|
//! a rule's revocation configuration, bypassing the normal pattern-matching phase.
|
|
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet},
|
|
io::{self, Read},
|
|
time::Duration,
|
|
};
|
|
|
|
use anyhow::{anyhow, bail, Context, Result};
|
|
use liquid::Object;
|
|
use liquid_core::{Value, ValueView};
|
|
use regex::Regex;
|
|
use reqwest::Client;
|
|
use serde::Serialize;
|
|
use tracing::debug;
|
|
|
|
use crate::{
|
|
cli::{commands::revoke::RevokeArgs, global::GlobalArgs},
|
|
liquid_filters::register_all,
|
|
rule_loader::RuleLoader,
|
|
template_vars::extract_template_vars,
|
|
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,
|
|
};
|
|
|
|
use kingfisher_rules::{
|
|
HttpMultiStepRevocation, HttpValidation, ResponseExtractor, Revocation, RevocationStep, Rule,
|
|
};
|
|
|
|
/// Result of a direct revocation attempt.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct DirectRevocationResult {
|
|
/// The rule ID that was used for revocation.
|
|
pub rule_id: String,
|
|
/// The rule name.
|
|
pub rule_name: String,
|
|
/// Whether the secret was revoked successfully.
|
|
pub revoked: bool,
|
|
/// HTTP status code from the revocation 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();
|
|
|
|
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 {
|
|
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 !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 all template variables used in a revocation configuration.
|
|
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 {
|
|
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));
|
|
}
|
|
}
|
|
Revocation::HttpMultiStep(multi_step) => {
|
|
// Extract variables from all steps
|
|
// Note: Variables extracted in step 1 are available in step 2,
|
|
// but we only track initial input variables here
|
|
for step in &multi_step.steps {
|
|
vars.extend(extract_template_vars(&step.request.url));
|
|
for (key, value) in &step.request.headers {
|
|
vars.extend(extract_template_vars(key));
|
|
vars.extend(extract_template_vars(value));
|
|
}
|
|
if let Some(body) = &step.request.body {
|
|
vars.extend(extract_template_vars(body));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
args: &[String],
|
|
variables: &[String],
|
|
template_vars: &BTreeSet<String>,
|
|
) -> Result<Object> {
|
|
let mut globals = Object::new();
|
|
globals.insert("TOKEN".into(), Value::scalar(secret.to_string()));
|
|
|
|
let auto_assign_vars: Vec<&String> = template_vars.iter().filter(|v| *v != "TOKEN").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));
|
|
}
|
|
|
|
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("-") => {
|
|
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 revocation 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))
|
|
}
|
|
|
|
/// Render Liquid templates within an extractor's string fields.
|
|
///
|
|
/// This allows extraction patterns/paths to use `{{ TOKEN | prefix: 8 }}` etc.
|
|
/// so that multi-step revocations can locate the correct item in a list response.
|
|
fn render_extractor(
|
|
extractor: &ResponseExtractor,
|
|
parser: &liquid::Parser,
|
|
globals: &Object,
|
|
) -> Result<ResponseExtractor> {
|
|
let render = |template_str: &str| -> Result<String> {
|
|
if !template_str.contains("{{") && !template_str.contains("{%") {
|
|
return Ok(template_str.to_string());
|
|
}
|
|
let template = parser
|
|
.parse(template_str)
|
|
.map_err(|e| anyhow!("Failed to parse extractor template: {}", e))?;
|
|
template.render(globals).map_err(|e| anyhow!("Failed to render extractor template: {}", e))
|
|
};
|
|
|
|
match extractor {
|
|
ResponseExtractor::JsonPath { path } => {
|
|
Ok(ResponseExtractor::JsonPath { path: render(path)? })
|
|
}
|
|
ResponseExtractor::Regex { pattern } => {
|
|
Ok(ResponseExtractor::Regex { pattern: render(pattern)? })
|
|
}
|
|
ResponseExtractor::Header { name } => Ok(ResponseExtractor::Header { name: render(name)? }),
|
|
// Body and StatusCode have no string fields to render
|
|
other => Ok(other.clone()),
|
|
}
|
|
}
|
|
|
|
fn truncate_with_ellipsis(input: &str, max_chars: usize) -> String {
|
|
let truncated: String = input.chars().take(max_chars).collect();
|
|
if input.chars().count() > max_chars {
|
|
format!("{}...", truncated)
|
|
} else {
|
|
input.to_string()
|
|
}
|
|
}
|
|
|
|
/// Extract a value from an HTTP response using the specified extractor.
|
|
fn extract_value_from_response(
|
|
extractor: &ResponseExtractor,
|
|
body: &str,
|
|
headers: &reqwest::header::HeaderMap,
|
|
status: &reqwest::StatusCode,
|
|
) -> Result<String> {
|
|
match extractor {
|
|
ResponseExtractor::JsonPath { path } => {
|
|
let json: serde_json::Value =
|
|
serde_json::from_str(body).context("Response body is not valid JSON")?;
|
|
|
|
// Simple JSONPath implementation supporting basic paths like:
|
|
// $.field, $.field.nested, $.array[0], $.array[0].field
|
|
let path_parts: Vec<&str> = path.trim_start_matches("$.").split('.').collect();
|
|
|
|
let mut current = &json;
|
|
for part in path_parts {
|
|
if let Some((array_name, index_str)) = part.split_once('[') {
|
|
let index: usize =
|
|
index_str.trim_end_matches(']').parse().context("Invalid array index")?;
|
|
|
|
if !array_name.is_empty() {
|
|
current = current
|
|
.get(array_name)
|
|
.ok_or_else(|| anyhow!("Field '{}' not found", array_name))?;
|
|
}
|
|
|
|
current = current
|
|
.get(index)
|
|
.ok_or_else(|| anyhow!("Array index {} not found", index))?;
|
|
} else {
|
|
current =
|
|
current.get(part).ok_or_else(|| anyhow!("Field '{}' not found", part))?;
|
|
}
|
|
}
|
|
|
|
match current {
|
|
serde_json::Value::String(s) => Ok(s.clone()),
|
|
serde_json::Value::Number(n) => Ok(n.to_string()),
|
|
serde_json::Value::Bool(b) => Ok(b.to_string()),
|
|
_ => Ok(current.to_string()),
|
|
}
|
|
}
|
|
ResponseExtractor::Regex { pattern } => {
|
|
let re = Regex::new(pattern).context(format!("Invalid regex pattern: {}", pattern))?;
|
|
let caps = re
|
|
.captures(body)
|
|
.ok_or_else(|| anyhow!("Regex pattern did not match response body"))?;
|
|
|
|
caps.get(1)
|
|
.map(|m| m.as_str().to_string())
|
|
.ok_or_else(|| anyhow!("No capture group found in regex pattern"))
|
|
}
|
|
ResponseExtractor::Header { name } => headers
|
|
.get(name)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string())
|
|
.ok_or_else(|| anyhow!("Header '{}' not found in response", name)),
|
|
ResponseExtractor::Body => Ok(body.to_string()),
|
|
ResponseExtractor::StatusCode => Ok(status.as_u16().to_string()),
|
|
}
|
|
}
|
|
|
|
/// Execute HTTP revocation against the provided rule.
|
|
async fn execute_http_revocation(
|
|
http_revocation: &HttpValidation,
|
|
globals: &Object,
|
|
client: &Client,
|
|
parser: &liquid::Parser,
|
|
timeout: Duration,
|
|
retries: u32,
|
|
) -> Result<DirectRevocationResult> {
|
|
let url = render_and_parse_url(parser, globals, &http_revocation.request.url).await?;
|
|
|
|
debug!("Revoking against URL: {}", url);
|
|
|
|
let request_builder = build_request_builder(
|
|
client,
|
|
&http_revocation.request.method,
|
|
&url,
|
|
&http_revocation.request.headers,
|
|
&http_revocation.request.body,
|
|
timeout,
|
|
parser,
|
|
globals,
|
|
)
|
|
.map_err(|e| anyhow!("Failed to build request: {}", e))?;
|
|
|
|
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.context("Failed to read response body")?;
|
|
|
|
let display_body = truncate_with_ellipsis(&body, 500);
|
|
let body_len = body.chars().count();
|
|
|
|
debug!("Revocation response status: {}", status);
|
|
debug!("Revocation response body (len={}): {}", body_len, display_body);
|
|
|
|
let matchers = http_revocation
|
|
.request
|
|
.response_matcher
|
|
.as_deref()
|
|
.ok_or_else(|| anyhow!("Revocation response_matcher is required"))?;
|
|
let html_allowed = http_revocation.request.response_is_html;
|
|
let revoked = validate_response(matchers, &body, &status, &headers, html_allowed);
|
|
|
|
Ok(DirectRevocationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
revoked,
|
|
status_code: Some(status.as_u16()),
|
|
message: display_body,
|
|
})
|
|
}
|
|
|
|
/// Execute a single revocation step and extract variables from the response.
|
|
async fn execute_revocation_step(
|
|
step: &RevocationStep,
|
|
globals: &mut Object,
|
|
client: &Client,
|
|
parser: &liquid::Parser,
|
|
timeout: Duration,
|
|
retries: u32,
|
|
step_number: usize,
|
|
) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String)> {
|
|
let default_step_name = format!("step_{}", step_number);
|
|
let step_name = step.name.as_ref().map(|s| s.as_str()).unwrap_or(&default_step_name);
|
|
|
|
debug!("Executing revocation step {}: {}", step_number, step_name);
|
|
|
|
let url = render_and_parse_url(parser, globals, &step.request.url).await?;
|
|
debug!("Step {} URL: {}", step_number, url);
|
|
|
|
let request_builder = build_request_builder(
|
|
client,
|
|
&step.request.method,
|
|
&url,
|
|
&step.request.headers,
|
|
&step.request.body,
|
|
timeout,
|
|
parser,
|
|
globals,
|
|
)
|
|
.map_err(|e| anyhow!("Failed to build request for {}: {}", step_name, e))?;
|
|
|
|
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 for {}: {}", step_name, e))?;
|
|
|
|
let status = response.status();
|
|
let headers = response.headers().clone();
|
|
let body = response
|
|
.text()
|
|
.await
|
|
.with_context(|| format!("Failed to read response body for {}", step_name))?;
|
|
|
|
let display_body = truncate_with_ellipsis(&body, 500);
|
|
let body_len = body.chars().count();
|
|
|
|
debug!("Step {} response status: {}", step_number, status);
|
|
debug!("Step {} response body (len={}): {}", step_number, body_len, display_body);
|
|
|
|
// Extract variables from the response if configured
|
|
if let Some(extractors) = &step.extract {
|
|
debug!("Extracting {} variable(s) from step {} response", extractors.len(), step_number);
|
|
|
|
for (var_name, extractor) in extractors {
|
|
// Render any Liquid templates in the extractor (e.g., {{ TOKEN | prefix: 8 }})
|
|
let rendered_extractor =
|
|
render_extractor(extractor, parser, globals).with_context(|| {
|
|
format!(
|
|
"Failed to render extractor template for '{}' in step {}",
|
|
var_name, step_number
|
|
)
|
|
})?;
|
|
debug!(
|
|
"Step {}: Rendered extractor for '{}': {:?}",
|
|
step_number, var_name, rendered_extractor
|
|
);
|
|
|
|
match extract_value_from_response(&rendered_extractor, &body, &headers, &status) {
|
|
Ok(value) => {
|
|
debug!("Step {}: Extracted variable {} = '{}'", step_number, var_name, value);
|
|
globals.insert(var_name.to_uppercase().into(), Value::scalar(value));
|
|
}
|
|
Err(e) => {
|
|
return Err(anyhow!(
|
|
"Failed to extract variable '{}' in step {}: {}\nResponse status: {}\nResponse body: {}",
|
|
var_name,
|
|
step_number,
|
|
e,
|
|
status,
|
|
display_body
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok((status, headers, body))
|
|
}
|
|
|
|
/// Execute multi-step HTTP revocation.
|
|
async fn execute_multi_step_revocation(
|
|
multi_step: &HttpMultiStepRevocation,
|
|
globals: &mut Object,
|
|
client: &Client,
|
|
parser: &liquid::Parser,
|
|
timeout: Duration,
|
|
retries: u32,
|
|
) -> Result<DirectRevocationResult> {
|
|
if multi_step.steps.is_empty() {
|
|
bail!("Multi-step revocation must have at least one step");
|
|
}
|
|
|
|
if multi_step.steps.len() > 2 {
|
|
bail!(
|
|
"Multi-step revocation supports a maximum of 2 steps, got {}",
|
|
multi_step.steps.len()
|
|
);
|
|
}
|
|
|
|
let num_steps = multi_step.steps.len();
|
|
debug!("Executing {}-step revocation", num_steps);
|
|
|
|
// Execute each step sequentially
|
|
for (i, step) in multi_step.steps.iter().enumerate() {
|
|
let step_number = i + 1;
|
|
let is_final_step = step_number == num_steps;
|
|
|
|
let (status, headers, body) =
|
|
execute_revocation_step(step, globals, client, parser, timeout, retries, step_number)
|
|
.await?;
|
|
|
|
if is_final_step {
|
|
// Final step: validate response to determine success
|
|
let display_body = truncate_with_ellipsis(&body, 500);
|
|
|
|
let matchers = step
|
|
.request
|
|
.response_matcher
|
|
.as_deref()
|
|
.ok_or_else(|| anyhow!("Final revocation step must have response_matcher"))?;
|
|
|
|
let html_allowed = step.request.response_is_html;
|
|
let revoked = validate_response(matchers, &body, &status, &headers, html_allowed);
|
|
|
|
return Ok(DirectRevocationResult {
|
|
rule_id: String::new(),
|
|
rule_name: String::new(),
|
|
revoked,
|
|
status_code: Some(status.as_u16()),
|
|
message: display_body,
|
|
});
|
|
} else {
|
|
// Intermediate step: just log the response
|
|
debug!("Step {} completed with status {}", step_number, status);
|
|
}
|
|
}
|
|
|
|
// This should never happen due to the checks above, but keep for safety
|
|
Err(anyhow!("Multi-step revocation did not complete"))
|
|
}
|
|
|
|
/// Run direct revocation of a secret against one or more rules.
|
|
pub async fn run_direct_revocation(
|
|
args: &RevokeArgs,
|
|
global_args: &GlobalArgs,
|
|
) -> Result<Vec<DirectRevocationResult>> {
|
|
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 = crate::direct_validate::create_minimal_scan_args();
|
|
let loaded = loader.load(&scan_args)?;
|
|
|
|
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);
|
|
}
|
|
|
|
let client = Client::builder()
|
|
.danger_accept_invalid_certs(global_args.ignore_certs)
|
|
.timeout(Duration::from_secs(args.timeout))
|
|
.user_agent(GLOBAL_USER_AGENT.as_str())
|
|
.gzip(true)
|
|
.deflate(true)
|
|
.brotli(true)
|
|
.build()
|
|
.context("Failed to build HTTP client")?;
|
|
|
|
let parser = register_all(liquid::ParserBuilder::with_stdlib()).build()?;
|
|
let timeout = Duration::from_secs(args.timeout);
|
|
|
|
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);
|
|
|
|
let revocation = match rule.syntax().revocation.as_ref() {
|
|
Some(v) => v,
|
|
None => {
|
|
debug!("Rule '{}' has no revocation defined, skipping", rule_id);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let template_vars = extract_revocation_vars(revocation);
|
|
let non_token_vars: Vec<&String> = template_vars.iter().filter(|v| *v != "TOKEN").collect();
|
|
|
|
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 {
|
|
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)?;
|
|
|
|
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 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,
|
|
&globals,
|
|
&client,
|
|
&parser,
|
|
timeout,
|
|
args.retries,
|
|
)
|
|
.await?
|
|
}
|
|
Revocation::HttpMultiStep(multi_step) => {
|
|
let mut globals_mut = globals.clone();
|
|
execute_multi_step_revocation(
|
|
multi_step,
|
|
&mut globals_mut,
|
|
&client,
|
|
&parser,
|
|
timeout,
|
|
args.retries,
|
|
)
|
|
.await?
|
|
}
|
|
};
|
|
|
|
result.rule_id = rule_id;
|
|
result.rule_name = rule_name;
|
|
results.push(result);
|
|
}
|
|
|
|
if results.is_empty() {
|
|
bail!(
|
|
"No rules with revocation found matching '{}'. \
|
|
Use `kingfisher rules list` to see available rules.",
|
|
args.rule
|
|
);
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Print revocation results to stdout.
|
|
pub fn print_results(results: &[DirectRevocationResult], 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 revoked_str = if result.revoked {
|
|
if use_color {
|
|
"\x1b[32m✓ REVOKED\x1b[0m"
|
|
} else {
|
|
"REVOKED"
|
|
}
|
|
} else if use_color {
|
|
"\x1b[31m✗ FAILED\x1b[0m"
|
|
} else {
|
|
"FAILED"
|
|
};
|
|
|
|
println!("Rule: {} ({})", result.rule_name, result.rule_id);
|
|
println!("Result: {}", revoked_str);
|
|
if let Some(status) = result.status_code {
|
|
println!("Status: {}", status);
|
|
}
|
|
if !result.message.is_empty() {
|
|
println!("Response: {}", result.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if any result was revoked.
|
|
pub fn any_revoked(results: &[DirectRevocationResult]) -> bool {
|
|
results.iter().any(|r| r.revoked)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use kingfisher_rules::{HttpValidation, ResponseExtractor, Revocation};
|
|
use reqwest::header::{HeaderMap, HeaderValue};
|
|
use reqwest::StatusCode;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
// ---- extract_value_from_response: JsonPath ----
|
|
|
|
#[test]
|
|
fn jsonpath_simple_field() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.name".into() };
|
|
let body = r#"{"name":"alice"}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "alice");
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_nested_field() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.data.user.id".into() };
|
|
let body = r#"{"data":{"user":{"id":"u-123"}}}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "u-123");
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_numeric_value() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.count".into() };
|
|
let body = r#"{"count":42}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_boolean_value() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.active".into() };
|
|
let body = r#"{"active":true}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "true");
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_array_index_zero() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.items[0]".into() };
|
|
let body = r#"{"items":["first","second","third"]}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "first");
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_array_index_nested_field() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.items[0].token_id".into() };
|
|
let body = r#"{"items":[{"token_id":"tok-abc"},{"token_id":"tok-def"}]}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "tok-abc");
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_array_second_element() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.data[1].name".into() };
|
|
let body = r#"{"data":[{"name":"a"},{"name":"b"}]}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "b");
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_missing_top_level_field() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.nonexistent".into() };
|
|
let body = r#"{"name":"alice"}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
let err = result.unwrap_err();
|
|
assert!(err.to_string().contains("not found"), "Expected 'not found', got: {}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_missing_nested_field() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.data.missing.deep".into() };
|
|
let body = r#"{"data":{"other":"value"}}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_array_index_out_of_bounds() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.items[5]".into() };
|
|
let body = r#"{"items":["only","two"]}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
let err = result.unwrap_err();
|
|
assert!(err.to_string().contains("not found"), "Expected 'not found', got: {}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_invalid_json_body() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.field".into() };
|
|
let body = "not json at all";
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
let err = result.unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("not valid JSON"),
|
|
"Expected JSON parse error, got: {}",
|
|
err
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn jsonpath_object_value_returns_json_string() {
|
|
let ext = ResponseExtractor::JsonPath { path: "$.nested".into() };
|
|
let body = r#"{"nested":{"a":1,"b":2}}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
let val = result.unwrap();
|
|
// When the value is not a string/number/bool, it should be serialized as JSON
|
|
let parsed: serde_json::Value = serde_json::from_str(&val).unwrap();
|
|
assert_eq!(parsed["a"], 1);
|
|
assert_eq!(parsed["b"], 2);
|
|
}
|
|
|
|
// ---- extract_value_from_response: Regex ----
|
|
|
|
#[test]
|
|
fn regex_with_capture_group() {
|
|
let ext = ResponseExtractor::Regex { pattern: r#"token_id":\s*"([^"]+)"#.into() };
|
|
let body = r#"{"token_id": "abc-123-def"}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "abc-123-def");
|
|
}
|
|
|
|
#[test]
|
|
fn regex_no_capture_group() {
|
|
let ext = ResponseExtractor::Regex { pattern: r"token_id".into() };
|
|
let body = r#"{"token_id": "abc"}"#;
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
let err = result.unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("No capture group"),
|
|
"Expected 'No capture group', got: {}",
|
|
err
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn regex_pattern_does_not_match() {
|
|
let ext = ResponseExtractor::Regex { pattern: r"xyz_(\d+)".into() };
|
|
let body = "no match here";
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
let err = result.unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("did not match"),
|
|
"Expected 'did not match', got: {}",
|
|
err
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn regex_invalid_pattern() {
|
|
let ext = ResponseExtractor::Regex { pattern: r"[invalid".into() };
|
|
let body = "anything";
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn regex_multiple_capture_groups_uses_first() {
|
|
let ext = ResponseExtractor::Regex { pattern: r"(\w+):(\w+)".into() };
|
|
let body = "key:value";
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "key");
|
|
}
|
|
|
|
// ---- extract_value_from_response: Header ----
|
|
|
|
#[test]
|
|
fn header_extraction_found() {
|
|
let ext = ResponseExtractor::Header { name: "x-request-id".into() };
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-request-id", HeaderValue::from_static("req-456"));
|
|
let result = extract_value_from_response(&ext, "", &headers, &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "req-456");
|
|
}
|
|
|
|
#[test]
|
|
fn header_extraction_missing() {
|
|
let ext = ResponseExtractor::Header { name: "x-missing".into() };
|
|
let result = extract_value_from_response(&ext, "", &HeaderMap::new(), &StatusCode::OK);
|
|
let err = result.unwrap_err();
|
|
assert!(err.to_string().contains("not found"), "Expected 'not found', got: {}", err);
|
|
}
|
|
|
|
// ---- extract_value_from_response: Body ----
|
|
|
|
#[test]
|
|
fn body_extraction() {
|
|
let ext = ResponseExtractor::Body;
|
|
let body = "the full response body";
|
|
let result = extract_value_from_response(&ext, body, &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "the full response body");
|
|
}
|
|
|
|
#[test]
|
|
fn body_extraction_empty() {
|
|
let ext = ResponseExtractor::Body;
|
|
let result = extract_value_from_response(&ext, "", &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "");
|
|
}
|
|
|
|
// ---- extract_value_from_response: StatusCode ----
|
|
|
|
#[test]
|
|
fn status_code_extraction_200() {
|
|
let ext = ResponseExtractor::StatusCode;
|
|
let result = extract_value_from_response(&ext, "", &HeaderMap::new(), &StatusCode::OK);
|
|
assert_eq!(result.unwrap(), "200");
|
|
}
|
|
|
|
#[test]
|
|
fn status_code_extraction_404() {
|
|
let ext = ResponseExtractor::StatusCode;
|
|
let result =
|
|
extract_value_from_response(&ext, "", &HeaderMap::new(), &StatusCode::NOT_FOUND);
|
|
assert_eq!(result.unwrap(), "404");
|
|
}
|
|
|
|
#[test]
|
|
fn status_code_extraction_201() {
|
|
let ext = ResponseExtractor::StatusCode;
|
|
let result = extract_value_from_response(&ext, "", &HeaderMap::new(), &StatusCode::CREATED);
|
|
assert_eq!(result.unwrap(), "201");
|
|
}
|
|
|
|
// ---- extract_template_vars ----
|
|
|
|
#[test]
|
|
fn template_vars_basic() {
|
|
let vars = extract_template_vars("https://api.example.com/{{ TOKEN }}/revoke");
|
|
assert!(vars.contains("TOKEN"));
|
|
assert_eq!(vars.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn template_vars_multiple() {
|
|
let vars = extract_template_vars(
|
|
"https://api.example.com/{{ AKID }}/keys/{{ KEY_ID }}?token={{ TOKEN }}",
|
|
);
|
|
assert!(vars.contains("AKID"));
|
|
assert!(vars.contains("KEY_ID"));
|
|
assert!(vars.contains("TOKEN"));
|
|
assert_eq!(vars.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn template_vars_with_filters() {
|
|
let vars = extract_template_vars("{{ TOKEN | base64_encode }}");
|
|
assert!(vars.contains("TOKEN"));
|
|
assert_eq!(vars.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn template_vars_no_vars() {
|
|
let vars = extract_template_vars("https://api.example.com/revoke");
|
|
assert!(vars.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn template_vars_case_normalization() {
|
|
// Variables are uppercased on extraction
|
|
let vars = extract_template_vars("{{ token }}");
|
|
assert!(vars.contains("TOKEN"));
|
|
}
|
|
|
|
// ---- build_globals ----
|
|
|
|
#[test]
|
|
fn build_globals_sets_token() {
|
|
let template_vars = BTreeSet::from(["TOKEN".to_string()]);
|
|
let globals = build_globals("my-secret", &[], &[], &template_vars).unwrap();
|
|
assert_eq!(globals.get("TOKEN"), Some(Value::scalar("my-secret".to_string())).as_ref());
|
|
}
|
|
|
|
#[test]
|
|
fn build_globals_auto_assigns_args() {
|
|
let template_vars =
|
|
BTreeSet::from(["TOKEN".to_string(), "AKID".to_string(), "REGION".to_string()]);
|
|
let args = vec!["my-akid".to_string(), "us-east-1".to_string()];
|
|
let globals = build_globals("secret", &args, &[], &template_vars).unwrap();
|
|
|
|
assert_eq!(globals.get("TOKEN"), Some(Value::scalar("secret".to_string())).as_ref());
|
|
assert_eq!(globals.get("AKID"), Some(Value::scalar("my-akid".to_string())).as_ref());
|
|
assert_eq!(globals.get("REGION"), Some(Value::scalar("us-east-1".to_string())).as_ref());
|
|
}
|
|
|
|
#[test]
|
|
fn build_globals_explicit_variables() {
|
|
let template_vars = BTreeSet::from(["TOKEN".to_string(), "AKID".to_string()]);
|
|
let vars = vec!["AKID=explicit-value".to_string()];
|
|
let globals = build_globals("secret", &[], &vars, &template_vars).unwrap();
|
|
|
|
assert_eq!(globals.get("AKID"), Some(Value::scalar("explicit-value".to_string())).as_ref());
|
|
}
|
|
|
|
#[test]
|
|
fn build_globals_invalid_var_format() {
|
|
let template_vars = BTreeSet::new();
|
|
let vars = vec!["NO_EQUALS_SIGN".to_string()];
|
|
let result = build_globals("secret", &[], &vars, &template_vars);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("Expected NAME=VALUE"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_globals_empty_var_name() {
|
|
let template_vars = BTreeSet::new();
|
|
let vars = vec!["=value".to_string()];
|
|
let result = build_globals("secret", &[], &vars, &template_vars);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
|
|
}
|
|
|
|
// ---- extract_revocation_vars ----
|
|
|
|
#[test]
|
|
fn extract_revocation_vars_aws() {
|
|
let vars = extract_revocation_vars(&Revocation::AWS);
|
|
assert!(vars.contains("AKID"));
|
|
assert!(vars.contains("TOKEN"));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_revocation_vars_gcp() {
|
|
let vars = extract_revocation_vars(&Revocation::GCP);
|
|
assert!(vars.contains("TOKEN"));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_revocation_vars_http() {
|
|
use kingfisher_rules::HttpRequest;
|
|
|
|
let http = HttpValidation {
|
|
request: HttpRequest {
|
|
method: "DELETE".into(),
|
|
url: "https://api.example.com/{{ AKID }}/{{ TOKEN }}".into(),
|
|
headers: BTreeMap::from([("Authorization".into(), "Bearer {{ TOKEN }}".into())]),
|
|
body: Some(r#"{"key":"{{ KEY_ID }}"}"#.into()),
|
|
response_matcher: None,
|
|
multipart: None,
|
|
response_is_html: false,
|
|
},
|
|
multipart: None,
|
|
};
|
|
let vars = extract_revocation_vars(&Revocation::Http(http));
|
|
assert!(vars.contains("AKID"));
|
|
assert!(vars.contains("TOKEN"));
|
|
assert!(vars.contains("KEY_ID"));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_revocation_vars_multi_step() {
|
|
use kingfisher_rules::{HttpMultiStepRevocation, HttpRequest, RevocationStep};
|
|
|
|
let multi = HttpMultiStepRevocation {
|
|
steps: vec![
|
|
RevocationStep {
|
|
name: Some("lookup".into()),
|
|
request: HttpRequest {
|
|
method: "GET".into(),
|
|
url: "https://api.example.com/{{ TOKEN }}/info".into(),
|
|
headers: BTreeMap::new(),
|
|
body: None,
|
|
response_matcher: None,
|
|
multipart: None,
|
|
response_is_html: false,
|
|
},
|
|
multipart: None,
|
|
extract: None,
|
|
},
|
|
RevocationStep {
|
|
name: Some("delete".into()),
|
|
request: HttpRequest {
|
|
method: "DELETE".into(),
|
|
url: "https://api.example.com/{{ KEY_ID }}".into(),
|
|
headers: BTreeMap::from([("X-Api-Key".into(), "{{ API_KEY }}".into())]),
|
|
body: None,
|
|
response_matcher: None,
|
|
multipart: None,
|
|
response_is_html: false,
|
|
},
|
|
multipart: None,
|
|
extract: None,
|
|
},
|
|
],
|
|
};
|
|
let vars = extract_revocation_vars(&Revocation::HttpMultiStep(multi));
|
|
assert!(vars.contains("TOKEN"));
|
|
assert!(vars.contains("KEY_ID"));
|
|
assert!(vars.contains("API_KEY"));
|
|
}
|
|
|
|
// ---- find_rules_by_selector ----
|
|
|
|
fn make_test_rule(id: &str, name: &str) -> Rule {
|
|
Rule::new(kingfisher_rules::RuleSyntax {
|
|
name: name.to_string(),
|
|
id: id.to_string(),
|
|
pattern: r"\btest\b".to_string(),
|
|
min_entropy: 0.0,
|
|
confidence: Default::default(),
|
|
visible: true,
|
|
examples: vec![],
|
|
negative_examples: vec![],
|
|
references: vec![],
|
|
validation: None,
|
|
revocation: None,
|
|
depends_on_rule: vec![],
|
|
pattern_requirements: None,
|
|
tls_mode: None,
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn find_rules_exact_match() {
|
|
let mut rules = BTreeMap::new();
|
|
rules.insert(
|
|
"kingfisher.github.1".into(),
|
|
make_test_rule("kingfisher.github.1", "GitHub Token"),
|
|
);
|
|
rules.insert(
|
|
"kingfisher.gitlab.1".into(),
|
|
make_test_rule("kingfisher.gitlab.1", "GitLab Token"),
|
|
);
|
|
|
|
let matched = find_rules_by_selector("kingfisher.github.1", &rules).unwrap();
|
|
assert_eq!(matched.len(), 1);
|
|
assert_eq!(matched[0].id(), "kingfisher.github.1");
|
|
}
|
|
|
|
#[test]
|
|
fn find_rules_prefix_match() {
|
|
let mut rules = BTreeMap::new();
|
|
rules.insert(
|
|
"kingfisher.github.1".into(),
|
|
make_test_rule("kingfisher.github.1", "GitHub PAT"),
|
|
);
|
|
rules.insert(
|
|
"kingfisher.github.2".into(),
|
|
make_test_rule("kingfisher.github.2", "GitHub App"),
|
|
);
|
|
rules.insert(
|
|
"kingfisher.gitlab.1".into(),
|
|
make_test_rule("kingfisher.gitlab.1", "GitLab Token"),
|
|
);
|
|
|
|
let matched = find_rules_by_selector("kingfisher.github", &rules).unwrap();
|
|
assert_eq!(matched.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn find_rules_auto_prefix_kingfisher() {
|
|
let mut rules = BTreeMap::new();
|
|
rules.insert(
|
|
"kingfisher.github.1".into(),
|
|
make_test_rule("kingfisher.github.1", "GitHub Token"),
|
|
);
|
|
|
|
// Searching without "kingfisher." prefix should still find the rule
|
|
let matched = find_rules_by_selector("github.1", &rules).unwrap();
|
|
assert_eq!(matched.len(), 1);
|
|
assert_eq!(matched[0].id(), "kingfisher.github.1");
|
|
}
|
|
|
|
#[test]
|
|
fn find_rules_no_match() {
|
|
let mut rules = BTreeMap::new();
|
|
rules.insert(
|
|
"kingfisher.github.1".into(),
|
|
make_test_rule("kingfisher.github.1", "GitHub Token"),
|
|
);
|
|
|
|
let result = find_rules_by_selector("nonexistent", &rules);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("No rule found"));
|
|
}
|
|
|
|
#[test]
|
|
fn find_rules_prefix_boundary() {
|
|
// "kingfisher.git" should NOT match "kingfisher.github.1" because
|
|
// "github" does not start after a '.' boundary following "git"
|
|
let mut rules = BTreeMap::new();
|
|
rules.insert(
|
|
"kingfisher.github.1".into(),
|
|
make_test_rule("kingfisher.github.1", "GitHub Token"),
|
|
);
|
|
|
|
let result = find_rules_by_selector("kingfisher.git", &rules);
|
|
assert!(result.is_err(), "Prefix 'kingfisher.git' should not match 'kingfisher.github.1'");
|
|
}
|
|
|
|
// ---- render_extractor ----
|
|
|
|
#[test]
|
|
fn render_extractor_renders_liquid_in_regex() {
|
|
let parser = crate::liquid_filters::register_all(liquid::ParserBuilder::with_stdlib())
|
|
.build()
|
|
.unwrap();
|
|
let mut globals = Object::new();
|
|
// kingfisher:ignore (test fixture, not a real token)
|
|
globals.insert(
|
|
"TOKEN".into(),
|
|
Value::scalar("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".to_string()),
|
|
);
|
|
|
|
let extractor = ResponseExtractor::Regex {
|
|
pattern: r#""key":"([^"]+)","token":"{{ TOKEN | prefix: 8 }}"#.to_string(),
|
|
};
|
|
|
|
let rendered = render_extractor(&extractor, &parser, &globals).unwrap();
|
|
match rendered {
|
|
ResponseExtractor::Regex { pattern } => {
|
|
assert_eq!(pattern, r#""key":"([^"]+)","token":"npm_rmll"#);
|
|
}
|
|
_ => panic!("Expected Regex variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn render_extractor_regex_matches_correct_token_in_npm_response() {
|
|
let parser = crate::liquid_filters::register_all(liquid::ParserBuilder::with_stdlib())
|
|
.build()
|
|
.unwrap();
|
|
let mut globals = Object::new();
|
|
// kingfisher:ignore (test fixture, not a real token)
|
|
globals.insert(
|
|
"TOKEN".into(),
|
|
Value::scalar("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".to_string()),
|
|
);
|
|
|
|
let extractor = ResponseExtractor::Regex {
|
|
pattern: r#""key":"([^"]+)","token":"{{ TOKEN | prefix: 8 }}"#.to_string(),
|
|
};
|
|
let rendered = render_extractor(&extractor, &parser, &globals).unwrap();
|
|
|
|
// Simulated npm API response with multiple tokens
|
|
let body = r#"{"objects":[{"key":"e089a40c-800b-4ec0-95b1-c17a63305887","token":"npm_yJcQ...rEf1"},{"key":"43c14e2d-8b5d-4f8b-91cd-280a7afead0c","token":"npm_rmll...aQIU"},{"key":"1ced5278-29a9-4266-bf8e-03223bc9c30c","token":"npm_ahWC...2pw1"}]}"#;
|
|
|
|
let result =
|
|
extract_value_from_response(&rendered, body, &HeaderMap::new(), &StatusCode::OK)
|
|
.unwrap();
|
|
|
|
// Should extract the key for the token matching prefix "npm_rmll", NOT the first one
|
|
assert_eq!(result, "43c14e2d-8b5d-4f8b-91cd-280a7afead0c");
|
|
}
|
|
|
|
#[test]
|
|
fn render_extractor_leaves_non_template_patterns_unchanged() {
|
|
let parser = crate::liquid_filters::register_all(liquid::ParserBuilder::with_stdlib())
|
|
.build()
|
|
.unwrap();
|
|
let globals = Object::new();
|
|
|
|
let extractor = ResponseExtractor::JsonPath { path: "$.objects[0].key".to_string() };
|
|
let rendered = render_extractor(&extractor, &parser, &globals).unwrap();
|
|
match rendered {
|
|
ResponseExtractor::JsonPath { path } => {
|
|
assert_eq!(path, "$.objects[0].key");
|
|
}
|
|
_ => panic!("Expected JsonPath variant"),
|
|
}
|
|
}
|
|
|
|
// ---- truncate_with_ellipsis ----
|
|
|
|
#[test]
|
|
fn truncate_with_ellipsis_no_truncation() {
|
|
let input = "ok";
|
|
let output = truncate_with_ellipsis(input, 500);
|
|
assert_eq!(output, input);
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_with_ellipsis_handles_unicode() {
|
|
let input = "é".repeat(501);
|
|
let output = truncate_with_ellipsis(&input, 500);
|
|
|
|
assert!(output.ends_with("..."));
|
|
assert_eq!(output.chars().count(), 503);
|
|
assert!(output.chars().take(500).all(|ch| ch == 'é'));
|
|
}
|
|
}
|