keyhog-core 0.5.38

keyhog-core: shared data model and detector specifications for the KeyHog secret scanner
Documentation
//! Process-level memory protections.
//!
//! Two tiers:
//!
//! 1. **Always on** (`apply_default_protections`): zero-cost runtime
//!    settings that disable debugging features. No throughput impact, so
//!    they live outside the `lockdown` feature gate. Examples:
//!    - Linux: `prctl(PR_SET_DUMPABLE, 0)` - no core dumps, no
//!      `/proc/<pid>/mem` read, no `ptrace` attach from non-root.
//!    - macOS: `ptrace(PT_DENY_ATTACH, …)` - same intent.
//!    - Windows: best-effort process mitigation policy.
//!
//! 2. **Lockdown-only** (`apply_lockdown_protections`): protections that
//!    have a real cost or change runtime behavior. Examples:
//!    - `mlockall(MCL_CURRENT | MCL_FUTURE)` - pin all current and
//!      future allocations into RAM. Slows allocator paths and can be
//!      blocked by ulimits.
//!    - Refuse to run if `/proc/self/coredump_filter` allows anonymous
//!      pages (Linux).
//!    - Refuse to run if any persistence cache exists on disk.
//!
//! Callers that embed keyhog in security-critical contexts (EnvSeal,
//! lockdown-mode UIs) should call both. Callers using keyhog as a normal
//! triage tool only get the always-on tier.

#![allow(missing_docs)]

use std::path::PathBuf;

/// Outcome of a hardening attempt - collected so callers can log which
/// protections actually took.
#[derive(Debug, Default, Clone)]
pub struct HardeningReport {
    pub no_core_dumps: bool,
    pub no_ptrace: bool,
    pub mlocked: bool,
    pub coredump_filter_safe: bool,
    pub failures: Vec<String>,
}

/// Apply zero-cost process protections that should always be on for a
/// secret-scanning binary. Returns a report of what took.
///
/// Always safe to call - failures are logged and tallied but do not
/// abort. The same bits set twice are idempotent.
pub fn apply_default_protections() -> HardeningReport {
    let mut report = HardeningReport::default();

    #[cfg(target_os = "linux")]
    {
        // PR_SET_DUMPABLE = 0 disables: core dumps, ptrace, /proc/<pid>/mem
        // read by other processes, and the kernel's coredump_filter. This
        // is what every credential manager (gpg-agent, ssh-agent, etc) does
        // and it costs nothing - the kernel just sets a flag.
        // SAFETY: prctl is a documented syscall; failure is non-fatal.
        let rc = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
        if rc == 0 {
            report.no_core_dumps = true;
            report.no_ptrace = true;
        } else {
            let err = std::io::Error::last_os_error();
            report
                .failures
                .push(format!("prctl(PR_SET_DUMPABLE): {err}"));
        }
    }

    #[cfg(target_os = "macos")]
    {
        // PT_DENY_ATTACH on macOS prevents the calling process from being
        // attached by ptrace (lldb, dtrace). Same intent as Linux's
        // PR_SET_DUMPABLE. Best-effort.
        const PT_DENY_ATTACH: libc::c_int = 31;
        // SAFETY: documented sysctl; failure non-fatal.
        let rc = unsafe { libc::ptrace(PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) };
        if rc == 0 {
            report.no_ptrace = true;
            // macOS doesn't surface a separate "no core" knob; PT_DENY_ATTACH
            // implicitly disables that as well in practice.
            report.no_core_dumps = true;
        } else {
            let err = std::io::Error::last_os_error();
            report
                .failures
                .push(format!("ptrace(PT_DENY_ATTACH): {err}"));
        }
    }

    #[cfg(target_os = "windows")]
    {
        // SetProcessMitigationPolicy with ProcessSystemCallDisablePolicy
        // would deny dynamic loading of the Win32k subsystem; in practice
        // it's enough that we set DEP/CFG/etc which are default-on for
        // 64-bit binaries anyway. Mark as already-protected by platform.
        report.no_core_dumps = true;
        report.no_ptrace = true;
    }

    report
}

