mod mounts;
use crate::args::ScanSystemArgs;
use crate::format::format_bytes;
use anyhow::{Context, Result};
use keyhog_scanner::CompiledScanner;
use mounts::enumerate_mounts;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
#[doc(hidden)]
pub const MAX_RESIDENT_FINDINGS: usize = 1_000_000;
#[doc(hidden)]
pub struct FindingSink {
redacted: Vec<keyhog_core::RedactedFinding>,
total: u64,
cap: usize,
capped_warned: bool,
}
impl FindingSink {
#[doc(hidden)]
pub fn new() -> Self {
Self::with_cap(MAX_RESIDENT_FINDINGS)
}
#[doc(hidden)]
pub fn with_cap(cap: usize) -> Self {
Self {
redacted: Vec::new(),
total: 0,
cap,
capped_warned: false,
}
}
#[doc(hidden)]
pub fn absorb(&mut self, matches: Vec<keyhog_core::RawMatch>) {
for m in &matches {
self.total += 1;
if self.redacted.len() < self.cap {
self.redacted.push(m.to_redacted());
} else if !self.capped_warned {
self.capped_warned = true;
eprintln!(
"⚠ resident findings cap ({}) reached; further findings are \
counted but not retained in memory",
self.cap
);
}
}
}
#[doc(hidden)]
pub fn is_empty(&self) -> bool {
self.total == 0
}
#[doc(hidden)]
pub fn total(&self) -> u64 {
self.total
}
#[doc(hidden)]
pub fn retained_len(&self) -> usize {
self.redacted.len()
}
#[doc(hidden)]
pub fn cap(&self) -> usize {
self.cap
}
#[doc(hidden)]
pub fn capped_warned(&self) -> bool {
self.capped_warned
}
#[doc(hidden)]
pub fn retained_hash(&self, index: usize) -> Option<[u8; 32]> {
self.redacted
.get(index)
.map(|finding| finding.credential_hash)
}
#[doc(hidden)]
pub fn retained_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&self.redacted)
}
}
pub fn run(args: ScanSystemArgs) -> Result<ExitCode> {
if args.space == 0 {
anyhow::bail!("scan-system --space must be greater than zero bytes");
}
let hw = keyhog_scanner::hw_probe::probe_hardware();
crate::orchestrator_config::configure_threads(args.threads, hw.physical_cores);
if args.lockdown && args.include_network {
anyhow::bail!(
"lockdown mode forbids --include-network (would scan NFS/SMB/sshfs \
mounts that may host other tenants' credentials)."
);
}
eprintln!(
"🛰 keyhog scan-system | space cap: {} | network mounts: {} | git history: {}",
format_bytes(args.space),
if args.include_network { "yes" } else { "no" },
if args.no_git_history { "no" } else { "yes" },
);
if args.lockdown {
let lockdown = keyhog_core::hardening::apply_lockdown_protections();
if !lockdown.failures.is_empty() {
anyhow::bail!(
"lockdown mode requested but protections failed to apply: {:?}",
lockdown.failures
);
}
eprintln!("🔒 LOCKDOWN MODE: coredump-blocked, mlocked, network mounts refused");
}
let report = keyhog_core::hardening::apply_default_protections();
if !report.failures.is_empty() {
eprintln!("⚠ hardening warnings: {:?}", report.failures);
}
eprintln!(
"🔒 core_dumps={} ptrace={} (always-on protections applied)",
if report.no_core_dumps { "off" } else { "on" },
if report.no_ptrace {
"denied"
} else {
"allowed"
},
);
let detectors = crate::orchestrator_config::load_detectors_or_embedded(&args.detectors)?;
eprintln!("📋 loaded {} detectors", detectors.len());
let scanner = Arc::new(
CompiledScanner::compile(detectors)
.map_err(|e| anyhow::anyhow!("scanner compile failed: {e:?}"))?,
);
scanner.warm();
let mounts = enumerate_mounts(args.include_network)?;
eprintln!("💾 will scan {} mount(s):", mounts.len());
for m in &mounts {
eprintln!(" {}", m.display());
}
let mut git_repos: Vec<PathBuf> = Vec::new();
if !args.no_git_history {
for mount in &mounts {
discover_git_repos(mount, &mut git_repos, args.space);
}
eprintln!("🌿 discovered {} git repo(s)", git_repos.len());
}
let bytes_scanned = Arc::new(AtomicU64::new(0));
let space_cap = args.space;
let mut sink = FindingSink::new();
for mount in &mounts {
if bytes_scanned.load(Ordering::Relaxed) >= space_cap {
eprintln!(
"⚠ space cap reached ({}); skipping remaining mounts",
format_bytes(space_cap)
);
break;
}
eprintln!("→ walking {}", mount.display());
scan_mount(&scanner, mount, &args, &bytes_scanned, space_cap, &mut sink);
}
if !args.no_git_history {
for repo in &git_repos {
if bytes_scanned.load(Ordering::Relaxed) >= space_cap {
eprintln!("⚠ space cap reached; skipping remaining git histories");
break;
}
eprintln!("→ git history: {}", repo.display());
scan_git_history(&scanner, repo, &bytes_scanned, space_cap, &mut sink);
}
}
eprintln!(
"✅ system scan complete | bytes scanned: {} | findings: {}",
format_bytes(bytes_scanned.load(Ordering::Relaxed)),
sink.total
);
if let Some(out) = &args.output {
let json = serde_json::to_string_pretty(&sink.redacted).context("serialize findings")?;
std::fs::write(out, json).with_context(|| format!("write {}", out.display()))?;
eprintln!("📄 wrote findings to {}", out.display());
} else {
for m in &sink.redacted {
println!(
"🔍 {} {}{} {:?} {}",
m.detector_id,
m.location.file_path.as_deref().unwrap_or("<no-path>"),
m.location.line.map(|l| format!(":{l}")).unwrap_or_default(),
m.severity,
m.credential_redacted
);
}
}
if sink.is_empty() {
Ok(ExitCode::SUCCESS)
} else {
Ok(ExitCode::from(1))
}
}
fn discover_git_repos(root: &Path, out: &mut Vec<PathBuf>, _space_cap: u64) {
use std::collections::HashSet;
use std::fs;
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut stack: Vec<PathBuf> = Vec::new();
if let Ok(canon) = fs::canonicalize(root) {
stack.push(canon);
} else {
return;
}
while let Some(dir) = stack.pop() {
if !visited.insert(dir.clone()) {
continue;
}
let dot_git = dir.join(".git");
if dot_git.exists() {
out.push(dir.clone());
continue;
}
if dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".git"))
&& dir.join("HEAD").exists()
&& dir.join("objects").exists()
{
out.push(dir.clone());
continue;
}
if let Some(name) = dir.file_name().and_then(|n| n.to_str()) {
if matches!(
name,
"node_modules"
| "target"
| ".cargo"
| ".cache"
| "Library"
| "AppData"
| "$Recycle.Bin"
| "System Volume Information"
) {
continue;
}
}
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
if let Ok(canon) = fs::canonicalize(entry.path()) {
if !visited.contains(&canon) {
stack.push(canon);
}
}
}
}
}
}
}
fn scan_mount(
scanner: &CompiledScanner,
root: &Path,
args: &ScanSystemArgs,
bytes_scanned: &AtomicU64,
space_cap: u64,
out: &mut FindingSink,
) {
use keyhog_core::Source;
use keyhog_sources::FilesystemSource;
let source =
FilesystemSource::new(root.to_path_buf()).with_respect_gitignore(args.respect_gitignore);
for chunk_result in source.chunks() {
if bytes_scanned.load(Ordering::Relaxed) >= space_cap {
return;
}
let chunk = match chunk_result {
Ok(c) => c,
Err(_) => continue,
};
bytes_scanned.fetch_add(chunk.data.len() as u64, Ordering::Relaxed);
out.absorb(scanner.scan(&chunk));
}
}
fn scan_git_history(
scanner: &CompiledScanner,
repo: &Path,
bytes_scanned: &AtomicU64,
space_cap: u64,
out: &mut FindingSink,
) {
#[cfg(feature = "git")]
{
use keyhog_core::Source;
let source = keyhog_sources::GitSource::new(repo.to_path_buf());
for chunk_result in source.chunks() {
if bytes_scanned.load(Ordering::Relaxed) >= space_cap {
return;
}
let chunk = match chunk_result {
Ok(c) => c,
Err(_) => continue,
};
bytes_scanned.fetch_add(chunk.data.len() as u64, Ordering::Relaxed);
out.absorb(scanner.scan(&chunk));
}
}
#[cfg(not(feature = "git"))]
{
let _ = (scanner, repo, bytes_scanned, space_cap, out);
tracing::warn!("git history scan requires the `git` feature; skipping");
}
}