use crate::args::ScanArgs;
use crate::value_parsers::{parse_dedup_scope, parse_output_format, parse_severity_filter};
use std::path::PathBuf;
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default)]
pub struct ConfigFile {
pub detectors: Option<String>,
pub severity: Option<String>,
pub format: Option<String>,
pub fast: Option<bool>,
pub deep: Option<bool>,
pub no_decode: Option<bool>,
pub no_entropy: Option<bool>,
pub min_confidence: Option<f64>,
pub threads: Option<usize>,
pub dedup: Option<String>,
pub verify: Option<bool>,
pub timeout: Option<u64>,
pub rate: Option<usize>,
pub max_commits: Option<usize>,
pub show_secrets: Option<bool>,
pub decode_depth: Option<usize>,
pub decode_size_limit: Option<String>,
pub entropy_source_files: Option<bool>,
pub entropy_threshold: Option<f64>,
pub no_unicode_norm: Option<bool>,
pub no_ml: Option<bool>,
pub exclude_paths: Option<Vec<String>>,
pub max_file_size: Option<String>,
pub regex_dfa_limit: Option<String>,
pub ml_weight: Option<f64>,
pub known_prefixes: Option<Vec<String>>,
pub secret_keywords: Option<Vec<String>>,
pub test_keywords: Option<Vec<String>>,
pub placeholder_keywords: Option<Vec<String>>,
pub scan: Option<ScanSection>,
pub allowlist: Option<AllowlistSection>,
pub detector: Option<std::collections::HashMap<String, DetectorSection>>,
pub lockdown: Option<LockdownSection>,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default)]
pub struct ScanSection {
pub severity: Option<String>,
pub min_confidence: Option<f64>,
pub format: Option<String>,
pub exclude: Option<Vec<String>>,
pub threads: Option<usize>,
pub dedup: Option<String>,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default)]
pub struct AllowlistSection {
pub file: Option<String>,
pub require_reason: Option<bool>,
pub require_approved_by: Option<bool>,
pub max_expires_days: Option<u64>,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default)]
pub struct DetectorSection {
pub enabled: Option<bool>,
pub min_confidence: Option<f64>,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default)]
pub struct LockdownSection {
pub require: Option<bool>,
}
pub fn find_config_file(start: Option<&std::path::Path>) -> Option<PathBuf> {
let mut dir = start
.and_then(|p| {
if p.is_dir() {
Some(p.to_path_buf())
} else {
p.parent().map(std::path::Path::to_path_buf)
}
})
.or_else(|| std::env::current_dir().ok())?;
loop {
let candidate = dir.join(".keyhog.toml");
if candidate.is_file() {
return Some(candidate);
}
if !dir.pop() {
break;
}
}
None
}
#[derive(Debug, Default)]
pub struct ConfigOutcome {
pub disabled_detectors: Vec<String>,
pub require_lockdown: bool,
}
#[allow(clippy::collapsible_if, clippy::cmp_owned)]
pub fn apply_config_file(args: &mut ScanArgs) -> ConfigOutcome {
let config_path = args
.config
.clone()
.or_else(|| find_config_file(args.path.as_deref()));
let config_path = match config_path {
Some(path) => path,
None => return ConfigOutcome::default(),
};
let raw = match std::fs::read_to_string(&config_path) {
Ok(content) => content,
Err(error) => {
tracing::warn!(
path = %config_path.display(),
"failed to read .keyhog.toml: {error}"
);
return ConfigOutcome::default();
}
};
let config: ConfigFile = match toml::from_str(&raw) {
Ok(parsed) => parsed,
Err(error) => {
eprintln!(
"⚠️ WARNING: Failed to parse .keyhog.toml at {}: {}",
config_path.display(),
error
);
tracing::warn!(
path = %config_path.display(),
"failed to parse .keyhog.toml: {error}"
);
return ConfigOutcome::default();
}
};
tracing::debug!(path = %config_path.display(), "loaded .keyhog.toml");
if let Some(ref detectors_str) = config.detectors {
if args.detectors == PathBuf::from("detectors") {
args.detectors = PathBuf::from(detectors_str);
}
}
if let Some(ref format_str) = config.format {
if matches!(args.format, crate::args::OutputFormat::Text) {
if let Some(fmt) = parse_output_format(format_str) {
args.format = fmt;
}
}
}
if let Some(ref severity_str) = config.severity {
if args.severity.is_none() {
args.severity = parse_severity_filter(severity_str);
}
}
if let Some(fast) = config.fast {
if !args.fast && !args.deep {
args.fast = fast;
}
}
if let Some(deep) = config.deep {
if !args.fast && !args.deep {
args.deep = deep;
}
}
if let Some(no_decode) = config.no_decode {
if !args.no_decode {
args.no_decode = no_decode;
}
}
if let Some(_no_entropy) = config.no_entropy {
if !args.no_entropy {
args.no_entropy = _no_entropy;
}
}
if let Some(min_conf) = config.min_confidence {
if args.min_confidence.is_none() {
args.min_confidence = Some(min_conf);
}
}
if let Some(threads) = config.threads {
if args.threads.is_none() {
args.threads = Some(threads);
}
}
if let Some(ref dedup_str) = config.dedup {
if matches!(args.dedup, crate::args::CliDedupScope::Credential) {
if let Some(scope) = parse_dedup_scope(dedup_str) {
args.dedup = scope;
}
}
}
if let Some(_verify) = config.verify {
#[cfg(feature = "verify")]
if !args.verify {
args.verify = _verify;
}
}
if let Some(timeout) = config.timeout {
if args.timeout == 5 {
args.timeout = timeout;
}
}
if let Some(rate) = config.rate {
if args.rate == 5 {
args.rate = rate;
}
}
if let Some(_max_commits) = config.max_commits {
#[cfg(feature = "git")]
if args.max_commits == 1000 {
args.max_commits = _max_commits;
}
}
if let Some(show_secrets) = config.show_secrets {
if !args.show_secrets {
args.show_secrets = show_secrets;
}
}
if let Some(depth) = config.decode_depth {
if args.decode_depth.is_none() {
args.decode_depth = Some(depth);
}
}
if let Some(ref limit_str) = config.decode_size_limit {
if args.decode_size_limit.is_none() {
if let Ok(size) = crate::value_parsers::parse_byte_size(limit_str) {
args.decode_size_limit = Some(size);
}
}
}
if let Some(_entropy_source) = config.entropy_source_files {
if !args.entropy_source_files {
args.entropy_source_files = _entropy_source;
}
}
if let Some(_entropy_threshold) = config.entropy_threshold {
if args.entropy_threshold.is_none() {
args.entropy_threshold = Some(_entropy_threshold);
}
}
if let Some(no_unicode_norm) = config.no_unicode_norm {
if !args.no_unicode_norm {
args.no_unicode_norm = no_unicode_norm;
}
}
if let Some(no_ml) = config.no_ml {
if !args.no_ml {
args.no_ml = no_ml;
}
}
if let Some(ml_weight) = config.ml_weight {
if args.ml_weight.is_none() {
args.ml_weight = Some(ml_weight);
}
}
if let Some(ref limit_str) = config.max_file_size {
if args.max_file_size.is_none() {
if let Ok(size) = crate::value_parsers::parse_byte_size(limit_str) {
args.max_file_size = Some(size);
}
}
}
if let Some(ref limit_str) = config.regex_dfa_limit {
if args.regex_dfa_limit.is_none() {
if let Ok(size) = crate::value_parsers::parse_byte_size(limit_str) {
args.regex_dfa_limit = Some(size);
}
}
}
if let Some(paths) = config.exclude_paths {
if args.exclude_paths.is_none() {
args.exclude_paths = Some(paths);
}
}
if let Some(prefixes) = config.known_prefixes {
args.known_prefixes = prefixes;
}
if let Some(keywords) = config.secret_keywords {
args.secret_keywords = keywords;
}
if let Some(keywords) = config.test_keywords {
args.test_keywords = keywords;
}
if let Some(keywords) = config.placeholder_keywords {
args.placeholder_keywords = keywords;
}
if let Some(scan) = config.scan {
if args.severity.is_none() {
if let Some(ref s) = scan.severity {
args.severity = parse_severity_filter(s);
}
}
if args.min_confidence.is_none() {
args.min_confidence = scan.min_confidence;
}
if matches!(args.format, crate::args::OutputFormat::Text) {
if let Some(ref f) = scan.format {
if let Some(fmt) = parse_output_format(f) {
args.format = fmt;
}
}
}
if args.exclude_paths.is_none() {
args.exclude_paths = scan.exclude;
}
if args.threads.is_none() {
args.threads = scan.threads;
}
if matches!(args.dedup, crate::args::CliDedupScope::Credential) {
if let Some(ref d) = scan.dedup {
if let Some(scope) = parse_dedup_scope(d) {
args.dedup = scope;
}
}
}
}
let require_lockdown = config
.lockdown
.as_ref()
.and_then(|l| l.require)
.unwrap_or(false);
let disabled_detectors = config
.detector
.map(|map| {
map.into_iter()
.filter(|(_, section)| section.enabled == Some(false))
.map(|(id, _)| id)
.collect()
})
.unwrap_or_default();
ConfigOutcome {
disabled_detectors,
require_lockdown,
}
}