use super::allowlist::{load_allowlist, load_rule_suppressor};
use super::reporting::{
dump_dogfood_trace, report_completion_summary, report_skip_summary, TickerGuard,
};
use super::ScanOrchestrator;
use crate::baseline::Baseline;
use crate::exit_codes::{
EXIT_FINDINGS, EXIT_LIVE_CREDENTIALS, EXIT_REQUIRE_GPU_UNMET, EXIT_SCANNER_PANIC,
EXIT_SOURCE_FAILED, EXIT_SUCCESS, EXIT_SYSTEM_ERROR,
};
use crate::style;
use anyhow::Result;
use keyhog_core::{VerificationResult, VerifiedFinding};
use std::io::IsTerminal;
use std::time::Instant;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) struct ScanOutcome {
pub(super) autoroute_calibration: bool,
pub(super) scanner_panicked: bool,
pub(super) has_live_credentials: bool,
pub(super) has_new_entries: bool,
pub(super) incremental_cache_failed: bool,
pub(super) source_coverage_incomplete: bool,
}
pub(super) fn resolve_scan_exit(outcome: ScanOutcome) -> u8 {
if outcome.autoroute_calibration && !outcome.scanner_panicked {
EXIT_SUCCESS
} else if outcome.scanner_panicked {
EXIT_SCANNER_PANIC
} else if outcome.has_live_credentials {
EXIT_LIVE_CREDENTIALS
} else if outcome.has_new_entries {
EXIT_FINDINGS
} else if outcome.incremental_cache_failed {
EXIT_SYSTEM_ERROR
} else if outcome.source_coverage_incomplete {
EXIT_SOURCE_FAILED
} else {
EXIT_SUCCESS
}
}
impl ScanOrchestrator {
pub async fn run(mut self) -> Result<std::process::ExitCode> {
crate::reset_scan_runtime_state();
let start = Instant::now();
let wall_start = chrono::Utc::now();
let stderr_is_tty = std::io::stderr().is_terminal();
if self.args.no_color {
std::env::set_var("NO_COLOR", "1");
}
let no_color = self.args.no_color || crate::style::no_color_requested();
self.args.no_color = no_color;
let show_progress = !self.args.quiet && (self.args.progress || stderr_is_tty);
let progress_ansi = stderr_is_tty && !no_color;
if self.args.dogfood {
keyhog_scanner::telemetry::enable_dogfood();
}
let hardening = keyhog_core::apply_protections(false);
if !hardening.failures.is_empty() {
tracing::warn!(
failures = ?hardening.failures,
"default hardening protections did not fully apply"
);
}
if self.args.lockdown {
#[cfg(feature = "verify")]
if self.effective_config.report.verify {
anyhow::bail!(
"lockdown mode forbids --verify (would send credentials \
to outbound HTTPS endpoints). Drop --verify or drop --lockdown."
);
}
if self.effective_config.report.show_secrets {
anyhow::bail!(
"lockdown mode forbids --show-secrets (would print plaintext credentials \
to stdout/stderr). Drop --show-secrets or drop --lockdown."
);
}
let lockdown = keyhog_core::apply_protections_with_persistence_paths(
true,
self.lockdown_persistence_cache_paths(),
);
if !lockdown.failures.is_empty() {
anyhow::bail!(
"lockdown mode requested but protections failed to apply: {:?}",
lockdown.failures
);
}
tracing::info!(
mlocked = lockdown.mlocked,
"lockdown mode active: mlocked + coredump-blocked + cache-free"
);
let palette = style::for_stderr();
eprintln!(
"{} LOCKDOWN MODE: no findings cache on disk, mlocked, no live verifier",
style::info("INFO", &palette)
);
if self.args.no_default_excludes {
anyhow::bail!(
"lockdown mode forbids --no-default-excludes (would scan untrusted \
lock files / minified bundles / vendor dirs that are common \
credential-leak vectors)."
);
}
if self.args.no_unicode_norm {
anyhow::bail!(
"lockdown mode forbids --no-unicode-norm (would let homoglyph \
attackers hide secrets behind visually identical Unicode)."
);
}
if self.args.no_decode {
anyhow::bail!(
"lockdown mode forbids --no-decode (encoded secrets like \
base64('AKIA…') would slip through entirely)."
);
}
if self.args.no_entropy {
anyhow::bail!(
"lockdown mode forbids --no-entropy (entropy detection is the \
only catch for novel / unknown high-entropy secrets)."
);
}
if self.args.no_ml {
anyhow::bail!(
"lockdown mode forbids --no-ml (ML confidence gating reduces \
false-negative rate on hand-crafted near-misses)."
);
}
if self.args.fast {
anyhow::bail!(
"lockdown mode forbids --fast (it disables decode + entropy + ML \
simultaneously, the largest detection blind spot we ship)."
);
}
}
let hw = keyhog_scanner::hw_probe::probe_hardware();
let scanner_status = self.scanner.runtime_status();
let backend_policy = if self.effective_config.autoroute_calibration {
"calibrate"
} else if let Some(backend) = self.effective_config.backend_override {
backend.label()
} else {
"auto:persisted-per-workload"
};
tracing::info!(
backend_policy,
gpu_available = hw.gpu_available,
gpu_software = hw.gpu_is_software,
hyperscan = hw.hyperscan_available,
avx512 = hw.has_avx512,
avx2 = hw.has_avx2,
neon = hw.has_neon,
"scan backend policy configured"
);
if show_progress {
if let Err(error) =
crate::write_banner(&mut std::io::stderr(), progress_ansi, self.detectors.len())
{
tracing::debug!(%error, "banner write error");
}
let gpu_candidates = self.scanner.gpu_backend_candidates();
let gpu_label = gpu_candidates
.iter()
.filter(|candidate| candidate.is_eligible())
.map(|candidate| candidate.backend.label())
.collect::<Vec<_>>()
.join(",");
let gpu_label = if gpu_label.is_empty() {
"none"
} else {
gpu_label.as_str()
};
eprintln!(
"⚡ {} | backend={backend_policy} | gpu={gpu_label}",
keyhog_scanner::hw_probe::startup_banner(
hw,
self.detectors.len(),
scanner_status.pattern_count,
)
);
for candidate in gpu_candidates
.iter()
.filter(|candidate| !candidate.is_eligible())
{
if let Some(error) = candidate.acquisition_error.as_deref() {
eprintln!(
"gpu candidate unavailable | backend={} | error={error}",
candidate.backend.label()
);
} else if candidate.available {
eprintln!(
"gpu candidate ineligible | backend={} | software={} | complete_identity={}",
candidate.backend.label(),
candidate.is_software,
candidate.has_complete_identity(),
);
}
}
}
if let Err(diagnostic) = keyhog_scanner::gpu::require_gpu_preflight() {
eprintln!("keyhog: {diagnostic}");
return Ok(std::process::ExitCode::from(EXIT_REQUIRE_GPU_UNMET));
}
let calibration_mode = self.effective_config.autoroute_calibration;
if calibration_mode {
tracing::debug!(
target: "keyhog::routing",
"backend prewarm skipped during autoroute calibration"
);
} else if let Some(preferred) = self.effective_config.backend_override {
let warm_started = Instant::now();
let warmed = self.scanner.warm_backend(preferred);
let warm_ms = warm_started.elapsed().as_millis();
tracing::debug!(
target: "keyhog::routing",
backend = preferred.label(),
warmed,
elapsed_ms = warm_ms as u64,
"backend warmed"
);
} else {
tracing::debug!(
target: "keyhog::routing",
"automatic backend prewarm awaits the persisted workload decision"
);
}
if self.args.benchmark {
eprintln!("benchmark | gpu={}", crate::benchmark::format_gpu_summary());
let results = crate::benchmark::run_benchmark(&self)?;
let baseline_mb = results
.iter()
.map(|r| r.mb_per_sec)
.fold(f64::INFINITY, f64::min)
.max(f64::EPSILON);
for result in &results {
let speedup = result.mb_per_sec / baseline_mb;
eprintln!(
"benchmark | backend={:<14} | throughput={:>8.2} MiB/s | speedup={:>5.2}× | findings={:>4} | bytes={}",
result.backend.label(),
result.mb_per_sec,
speedup,
result.findings,
result.bytes_scanned
);
}
if let Some(fastest) = results
.iter()
.max_by(|a, b| a.mb_per_sec.total_cmp(&b.mb_per_sec))
{
eprintln!(
"benchmark winner: {} at {:.2} MiB/s",
fastest.backend.label(),
fastest.mb_per_sec
);
}
return Ok(std::process::ExitCode::SUCCESS);
}
let allowlist =
load_allowlist(self.args.path.as_deref(), &self.effective_config.allowlist)?;
let incremental_cache_path = self.incremental_cache_path()?;
let merkle = self.build_merkle_index(incremental_cache_path.as_deref());
let sources = crate::sources::build_sources(
&self.args,
&self.effective_config,
allowlist.ignored_paths.as_ref().to_vec(),
merkle.clone(),
)?;
if sources.is_empty() {
anyhow::bail!(
"no input source specified. Use --path, --stdin, --git, --git-diff, --git-history, --github-org, --gitlab-group, --bitbucket-workspace, --s3-bucket, --gcs-bucket, --azure-container-url, or --docker-image"
);
}
let all_matches =
self.scan_sources(sources, show_progress, merkle, incremental_cache_path)?;
let filtered = self.filter_and_resolve(all_matches, &allowlist)?;
let findings_pre_rules = self.finalize(filtered).await?;
let rule_suppressor = load_rule_suppressor(self.args.path.as_deref())?;
let pre_rule_count = findings_pre_rules.len();
let hide_client_safe = self.effective_config.report.hide_client_safe;
let mut client_safe_dropped = 0usize;
let findings: Vec<VerifiedFinding> = findings_pre_rules
.into_iter()
.filter(|f| {
if rule_suppressor.matches(f) {
return false;
}
if hide_client_safe && f.severity == keyhog_core::Severity::ClientSafe {
client_safe_dropped += 1;
return false;
}
true
})
.collect();
if findings.is_empty()
&& crate::FAILED_SOURCES.load(std::sync::atomic::Ordering::Relaxed) > 0
{
eprintln!(
"error: a requested scan source failed to read and produced no data (see the \
warnings above). Not reporting \"clean\": that scan did not run. Check the \
repository path, ref, token, or URL and re-run."
);
return Ok(std::process::ExitCode::from(EXIT_SOURCE_FAILED));
}
if show_progress {
let dropped = pre_rule_count - findings.len() - client_safe_dropped;
if dropped > 0 {
eprintln!(
"\n Suppressed {} finding(s) via .keyhogignore.toml",
dropped
);
}
}
if show_progress && client_safe_dropped > 0 {
eprintln!(
"\n Suppressed {} client-safe finding(s) via --hide-client-safe (public-by-design keys)",
client_safe_dropped
);
}
let scanner_panicked = crate::SCANNER_PANICKED.load(std::sync::atomic::Ordering::Relaxed);
let incremental_cache_failed =
crate::INCREMENTAL_CACHE_ERRORS.load(std::sync::atomic::Ordering::Relaxed) > 0;
let source_coverage_incomplete = source_coverage_incomplete();
let baseline_coverage_failed = baseline_coverage_untrustworthy();
let baseline_untrustworthy =
scanner_panicked || incremental_cache_failed || baseline_coverage_failed;
if let Some(ref path) = self.args.create_baseline {
if baseline_untrustworthy {
let exit = resolve_scan_exit(ScanOutcome {
autoroute_calibration: false,
scanner_panicked,
has_live_credentials: false,
has_new_entries: false,
incremental_cache_failed,
source_coverage_incomplete: baseline_coverage_failed,
});
eprintln!(
"error: refusing --create-baseline: scan is untrustworthy \
(panic={}, coverage_failed={}, incremental_cache_failed={}). \
Prior baseline left unchanged.",
scanner_panicked, baseline_coverage_failed, incremental_cache_failed
);
for (reason, count) in crate::reporting::coverage_gap_summary(
&crate::reporting::CoverageCounts::current(),
) {
if count > 0 {
eprintln!(" coverage gap: {count} {reason}");
}
}
return Ok(std::process::ExitCode::from(exit));
}
let baseline = Baseline::from_findings(&findings);
baseline.save(path)?;
if show_progress {
eprintln!(
"\n📝 Baseline created with {} entries at {}",
baseline.entries.len(),
path.display()
);
}
let has_live = findings
.iter()
.any(|f| matches!(f.verification, VerificationResult::Live));
if has_live {
return Ok(std::process::ExitCode::from(EXIT_LIVE_CREDENTIALS));
}
return Ok(std::process::ExitCode::SUCCESS);
}
let (report_findings, has_new_entries) = if let Some(ref path) = self.args.update_baseline {
if baseline_untrustworthy {
let exit = resolve_scan_exit(ScanOutcome {
autoroute_calibration: false,
scanner_panicked,
has_live_credentials: false,
has_new_entries: false,
incremental_cache_failed,
source_coverage_incomplete: baseline_coverage_failed,
});
eprintln!(
"error: refusing --update-baseline: scan is untrustworthy \
(panic={}, coverage_failed={}, incremental_cache_failed={}). \
Prior baseline left byte-identical.",
scanner_panicked, baseline_coverage_failed, incremental_cache_failed
);
for (reason, count) in crate::reporting::coverage_gap_summary(
&crate::reporting::CoverageCounts::current(),
) {
if count > 0 {
eprintln!(" coverage gap: {count} {reason}");
}
}
return Ok(std::process::ExitCode::from(exit));
}
let mut baseline = if path.exists() {
Baseline::load(path)?
} else {
Baseline::empty()
};
let new_findings = baseline.filter_new(&findings);
let had_new = !new_findings.is_empty();
baseline.merge(&findings);
baseline.save(path)?;
if show_progress {
eprintln!(
"\n📝 Baseline updated: added {} new entries at {}",
new_findings.len(),
path.display()
);
}
(new_findings, had_new)
} else if let Some(ref path) = self.args.baseline {
let baseline = Baseline::load(path)?;
let filtered_findings = baseline.filter_new(&findings);
let suppressed_count = findings.len() - filtered_findings.len();
let has_new = !filtered_findings.is_empty();
if show_progress && suppressed_count > 0 {
eprintln!("\n Suppressed {} baseline finding(s)", suppressed_count);
}
(filtered_findings, has_new)
} else {
let has_findings = !findings.is_empty();
(findings, has_findings)
};
let has_live_credentials = scan_exit_code(&report_findings) == EXIT_LIVE_CREDENTIALS;
if self.args.stream {
super::reporting::stream_report_previews(&report_findings);
}
let report_finished_at = chrono::Utc::now();
let report_metadata = crate::reporting::report_metadata_from_scan_run(
&self.args,
wall_start,
report_finished_at,
start.elapsed().as_millis(),
crate::SCANNED_CHUNKS.load(std::sync::atomic::Ordering::Relaxed),
crate::SCANNED_BYTES.load(std::sync::atomic::Ordering::Relaxed),
self.detectors.len(),
Some(crate::orchestrator_config::autoroute_config_digest(
&self.effective_config,
)),
);
let show_reporting_progress = show_progress
&& !self.args.stream
&& (self.args.output.is_some() || !std::io::stdout().is_terminal());
let report_finding_count = report_findings.len();
let reporting_progress = show_reporting_progress.then(|| {
TickerGuard::spawn("reporting", move |done, started| {
super::reporting::reporting_ticker(done, started, report_finding_count)
})
});
let report_result = crate::reporting::report_findings_with_metadata(
&report_findings,
&self.args,
&report_metadata,
);
if let Some(guard) = reporting_progress {
guard.stop();
}
report_result?;
let elapsed = start.elapsed().as_secs_f64();
if show_progress {
report_completion_summary(
&report_findings,
elapsed,
progress_ansi,
self.effective_config.backend_override,
);
} else {
report_skip_summary(false);
}
dump_dogfood_trace();
tracing::info!(
"Done in {:.1}s. {} findings",
elapsed,
report_findings.len()
);
let exit = resolve_scan_exit(ScanOutcome {
autoroute_calibration: self.args.autoroute_calibrate,
scanner_panicked,
has_live_credentials,
has_new_entries,
incremental_cache_failed,
source_coverage_incomplete,
});
if exit == EXIT_SOURCE_FAILED {
eprintln!(
"error: input coverage was incomplete (see coverage warnings above). Not \
reporting \"clean\": some requested bytes were not scanned."
);
}
Ok(std::process::ExitCode::from(exit))
}
}
pub(crate) fn scan_exit_code(findings: &[VerifiedFinding]) -> u8 {
if findings
.iter()
.any(|f| matches!(f.verification, VerificationResult::Live))
{
EXIT_LIVE_CREDENTIALS
} else {
EXIT_SUCCESS
}
}
fn source_coverage_incomplete() -> bool {
fail_class_coverage_gaps() > 0
}
fn baseline_coverage_untrustworthy() -> bool {
fail_class_coverage_gaps() > 0
}
fn fail_class_coverage_gaps() -> usize {
crate::reporting::CoverageCounts::current().fail_class_total()
}