use std::path::PathBuf;
use anyhow::{Context, Result};
use rayon::prelude::*;
use crate::cli::args::{DebugArgs, ExplainArgs, InitArgs, ScanArgs};
use crate::config;
use crate::discovery::{FileWalker, GitHistoryConfig, GitHistoryScanner};
use crate::output::{self, OutputFormat};
use crate::scanner::{Scanner, ScannerConfig};
use crate::scoring::{ScoringConfig as EngineScoringConfig, SensitivityPreset, ThresholdConfig};
pub fn cmd_scan(args: ScanArgs, config_path: Option<PathBuf>) -> Result<()> {
let mut config = if let Some(path) = config_path {
config::load_from_file(&path).context("Failed to load config file")?
} else {
config::load_or_default(&args.path).context("Failed to load config")?
};
if args.show_scores {
config.output.show_scores = true;
}
let threshold_config = ThresholdConfig::from_preset(SensitivityPreset::Custom(args.threshold));
let scoring_config = EngineScoringConfig {
threshold_config,
enable_scope_filter: !args.include_tests,
ignore_values: config.scoring.ignore_values.clone(),
ignore_value_prefixes: config.scoring.ignore_value_prefixes.clone(),
ignore_value_suffixes: config.scoring.ignore_value_suffixes.clone(),
ignore_value_contains: config.scoring.ignore_value_contains.clone(),
..Default::default()
};
let scanner_config = ScannerConfig {
scoring: scoring_config,
..Default::default()
};
if args.scan_history {
cmd_scan_history(args, scanner_config, config)
} else {
cmd_scan_filesystem(args, scanner_config, config)
}
}
fn cmd_scan_filesystem(
args: ScanArgs,
scanner_config: ScannerConfig,
config: crate::config::Config,
) -> Result<()> {
let walker = FileWalker::new(&args.path)
.with_ignore_patterns(&args.ignore)
.with_only_patterns(&args.only)
.with_include_tests(args.include_tests)
.with_include_examples(args.include_examples);
let files = walker.walk().context("Failed to walk directory")?;
let files_to_scan = if args.max_files > 0 && files.len() > args.max_files {
files[..args.max_files].to_vec()
} else {
files
};
eprintln!("Scanning {} files...", files_to_scan.len());
if let Some(jobs) = args.jobs {
rayon::ThreadPoolBuilder::new()
.num_threads(jobs)
.build_global()
.ok();
}
let scanner = Scanner::new(scanner_config).map_err(|e| anyhow::anyhow!("{}", e))?;
let result = scanner.scan(files_to_scan);
eprintln!(
"Scanned {} files, indexed {} constants",
result.files_scanned, result.constants_indexed
);
let mut findings = result.findings;
findings.sort_by(|a, b| b.score.total.cmp(&a.score.total));
let format: OutputFormat = args.format.into();
let output_str = output::format(&findings, format, &config);
if let Some(output_path) = args.output {
std::fs::write(&output_path, &output_str).context("Failed to write output file")?;
eprintln!("Results written to {}", output_path.display());
} else {
println!("{}", output_str);
}
eprintln!(
"\nFound {} potential secret(s) (threshold: {})",
findings.len(),
args.threshold
);
if args.fail_on_findings && !findings.is_empty() {
std::process::exit(1);
}
Ok(())
}
fn cmd_scan_history(
args: ScanArgs,
scanner_config: ScannerConfig,
config: crate::config::Config,
) -> Result<()> {
use indicatif::{ProgressBar, ProgressStyle};
let git_config = GitHistoryConfig {
max_commits: args.max_commits,
since: args.since.clone(),
until: args.until.clone(),
branch: args.branch.clone(),
extensions: vec!["rs".into()],
};
let history_scanner = GitHistoryScanner::new(git_config);
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
spinner.set_message("Scanning git history...");
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
let history_result = history_scanner
.scan(&args.path)
.map_err(|e| anyhow::anyhow!("Git history scan failed: {}", e))?;
spinner.finish_with_message(format!(
"Scanned {} commits, found {} historical files",
history_result.commits_scanned,
history_result.files.len()
));
if !history_result.errors.is_empty() {
eprintln!("Warnings during git history scan:");
for error in &history_result.errors {
eprintln!(" - {}", error);
}
}
if let Some(jobs) = args.jobs {
rayon::ThreadPoolBuilder::new()
.num_threads(jobs)
.build_global()
.ok();
}
let scanner = Scanner::new(scanner_config).map_err(|e| anyhow::anyhow!("{}", e))?;
let progress = ProgressBar::new(history_result.files.len() as u64);
progress.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} files ({eta})")
.unwrap()
.progress_chars("█▓▒░"),
);
progress.set_message("Analyzing files...");
let all_findings_raw: Vec<_> = history_result
.files
.par_iter()
.flat_map(|hist_file| {
let mut findings = scanner.scan_content(&hist_file.path, &hist_file.content);
for finding in &mut findings {
finding.metadata.insert(
"commit".to_string(),
hist_file.commit_id.chars().take(8).collect(),
);
finding.metadata.insert(
"commit_summary".to_string(),
hist_file.commit_summary.clone(),
);
finding
.metadata
.insert("author".to_string(), hist_file.author.clone());
if hist_file.was_deleted {
finding
.metadata
.insert("status".to_string(), "DELETED".to_string());
} else {
finding
.metadata
.insert("status".to_string(), "current".to_string());
}
}
progress.inc(1);
findings
})
.collect();
let filtered_findings: Vec<_> = all_findings_raw
.into_iter()
.filter(|f| {
let is_deleted = f.metadata.get("status").map(|s| s == "DELETED").unwrap_or(false);
if args.exclude_deleted && is_deleted {
return false;
}
if args.only_deleted && !is_deleted {
return false;
}
true
})
.collect();
let mut seen = std::collections::HashSet::new();
let mut all_findings = Vec::new();
for finding in filtered_findings {
let signature = format!(
"{}:{}:{}",
finding.location.file.display(),
finding.location.line,
finding.suspect.name().unwrap_or("literal")
);
if seen.insert(signature) {
all_findings.push(finding);
}
}
let deleted_count = all_findings
.iter()
.filter(|f| f.metadata.get("status").map(|s| s == "DELETED").unwrap_or(false))
.count();
let current_count = all_findings.len() - deleted_count;
progress.finish_with_message(format!(
"Analyzed {} historical file versions",
history_result.files.len()
));
all_findings.sort_by(|a, b| b.score.total.cmp(&a.score.total));
let format: OutputFormat = args.format.into();
let output_str = output::format(&all_findings, format, &config);
if let Some(output_path) = args.output {
std::fs::write(&output_path, &output_str).context("Failed to write output file")?;
eprintln!("Results written to {}", output_path.display());
} else {
println!("{}", output_str);
}
eprintln!(
"\nFound {} potential secret(s) in git history (threshold: {})",
all_findings.len(),
args.threshold
);
if deleted_count > 0 {
eprintln!(
" {} in DELETED files (secrets removed but remain in git history)",
deleted_count
);
}
if current_count > 0 {
eprintln!(" {} in current files", current_count);
}
if args.exclude_deleted {
eprintln!(" (use --only-deleted to see secrets from deleted files)");
} else if args.only_deleted {
eprintln!(" (showing only deleted files; remove --only-deleted to see all)");
}
if args.fail_on_findings && !all_findings.is_empty() {
std::process::exit(1);
}
Ok(())
}
pub fn cmd_init(args: InitArgs) -> Result<()> {
if args.output.exists() && !args.force {
anyhow::bail!(
"File already exists: {}. Use --force to overwrite.",
args.output.display()
);
}
let config_content = config::generate_default_config(args.minimal);
std::fs::write(&args.output, config_content).context("Failed to write config file")?;
println!("Created configuration file: {}", args.output.display());
Ok(())
}
pub fn cmd_explain(args: ExplainArgs) -> Result<()> {
if let Some(results_path) = args.results {
let content = std::fs::read_to_string(&results_path).context("Failed to read results file")?;
if content.contains(&args.finding_id) {
println!("Finding {} found in results file.", args.finding_id);
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(findings) = parsed.get("findings").and_then(|f| f.as_array()) {
for finding in findings {
if finding.get("id").and_then(|id| id.as_str()) == Some(&args.finding_id) {
println!("{}", serde_json::to_string_pretty(finding)?);
return Ok(());
}
}
}
}
}
println!("Finding {} not found in results file.", args.finding_id);
} else {
println!(
"To explain finding {}, please provide a results file with --results",
args.finding_id
);
}
Ok(())
}
pub fn cmd_debug(args: DebugArgs) -> Result<()> {
use crate::parser::parse_file;
let parsed = parse_file(&args.path).context("Failed to parse file")?;
println!("File: {}", args.path.display());
println!("Lines: {}", parsed.line_count());
if args.show_ast {
println!("\nAST Items: {}", parsed.ast.items.len());
for (i, item) in parsed.ast.items.iter().enumerate() {
println!(" [{}] {:?}", i, item_kind(item));
}
}
use crate::analysis::{ComparisonFinder, ConstantFinder};
let constants = ConstantFinder::find(args.path.clone(), &parsed.ast);
println!("\nConstants found: {}", constants.len());
for c in &constants {
println!(
" {} = {:?} (line {})",
c.name,
c.value.as_deref().unwrap_or("<non-string>"),
c.line
);
}
let comparisons = ComparisonFinder::find(args.path.clone(), &parsed.ast);
println!("\nComparisons found: {}", comparisons.len());
for comp in &comparisons {
println!(
" {:?} {:?} {:?} (line {})",
comp.left, comp.operator, comp.right, comp.line
);
}
Ok(())
}
fn item_kind(item: &syn::Item) -> &'static str {
match item {
syn::Item::Const(_) => "const",
syn::Item::Enum(_) => "enum",
syn::Item::Fn(_) => "fn",
syn::Item::Impl(_) => "impl",
syn::Item::Mod(_) => "mod",
syn::Item::Static(_) => "static",
syn::Item::Struct(_) => "struct",
syn::Item::Trait(_) => "trait",
syn::Item::Type(_) => "type",
syn::Item::Use(_) => "use",
_ => "other",
}
}