use crate::args::ScanArgs;
use std::path::PathBuf;
mod limits;
mod policy;
mod scan;
pub(crate) mod schema;
mod sections;
pub(crate) use policy::ConfigOutcome;
use policy::{base_config_outcome, config_file_error, resolve_policy_outcome};
use scan::{apply_scan_section, apply_top_level_scan_fields, validate_scan_preset_conflicts};
pub(crate) use schema::ConfigFile;
use sections::{
apply_allowlist_section, apply_aws_section, apply_http_section, apply_system_section,
apply_tuning_section,
};
const RETIRED_FLAT_SCAN_KEYS: &[&str] = &[
"severity",
"format",
"min_confidence",
"ml_threshold",
"threads",
"reader_threads",
"fused_batch",
"fused_depth",
"per_chunk_timeout_ms",
"dedup",
"incremental",
"incremental_cache",
"gpu_batch_input_limit",
"decode_depth",
"entropy_threshold",
"entropy_bpe_max_bytes_per_token",
"min_secret_len",
"exclude_paths",
];
pub(super) fn invalid_config_value(field: &str, value: &str, detail: &str) -> String {
format!("- {field} = {value:?}: {detail}")
}
pub(crate) fn find_config_file(start: Option<&std::path::Path>) -> Option<PathBuf> {
let _discover_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
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
}
fn discover_config_for_scan_roots(args: &ScanArgs) -> Result<Option<PathBuf>, String> {
let roots = args.scan_roots();
if roots.len() <= 1 {
return Ok(find_config_file(roots.first().map(PathBuf::as_path)));
}
let mut resolved = Vec::with_capacity(roots.len());
for root in roots {
let config = match find_config_file(Some(&root)) {
Some(path) => Some(std::fs::canonicalize(&path).map_err(|error| {
format!(
"cannot resolve repository config identity for {} at {}: {error}",
root.display(),
path.display()
)
})?),
None => None,
};
resolved.push((root, config));
}
let expected = resolved.first().and_then(|(_, config)| config.clone());
if resolved.iter().any(|(_, config)| config != &expected) {
let identities = resolved
.iter()
.map(|(root, config)| {
format!(
"{} -> {}",
root.display(),
config.as_deref().map_or_else(
|| "no .keyhog.toml".to_string(),
|path| path.display().to_string()
)
)
})
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"scan roots resolve different repository configuration identities ({identities})"
));
}
Ok(expected)
}
pub(crate) fn apply_config_file(args: &mut ScanArgs) -> ConfigOutcome {
apply_config_file_impl(args, true)
}
pub(crate) fn apply_config_file_quiet(args: &mut ScanArgs) -> ConfigOutcome {
apply_config_file_impl(args, false)
}
#[allow(clippy::collapsible_if, clippy::cmp_owned)]
fn apply_config_file_impl(args: &mut ScanArgs, emit_diagnostics: bool) -> ConfigOutcome {
if args.no_config {
return base_config_outcome();
}
let config_path = match args.config.clone() {
Some(path) => Some(path),
None => match discover_config_for_scan_roots(args) {
Ok(path) => path,
Err(error) => {
return config_file_error(
std::path::Path::new("<multiple scan roots>"),
error,
"pass one explicit --config PATH that intentionally governs every root, split the roots into separate scans, or use --no-config for compiled defaults",
);
}
},
};
let config_path = match config_path {
Some(path) => path,
None => return base_config_outcome(),
};
let load_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
let raw = match std::fs::read_to_string(&config_path) {
Ok(content) => content,
Err(error) => {
if emit_diagnostics {
tracing::warn!(
path = %config_path.display(),
"failed to read .keyhog.toml: {error}"
);
}
return config_file_error(
&config_path,
format_args!("failed to read config file: {error}"),
"make the file readable, pass a valid --config path, or run with --no-config",
);
}
};
let mut config: ConfigFile = match toml::from_str(&raw) {
Ok(parsed) => parsed,
Err(error) => {
if emit_diagnostics {
tracing::warn!(
path = %config_path.display(),
"failed to parse .keyhog.toml: {error}"
);
}
let parsed_table = raw.parse::<toml::Table>().ok(); let retired_key = parsed_table.as_ref().and_then(|table| {
RETIRED_FLAT_SCAN_KEYS
.iter()
.copied()
.find(|key| table.contains_key(*key))
});
let fix = retired_key.map_or_else(
|| {
"correct the TOML syntax or run with --no-config for a hermetic default scan"
.to_string()
},
|key| {
let canonical = if key == "exclude_paths" { "exclude" } else { key };
format!(
"move top-level `{key}` to `[scan].{canonical}`; scan policy has one TOML owner"
)
},
);
return config_file_error(
&config_path,
format_args!("failed to parse TOML: {error}"),
&fix,
);
}
};
tracing::debug!(path = %config_path.display(), "loaded .keyhog.toml");
drop(load_span);
let _merge_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
let mut config_errors = Vec::new();
let mut trusted_bin_dirs = Vec::new();
let mut aws_canary_accounts = Vec::new();
let mut scanner_tuning = keyhog_scanner::ScannerTuningConfig::default();
let mut allowlist_file = None;
let mut allowlist_require_reason = false;
let mut allowlist_require_approved_by = false;
let mut allowlist_max_expires_days = None;
apply_system_section(
args,
&mut config_errors,
&mut trusted_bin_dirs,
config.system.as_ref(),
);
apply_aws_section(
&mut config_errors,
&mut aws_canary_accounts,
config.aws.as_ref(),
);
apply_allowlist_section(
&mut config_errors,
&config_path,
&mut allowlist_file,
&mut allowlist_require_reason,
&mut allowlist_require_approved_by,
&mut allowlist_max_expires_days,
config.allowlist.as_ref(),
);
apply_tuning_section(
&mut config_errors,
&mut scanner_tuning,
config.tuning.as_ref(),
);
validate_scan_preset_conflicts(args, &mut config_errors, &config);
#[cfg(any(
feature = "web",
feature = "github",
feature = "gitlab",
feature = "bitbucket",
feature = "s3",
feature = "gcs",
feature = "azure",
feature = "verify"
))]
apply_http_section(args, config.http.as_ref());
#[cfg(not(any(
feature = "web",
feature = "github",
feature = "gitlab",
feature = "bitbucket",
feature = "s3",
feature = "gcs",
feature = "azure",
feature = "verify"
)))]
apply_http_section(args, &mut config_errors, config.http.as_ref());
apply_top_level_scan_fields(args, &mut config_errors, &config_path, &mut config);
apply_scan_section(args, &mut config_errors, config.scan.take());
let mut outcome = resolve_policy_outcome(&mut config);
config_errors.append(&mut outcome.config_errors);
outcome.config_errors = config_errors;
outcome.trusted_bin_dirs = trusted_bin_dirs;
outcome.aws_canary_accounts = aws_canary_accounts;
outcome.scanner_tuning = scanner_tuning;
outcome.allowlist_file = allowlist_file;
outcome.allowlist_require_reason = allowlist_require_reason;
outcome.allowlist_require_approved_by = allowlist_require_approved_by;
outcome.allowlist_max_expires_days = allowlist_max_expires_days;
outcome
}
#[cfg(test)]
#[path = "../tests/unit/config_reference_keys.rs"]
mod config_reference_keys;