use std::io::Write;
use anyhow::{Context, Result, bail};
use clap::Parser;
use console::Term;
use kingfisher::{
cli::{
self,
commands::rules::{
RuleCachePruneArgs, RulesCheckArgs, RulesCompileCacheArgs, RulesListArgs,
RulesListOutputFormat,
},
},
rule_loader::RuleLoader,
rules_database::{
RuleCacheConfig, RuleCachePruneConfig, RuleCacheStatus, RulesDatabase, prune_rule_cache,
},
};
use serde_json::json;
use tracing::{error, info, warn};
fn create_default_scan_args() -> Result<cli::commands::scan::ScanArgs> {
let cli::global::Command::Scan(command) =
cli::CommandLineArgs::try_parse_from(["kingfisher", "scan"])?.command
else {
unreachable!("the scan subcommand was explicitly selected");
};
let mut args = command.scan_args;
args.num_jobs = 1;
args.no_validate = true;
Ok(args)
}
pub(crate) fn run_rules_compile_cache(args: &RulesCompileCacheArgs) -> Result<()> {
let mut scan_args = create_default_scan_args()?;
scan_args.confidence = args.confidence;
let loader = RuleLoader::from_rule_specifiers(&args.rules);
let loaded = loader.load(&scan_args).context("Failed to load rules")?;
let resolved = loaded.resolve_enabled_rules_owned().context("Failed to resolve rules")?;
let betterleaks_prefilter = loaded.betterleaks_prefilter_for(&resolved);
let cache = RuleCacheConfig::from_dir_or_env(args.cache.rule_cache_dir.clone());
if !cache.is_enabled() {
bail!("No per-user rule cache directory is available; provide --rule-cache-dir");
}
info!(cache_dir = %cache.cache_dir().display(), "Using Vectorscan rule cache");
let rules_db = RulesDatabase::from_rules_with_cache_and_betterleaks_prefilter(
resolved,
&cache,
betterleaks_prefilter,
)
.context("Failed to compile rules with Vectorscan cache")?;
if rules_db.cache_status() == RuleCacheStatus::Bypassed {
bail!(
"Rules compiled, but the cache could not be persisted in {}. Check directory ownership, permissions, and available disk space",
cache.cache_dir().display()
);
}
println!("Rule cache ready: {} rules in {}", rules_db.num_rules(), cache.cache_dir().display());
Ok(())
}
pub(crate) fn run_rules_prune_cache(args: &RuleCachePruneArgs) -> Result<()> {
let cache = RuleCacheConfig::from_dir_or_env(args.cache.rule_cache_dir.clone());
let summary = prune_rule_cache(
&cache,
&RuleCachePruneConfig {
max_entries: args.max_entries,
max_age: args.max_age,
protected_cache_key: None,
dry_run: args.dry_run,
},
);
let action = if args.dry_run { "would remove" } else { "removed" };
println!(
"Rule cache prune {action} {} entries ({} bytes) from {}; scanned {} entries, {} valid, {} invalid, {} protected, {} removal errors",
if args.dry_run { summary.candidate_entries } else { summary.removed_entries },
if args.dry_run { summary.candidate_bytes } else { summary.removed_bytes },
cache.cache_dir().display(),
summary.scanned_entries,
summary.valid_entries,
summary.invalid_entries,
summary.protected_entries,
summary.removal_errors
);
Ok(())
}
pub(crate) fn run_rules_check(args: &RulesCheckArgs) -> Result<()> {
let mut num_errors = 0;
let mut num_warnings = 0;
let loader = RuleLoader::from_rule_specifiers(&args.rules);
let loaded = loader.load(&create_default_scan_args()?)?;
let resolved = loaded.resolve_enabled_rules_owned()?;
let betterleaks_prefilter = loaded.betterleaks_prefilter_for(&resolved);
let rules_db =
RulesDatabase::from_rules_with_betterleaks_prefilter(resolved, betterleaks_prefilter)?;
for (rule_index, rule) in rules_db.rules().iter().enumerate() {
let rule_syntax = rule.syntax();
if rule.name().len() < 3 {
warn!("Rule '{}' has a very short name", rule.name());
num_warnings += 1;
}
if rule.syntax().pattern.len() < 5 {
warn!("Rule '{}' has a very short pattern", rule.name());
num_warnings += 1;
}
if rule.syntax().examples.is_empty() {
if !rule.id().starts_with("betterleaks.") {
warn!("Rule '{}' has no examples", rule.name());
num_warnings += 1;
}
continue;
}
if let Err(e) = rule.syntax().as_regex() {
error!("Rule '{}' has invalid regex: {}", rule.name(), e);
num_errors += 1;
continue;
}
for (example_index, example) in rule_syntax.examples.iter().enumerate() {
let re =
rules_db.get_regex_by_rule_id(rule.id()).expect("Failed to get regex for rule");
let example_bytes = example.as_bytes();
let regex_matched = re.is_match(example_bytes);
if !regex_matched {
println!("\nTesting rule {} - {}", rule_index + 1, rule_syntax.name);
println!(" Processing example {}", example_index + 1);
println!(" [!] Pattern mismatch detected for example: {}", example);
println!(" Regex match: {}", regex_matched);
num_errors += 1;
continue;
}
if let Some(pattern_reqs) = rule.pattern_requirements() {
if let Some(captures) = re.captures(example_bytes) {
let full_capture = captures.get(0).expect("Group 0 should always exist");
let full_bytes = full_capture.as_bytes();
let matching_input_for_validation = 'block: {
if let Some(secret_cap) =
captures.name("secret").or_else(|| captures.name("SECRET"))
{
break 'block secret_cap;
}
if let Some(named_cap) = (1..captures.len()).find_map(|i| {
let name_opt = re.capture_names().nth(i).and_then(|n| n);
name_opt.and_then(|_| captures.get(i))
}) {
break 'block named_cap;
}
if let Some(pos_cap) = captures.get(1) {
break 'block pos_cap;
}
break 'block full_capture;
};
let validation_bytes = matching_input_for_validation.as_bytes();
use kingfisher_rules::PatternRequirementContext;
let context = PatternRequirementContext {
regex: re,
captures: &captures,
full_match: full_bytes,
};
use kingfisher_rules::PatternValidationResult;
match pattern_reqs.validate(validation_bytes, Some(context), false) {
PatternValidationResult::Passed => {
}
PatternValidationResult::Failed => {
println!("\nTesting rule {} - {}", rule_index + 1, rule_syntax.name);
println!(" Processing example {}", example_index + 1);
println!(
" [!] Pattern requirements not met for example: {}",
example
);
println!(
" The match does not satisfy the character requirements (min_digits, min_uppercase, etc.)"
);
num_errors += 1;
}
PatternValidationResult::FailedChecksum { actual_len, expected_len } => {
println!("\nTesting rule {} - {}", rule_index + 1, rule_syntax.name);
println!(" Processing example {}", example_index + 1);
println!(" [!] Checksum validation failed for example: {}", example);
println!(
" Actual checksum length: {}, Expected checksum length: {}",
actual_len, expected_len
);
num_errors += 1;
}
PatternValidationResult::IgnoredBySubstring { matched_term } => {
println!("\nTesting rule {} - {}", rule_index + 1, rule_syntax.name);
println!(" Processing example {}", example_index + 1);
println!(
" [!] Example would be ignored due to containing term: {}",
matched_term
);
println!(" Example: {}", example);
num_warnings += 1;
}
}
}
}
}
}
if num_errors > 0 || num_warnings > 0 {
println!("\nCheck Summary:");
println!(" Errors: {}", num_errors);
println!(" Warnings: {}", num_warnings);
println!("\nError types include:");
println!(" - Invalid regex patterns");
println!(" - Examples that don't match their patterns");
println!("\nWarning types include:");
println!(" - Rules with very short names");
println!(" - Rules with very short patterns");
println!(" - Rules without examples");
} else {
println!("\nAll rules passed validation successfully!");
}
if num_errors > 0 || (args.warnings_as_errors && num_warnings > 0) {
std::process::exit(1);
}
Ok(())
}
pub(crate) fn run_rules_list(args: &RulesListArgs) -> Result<()> {
let loader = RuleLoader::from_rule_specifiers(&args.rules);
let loaded = loader.load(&create_default_scan_args()?)?;
let resolved = loaded.resolve_enabled_rules()?;
let mut writer = args.output_args.get_writer()?;
#[cfg(debug_assertions)]
let show_validation = args.show_validation;
#[cfg(not(debug_assertions))]
let show_validation = false;
match args.output_args.format {
RulesListOutputFormat::Pretty => {
let term_width = usize::from(Term::stdout().size().1);
let max_name_width = resolved.iter().map(|r| r.name().len()).max().unwrap_or(0).max(4); let max_id_width = resolved.iter().map(|r| r.id().len()).max().unwrap_or(0).max(2); let max_conf_width = resolved
.iter()
.map(|r| format!("{:?}", r.confidence()).len())
.max()
.unwrap_or(0)
.max(10); let reserved_width = max_name_width + max_id_width + max_conf_width + 10;
let pattern_width = term_width.saturating_sub(reserved_width);
let format_pattern = |pattern: &str| {
let single_line = pattern
.replace(['\n', '\r'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if single_line.len() > pattern_width {
format!("{}...", &single_line[..pattern_width.saturating_sub(3)])
} else {
single_line
}
};
writeln!(
writer,
"\n{:name_width$} │ {:id_width$} │ {:conf_width$} │ Pattern",
"Rule",
"ID",
"Confidence",
name_width = max_name_width,
id_width = max_id_width,
conf_width = max_conf_width
)?;
writeln!(
writer,
"{0:─<name_width$} ┼ {0:─<id_width$} ┼ {0:─<conf_width$} ┼ {0:─<pattern_width$}",
"",
name_width = max_name_width,
id_width = max_id_width,
conf_width = max_conf_width,
pattern_width = pattern_width
)?;
for rule in resolved {
let formatted_pattern = format_pattern(&rule.syntax().pattern);
writeln!(
writer,
"{:name_width$} │ {:id_width$} │ {:conf_width$} │ {}",
rule.name(),
rule.id(),
format!("{:?}", rule.confidence()),
formatted_pattern,
name_width = max_name_width,
id_width = max_id_width,
conf_width = max_conf_width
)?;
if show_validation && let Some(validation) = &rule.syntax().validation {
match validation {
kingfisher::rules::Validation::Betterleaks(validation) => {
#[cfg(debug_assertions)]
writeln!(writer, " Validation: {}", validation.source)?;
writeln!(writer, " Validation AST: {:?}", validation.expression)?;
}
validation => writeln!(writer, " Validation: {validation:?}")?,
}
}
}
writeln!(writer)?;
}
RulesListOutputFormat::Json => {
let rules_json: Vec<_> = resolved
.iter()
.map(|rule| {
let mut value = json!({
"name": rule.name(),
"id": rule.id(),
"pattern": rule.syntax().pattern,
"confidence": rule.confidence(),
"examples": rule.syntax().examples,
"visible": rule.visible(),
});
if show_validation {
value["validation"] = serde_json::to_value(&rule.syntax().validation)
.expect("validation serialization should succeed");
}
value
})
.collect();
serde_json::to_writer_pretty(&mut writer, &rules_json)?;
writeln!(writer)?;
}
}
Ok(())
}