use crate::args::ScanArgs;
#[cfg(unix)]
use crate::daemon::client;
#[cfg(unix)]
use crate::daemon::protocol::{Request, Response};
#[cfg(unix)]
use crate::daemon::server::default_socket_path;
use crate::orchestrator::ScanOrchestrator;
use anyhow::{bail, Result};
#[cfg(unix)]
use anyhow::Context;
#[cfg(unix)]
use keyhog_core::{
dedup_cross_detector, dedup_matches, RawMatch, RuleSuppressor, VerificationResult,
VerifiedFinding,
};
#[cfg(unix)]
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[cfg(unix)]
const EXIT_CREDENTIALS_FOUND: u8 = 1;
pub async fn run(args: ScanArgs) -> Result<ExitCode> {
#[cfg(not(unix))]
{
if args.daemon {
bail!(
"`--daemon` is a unix-only flag (the daemon serves scans \
over a Unix-domain socket). Drop the flag to run \
in-process, or pass `--no-daemon` to be explicit."
);
}
let orchestrator = ScanOrchestrator::new(args)?;
return orchestrator.run().await;
}
#[cfg(unix)]
let policy = EffectivePolicy::resolve(&args);
#[cfg(unix)]
match daemon_route(&args, &policy) {
DaemonRoute::Required => run_via_daemon(&args).await,
DaemonRoute::Opportunistic => match run_via_daemon(&args).await {
Ok(exit) => Ok(exit),
Err(e) => {
tracing::debug!(
error = %e,
"daemon auto-route unavailable; falling back to in-process scanner"
);
let orchestrator = ScanOrchestrator::new(args)?;
orchestrator.run().await
}
},
DaemonRoute::Forbidden => {
let orchestrator = ScanOrchestrator::new(args)?;
orchestrator.run().await
}
}
}
#[cfg(unix)]
enum DaemonRoute {
Required,
Opportunistic,
Forbidden,
}
#[cfg(unix)]
struct EffectivePolicy {
min_confidence: Option<f64>,
show_secrets: bool,
severity: bool,
require_lockdown: bool,
}
#[cfg(unix)]
impl EffectivePolicy {
fn resolve(args: &ScanArgs) -> EffectivePolicy {
let mut probe = args.clone();
if probe.path.is_none() {
probe.path = probe.input.clone();
}
let outcome = crate::config::apply_config_file_quiet(&mut probe);
EffectivePolicy {
min_confidence: probe.min_confidence,
show_secrets: probe.show_secrets,
severity: probe.severity.is_some(),
require_lockdown: outcome.require_lockdown,
}
}
}
#[cfg(unix)]
fn daemon_route(args: &ScanArgs, policy: &EffectivePolicy) -> DaemonRoute {
if args.no_daemon {
return DaemonRoute::Forbidden;
}
#[cfg(feature = "verify")]
if args.verify {
if args.daemon {
tracing::warn!(
"--verify forces the in-process path (daemon has no verifier); --daemon ignored"
);
}
return DaemonRoute::Forbidden;
}
if args.baseline.is_some() {
if args.daemon {
tracing::warn!(
"--baseline forces the in-process path (daemon has no CLI-side state); --daemon ignored"
);
}
return DaemonRoute::Forbidden;
}
let is_eligible_shape = args.stdin || effective_single_file_path(args).is_some();
if !is_eligible_shape {
if args.daemon {
tracing::warn!(
"--daemon only supports --stdin or a single regular file (no directories, archives, git, http sources); falling back to in-process"
);
}
return DaemonRoute::Forbidden;
}
if args.lockdown
|| policy.require_lockdown
|| policy.show_secrets
|| policy.severity
|| policy.min_confidence.is_some()
|| args.hide_client_safe
{
if args.daemon {
tracing::warn!(
"--daemon ignored: this scan requests filtering/lockdown/secret-output \
policy the daemon cannot enforce (CLI flag or .keyhog.toml); \
using the in-process path"
);
}
return DaemonRoute::Forbidden;
}
if args.daemon {
return DaemonRoute::Required;
}
if default_socket_path().exists() {
DaemonRoute::Opportunistic
} else {
DaemonRoute::Forbidden
}
}
#[cfg(unix)]
fn effective_single_file_path(args: &ScanArgs) -> Option<&Path> {
let raw = args.path.as_deref().or(args.input.as_deref())?;
let meta = std::fs::metadata(raw).ok()?;
if !meta.is_file() {
return None;
}
if raw
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("har"))
.unwrap_or(false)
{
return None;
}
Some(raw)
}
#[cfg(unix)]
async fn run_via_daemon(args: &ScanArgs) -> Result<ExitCode> {
let socket = default_socket_path();
let mut conn = client::connect(&socket).await.with_context(|| {
format!(
"daemon route: connect to {} (start one with `keyhog daemon start` or pass --no-daemon)",
socket.display()
)
})?;
let matches = if args.stdin {
let text = read_stdin_to_string()?;
let resp = conn
.round_trip(&Request::ScanText { path: None, text })
.await?;
unwrap_scan_results(resp)?
} else if let Some(path) = effective_single_file_path(args) {
let working_dir = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().into_owned());
let resp = conn
.round_trip(&Request::ScanPath {
path: path.to_string_lossy().into_owned(),
working_dir,
})
.await?;
unwrap_scan_results(resp)?
} else {
bail!(
"daemon route requires either --stdin or a single file path. \
For directory scans, pass `--no-daemon` to use the in-process scanner."
);
};
let findings = finalize_for_report(matches, args);
crate::reporting::report_findings(&findings, args)?;
if findings.is_empty() {
Ok(ExitCode::SUCCESS)
} else {
Ok(ExitCode::from(EXIT_CREDENTIALS_FOUND))
}
}
#[cfg(unix)]
fn read_stdin_to_string() -> Result<String> {
use std::io::Read;
const STDIN_CAP_BYTES: usize = 10 * 1024 * 1024;
let mut buf = Vec::with_capacity(8 * 1024);
std::io::stdin()
.lock()
.take(STDIN_CAP_BYTES as u64 + 1)
.read_to_end(&mut buf)
.context("daemon route: reading stdin")?;
if buf.len() > STDIN_CAP_BYTES {
bail!(
"daemon route: stdin exceeds {STDIN_CAP_BYTES} byte limit. \
Drop `--daemon` to use the streaming in-process path."
);
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
#[cfg(unix)]
fn unwrap_scan_results(resp: Response) -> Result<Vec<RawMatch>> {
match resp {
Response::ScanResults {
matches,
engine_example_suppressions,
dogfood_events,
..
} => {
if engine_example_suppressions > 0 {
keyhog_scanner::telemetry::add_example_suppressions(
engine_example_suppressions as usize,
);
}
if !dogfood_events.is_empty() {
keyhog_scanner::telemetry::append_events(dogfood_events);
}
Ok(matches)
}
Response::Error { message } => bail!("daemon: {message}"),
other => bail!("daemon route: expected ScanResults, got {other:?}"),
}
}
#[cfg(unix)]
fn finalize_for_report(matches: Vec<RawMatch>, args: &ScanArgs) -> Vec<VerifiedFinding> {
let fixtures = if args.no_suppress_test_fixtures {
crate::test_fixture_suppressions::TestFixtureSuppressions::empty()
} else {
crate::test_fixture_suppressions::TestFixtureSuppressions::bundled()
};
let allowlist = load_daemon_allowlist(args);
let mut matches: Vec<RawMatch> = matches
.into_iter()
.filter(|m| {
if fixtures.suppresses(&m.credential) {
keyhog_scanner::telemetry::record_example_suppression(
m.detector_id.as_ref(),
m.location.file_path.as_deref(),
&m.credential,
"test_fixture_suppression",
);
return false;
}
if let Some(path) = m.location.file_path.as_deref() {
if allowlist.is_path_ignored(path) {
return false;
}
}
if allowlist.is_hash_ignored(&m.credential_hash) {
return false;
}
if allowlist.ignored_detectors.contains(&*m.detector_id) {
return false;
}
true
})
.collect();
for m in &mut matches {
if m.location.file_path.is_some() && m.location.source.as_ref() != "filesystem" {
m.location.source = std::sync::Arc::from("filesystem");
}
}
let mut matches = crate::inline_suppression::filter_inline_suppressions(matches);
matches.sort_by_key(|m| std::cmp::Reverse(m.severity));
let scope = args.dedup.to_core();
let deduped = dedup_matches(matches, &scope);
let deduped = dedup_cross_detector(deduped);
let findings: Vec<VerifiedFinding> = deduped
.into_iter()
.map(|m| VerifiedFinding {
detector_id: m.detector_id,
detector_name: m.detector_name,
service: m.service,
severity: m.severity,
credential_redacted: if args.show_secrets {
m.credential.to_string().into()
} else {
keyhog_core::redact(&m.credential)
},
credential_hash: m.credential_hash,
location: m.primary_location,
verification: VerificationResult::Skipped,
metadata: crate::orchestrator::offline_finding_metadata(&m.credential),
additional_locations: m.additional_locations,
confidence: m.confidence,
})
.collect();
let rule_suppressor = load_daemon_rule_suppressor(args);
if rule_suppressor.is_empty() {
return findings;
}
findings
.into_iter()
.filter(|f| !rule_suppressor.matches(f))
.collect()
}
#[cfg(unix)]
fn daemon_allowlist_root(args: &ScanArgs) -> PathBuf {
let Some(path) = args.path.as_deref().or(args.input.as_deref()) else {
return PathBuf::from(".");
};
if path.is_dir() {
return path.to_path_buf();
}
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
}
#[cfg(unix)]
fn load_daemon_allowlist(args: &ScanArgs) -> keyhog_core::allowlist::Allowlist {
let ignore_path = daemon_allowlist_root(args).join(".keyhogignore");
if ignore_path.exists() {
keyhog_core::allowlist::Allowlist::load(&ignore_path)
.unwrap_or_else(|_| keyhog_core::allowlist::Allowlist::empty())
} else {
keyhog_core::allowlist::Allowlist::empty()
}
}
#[cfg(unix)]
fn load_daemon_rule_suppressor(args: &ScanArgs) -> RuleSuppressor {
let toml_path = daemon_allowlist_root(args).join(".keyhogignore.toml");
match RuleSuppressor::load(&toml_path) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
file = %toml_path.display(),
error = %e,
"daemon route: failed to load .keyhogignore.toml; ignoring rules. \
Fix: validate the TOML schema (see docs/keyhogignore-toml.md)."
);
RuleSuppressor::empty()
}
}
}