/// Apply protections that have a real cost or operational impact. Only
/// call from `lockdown` mode - these protections trade throughput and
/// flexibility for additional defense in depth.
///
/// Returns a report of what took. Callers should treat any `failures`
/// entry as a hard error in lockdown - it means a protection the user
/// asked for did not engage.
pub fn apply_lockdown_protections() -> HardeningReport {
    let mut report = apply_default_protections();

    #[cfg(target_os = "linux")]
    {
        // mlockall(MCL_CURRENT | MCL_FUTURE) pins every page of this
        // process - current heap + every future allocation - to RAM.
        // No swap to disk. Costs ~30% on allocator-heavy workloads but
        // guarantees credentials never hit a swap partition.
        // SAFETY: documented syscall; failure non-fatal.
        let rc = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
        if rc == 0 {
            report.mlocked = true;
        } else {
            let err = std::io::Error::last_os_error();
            report.failures.push(format!("mlockall: {err}"));
        }

        // Hard-kill any core dump regardless of coredump_filter by
        // setting RLIMIT_CORE to 0. The kernel refuses to write a core
        // file at all when the soft limit is 0, so anonymous pages can
        // never reach disk via the dump path. This makes lockdown a
        // true one-flag toggle: the user no longer has to pre-set the
        // coredump filter outside keyhog.
        // SAFETY: documented syscall; failure non-fatal (we still try
        // PR_SET_DUMPABLE in apply_default_protections).
        let rlim_zero = libc::rlimit {
            rlim_cur: 0,
            rlim_max: 0,
        };
        let rc = unsafe { libc::setrlimit(libc::RLIMIT_CORE, &rlim_zero) };
        if rc != 0 {
            let err = std::io::Error::last_os_error();
            report
                .failures
                .push(format!("setrlimit(RLIMIT_CORE, 0): {err}"));
        }

        // With RLIMIT_CORE=0 set above the kernel cannot write any core
        // file, so coredump_filter is moot. We still record what was
        // configured for observability, but a non-zero filter is no
        // longer a fatal failure - the rlimit covers it. Only escalate
        // when *both* RLIMIT_CORE could not be set AND the filter is
        // open, which is the only scenario where credentials could
        // actually reach disk.
        let filter = std::fs::read_to_string("/proc/self/coredump_filter")
            .ok()
            .and_then(|s| u32::from_str_radix(s.trim(), 16).ok());
        let rlimit_blocked = rc == 0;
        match filter {
            Some(0) => report.coredump_filter_safe = true,
            Some(_other) if rlimit_blocked => {
                // Filter is open but RLIMIT_CORE=0 prevents any dump.
                report.coredump_filter_safe = true;
            }
            Some(other) => report.failures.push(format!(
                "/proc/self/coredump_filter = 0x{other:x} - anonymous pages would be dumped; \
                 RLIMIT_CORE could not be set to 0 either. Set ulimit -c 0 in the parent shell."
            )),
            None => {
                if rlimit_blocked {
                    report.coredump_filter_safe = true;
                } else {
                    report
                        .failures
                        .push("could not read /proc/self/coredump_filter".into());
                }
            }
        }
    }

    #[cfg(not(target_os = "linux"))]
    {
        // mlockall isn't standardized on non-Linux Unix and Windows uses
        // VirtualLock + DEP. Mark mlocked as best-effort handled by the
        // platform's default protections.
        report.mlocked = false;
    }

    report
}

/// In lockdown mode, the engine refuses to start if a keyhog cache that could
/// expose PAST FINDINGS exists on disk - such caches survive across runs and
/// are exactly the "credentials accidentally written to disk" exfil vector
/// lockdown is supposed to prevent. Returns the offending paths, empty if clean.
///
/// NOT every file under `<cache>/keyhog` qualifies. The compiled Hyperscan
/// pattern database (`hs-*.db`) is the only thing keyhog writes there by
/// default; it holds the compiled DETECTOR AUTOMATON - regex shapes - with zero
/// scan findings or credentials, and keyhog (re)creates it early in startup.
/// Treating it as a violation made `--lockdown` self-defeating: the gate
/// tripped on keyhog's own freshly-compiled pattern DB on every machine, so the
/// flag could never run. Only findings-bearing caches (an incremental/merkle
/// cache written into this dir) are real lockdown violations.
#[must_use]
pub fn lockdown_disk_cache_violations() -> Vec<PathBuf> {
    let mut hits = Vec::new();
    if let Some(cache_root) = dirs::cache_dir() {
        let keyhog_root = cache_root.join("keyhog");
        let has_findings_cache = std::fs::read_dir(&keyhog_root)
            .map(|entries| {
                entries.filter_map(Result::ok).any(|e| {
                    let name = e.file_name();
                    let name = name.to_string_lossy();
                    // Compiled-pattern DBs (`hs-*.db`) carry no findings; any
                    // other file is a potential past-findings cache.
                    !(name.starts_with("hs-") && name.ends_with(".db"))
                })
            })
            .unwrap_or(false);
        if has_findings_cache {
            hits.push(keyhog_root);
        }
    }
    hits
}