Skip to main content

keyhog_core/
hardening.rs

1//! Process-level memory protections.
2//!
3//! Two tiers:
4//!
5//! 1. **Always on** (`apply_protections(false)`): zero-cost runtime
6//!    settings that disable debugging features. No throughput impact, so
7//!    they live outside the `lockdown` feature gate. Examples:
8//!    - Linux: `prctl(PR_SET_DUMPABLE, 0)` - no core dumps, no
9//!      `/proc/<pid>/mem` read, no `ptrace` attach from non-root.
10//!    - macOS: `ptrace(PT_DENY_ATTACH, …)` - same intent.
11//!    - Windows: best-effort process mitigation policy.
12//!
13//! 2. **Lockdown-only** (`apply_protections(true)`): protections that
14//!    have a real cost or change runtime behavior. Examples:
15//!    - `mlockall(MCL_CURRENT | MCL_FUTURE)` - pin all current and
16//!      future allocations into RAM. Slows allocator paths and can be
17//!      blocked by ulimits.
18//!    - Refuse to run if `/proc/self/coredump_filter` allows anonymous
19//!      pages (Linux).
20//!    - Refuse to run if any persistence cache exists on disk.
21//!
22//! Callers that embed keyhog in security-critical contexts (EnvSeal,
23//! lockdown-mode UIs) should call both. Callers using keyhog as a normal
24//! triage tool only get the always-on tier.
25
26#![allow(missing_docs)]
27
28use std::ffi::OsStr;
29use std::io::Read;
30use std::path::{Path, PathBuf};
31
32use crate::hyperscan_cache::{HYPERSCAN_CACHE_PREFIX, HYPERSCAN_CACHE_SUFFIX};
33
34/// Outcome of a hardening attempt - collected so callers can log which
35/// protections actually took.
36#[derive(Debug, Default, Clone)]
37pub struct HardeningReport {
38    pub no_core_dumps: bool,
39    pub no_ptrace: bool,
40    pub mlocked: bool,
41    pub coredump_filter_safe: bool,
42    pub failures: Vec<String>,
43}
44
45/// Apply the process protections for the requested security mode.
46///
47/// When `lockdown` is true, this also fails closed on persisted keyhog cache
48/// artifacts that could expose past findings.
49#[must_use]
50pub fn apply_protections(lockdown: bool) -> HardeningReport {
51    apply_protections_with_persistence_paths(lockdown, std::iter::empty::<PathBuf>())
52}
53
54/// Apply process protections and, in lockdown mode, fail closed on known
55/// persistence artifacts outside the default keyhog cache root.
56#[must_use]
57pub fn apply_protections_with_persistence_paths<I, P>(
58    lockdown: bool,
59    persistence_paths: I,
60) -> HardeningReport
61where
62    I: IntoIterator<Item = P>,
63    P: AsRef<Path>,
64{
65    if !lockdown {
66        return apply_default_protections();
67    }
68
69    let mut report = apply_lockdown_protections();
70    for path in lockdown_disk_cache_violations_for_paths(persistence_paths) {
71        report.failures.push(format!(
72            "lockdown disk cache exists at {} and could expose past findings. \
73             Fix: remove it and rerun.",
74            path.display()
75        ));
76    }
77    report
78}
79
80/// Apply zero-cost process protections that should always be on for a
81/// secret-scanning binary. Returns a report of what took.
82///
83/// Always safe to call - failures are logged and tallied but do not
84/// abort. The same bits set twice are idempotent.
85fn apply_default_protections() -> HardeningReport {
86    let mut report = HardeningReport::default();
87
88    #[cfg(target_os = "linux")]
89    {
90        // PR_SET_DUMPABLE = 0 disables: core dumps, ptrace, /proc/<pid>/mem
91        // read by other processes, and the kernel's coredump_filter. This
92        // is what every credential manager (gpg-agent, ssh-agent, etc) does
93        // and it costs nothing - the kernel just sets a flag.
94        // SAFETY: prctl is a documented syscall; failure is non-fatal.
95        let rc = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
96        if rc == 0 {
97            report.no_core_dumps = true;
98            report.no_ptrace = true;
99        } else {
100            let err = std::io::Error::last_os_error();
101            report
102                .failures
103                .push(format!("prctl(PR_SET_DUMPABLE): {err}"));
104        }
105    }
106
107    #[cfg(target_os = "macos")]
108    {
109        // PT_DENY_ATTACH on macOS prevents the calling process from being
110        // attached by ptrace (lldb, dtrace). Same intent as Linux's
111        // PR_SET_DUMPABLE. Best-effort.
112        const PT_DENY_ATTACH: libc::c_int = 31;
113        // SAFETY: documented sysctl; failure non-fatal.
114        let rc = unsafe { libc::ptrace(PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) };
115        if rc == 0 {
116            report.no_ptrace = true;
117            // macOS doesn't surface a separate "no core" knob; PT_DENY_ATTACH
118            // implicitly disables that as well in practice.
119            report.no_core_dumps = true;
120        } else {
121            let err = std::io::Error::last_os_error();
122            report
123                .failures
124                .push(format!("ptrace(PT_DENY_ATTACH): {err}"));
125        }
126    }
127
128    #[cfg(target_os = "windows")]
129    {
130        // No syscall is wired here: SetProcessMitigationPolicy and WER
131        // dump-suppression need Win32 FFI this crate does not link. DEP/CFG are
132        // default-on for 64-bit images, but core-dump (WER) suppression and the
133        // ptrace-equivalent denial are NOT applied. Leave both flags false (their
134        // default) and record the gap so a caller logging the process posture is
135        // never told a protection took that didn't (Law 10).
136        report.failures.push(
137            "process mitigation policy not applied on Windows \
138             (SetProcessMitigationPolicy unwired); WER may still write a crash dump"
139                .to_string(),
140        );
141    }
142
143    report
144}
145
146/// Apply protections that have a real cost or operational impact. Only
147/// call from `lockdown` mode - these protections trade throughput and
148/// flexibility for additional defense in depth.
149///
150/// Returns a report of what took. Callers should treat any `failures`
151/// entry as a hard error in lockdown - it means a protection the user
152/// asked for did not engage.
153fn apply_lockdown_protections() -> HardeningReport {
154    let mut report = apply_default_protections();
155
156    #[cfg(target_os = "linux")]
157    {
158        // mlockall(MCL_CURRENT | MCL_FUTURE) pins every page of this
159        // process - current heap + every future allocation - to RAM.
160        // No swap to disk. Costs ~30% on allocator-heavy workloads but
161        // guarantees credentials never hit a swap partition.
162        // SAFETY: documented syscall; failure non-fatal.
163        let rc = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
164        if rc == 0 {
165            report.mlocked = true;
166        } else {
167            let err = std::io::Error::last_os_error();
168            report.failures.push(format!("mlockall: {err}"));
169        }
170
171        // Hard-kill any core dump regardless of coredump_filter by
172        // setting RLIMIT_CORE to 0. The kernel refuses to write a core
173        // file at all when the soft limit is 0, so anonymous pages can
174        // never reach disk via the dump path. This makes lockdown a
175        // true one-flag toggle: the user no longer has to pre-set the
176        // coredump filter outside keyhog.
177        // SAFETY: documented syscall; failure non-fatal (we still try
178        // PR_SET_DUMPABLE in apply_default_protections).
179        let rlim_zero = libc::rlimit {
180            rlim_cur: 0,
181            rlim_max: 0,
182        };
183        let rc = unsafe { libc::setrlimit(libc::RLIMIT_CORE, &rlim_zero) };
184        if rc != 0 {
185            let err = std::io::Error::last_os_error();
186            report
187                .failures
188                .push(format!("setrlimit(RLIMIT_CORE, 0): {err}"));
189        }
190
191        // With RLIMIT_CORE=0 set above the kernel cannot write any core
192        // file, so coredump_filter is moot. We still record what was
193        // configured for observability, but a non-zero filter is no
194        // longer a fatal failure - the rlimit covers it. Only escalate
195        // when *both* RLIMIT_CORE could not be set AND the filter is
196        // open, which is the only scenario where credentials could
197        // actually reach disk.
198        // A read/parse failure here yields `None`, and the `match filter` below
199        // escalates `None` to a `report.failures` entry (surfaced to the lockdown
200        // caller, which treats any `failures` entry as a hard error) UNLESS
201        // RLIMIT_CORE=0 already blocks any dump. The failure is recorded, never
202        // swallowed.
203        let filter = std::fs::read_to_string("/proc/self/coredump_filter")
204            .ok() // LAW10: None -> report.failures entry below; recorded, not swallowed
205            .and_then(|s| u32::from_str_radix(s.trim(), 16).ok()); // LAW10: parse failure -> report.failures below
206        let rlimit_blocked = rc == 0;
207        match filter {
208            Some(0) => report.coredump_filter_safe = true,
209            Some(_other) if rlimit_blocked => {
210                // Filter is open but RLIMIT_CORE=0 prevents any dump.
211                report.coredump_filter_safe = true;
212            }
213            Some(other) => report.failures.push(format!(
214                "/proc/self/coredump_filter = 0x{other:x} - anonymous pages would be dumped; \
215                 RLIMIT_CORE could not be set to 0 either. Set ulimit -c 0 in the parent shell."
216            )),
217            None => {
218                if rlimit_blocked {
219                    report.coredump_filter_safe = true;
220                } else {
221                    report
222                        .failures
223                        .push("could not read /proc/self/coredump_filter".into());
224                }
225            }
226        }
227    }
228
229    #[cfg(not(target_os = "linux"))]
230    {
231        // Lockdown's swap guarantee (credentials never reach a swap partition) is
232        // implemented via mlockall, which is Linux-only: macOS lacks mlockall and
233        // Windows needs per-region VirtualLock with a raised working-set quota,
234        // neither of which is wired. Fail closed (Law 10) instead of reporting a
235        // lock that never happened, push a failures entry so the lockdown caller
236        // (which treats any failure as hard) surfaces that memory pinning is
237        // unavailable on this platform rather than silently claiming success.
238        report.mlocked = false;
239        report.failures.push(format!(
240            "memory locking (mlockall) is unavailable on {}; lockdown cannot \
241             keep credentials out of swap on this platform",
242            std::env::consts::OS
243        ));
244    }
245
246    report
247}
248
249/// In lockdown mode, the engine refuses to start if a keyhog cache that could
250/// expose PAST FINDINGS exists on disk - such caches survive across runs and
251/// are exactly the "credentials accidentally written to disk" exfil vector
252/// lockdown is supposed to prevent. Returns the offending paths, empty if clean.
253///
254/// NOT every file under `<cache>/keyhog` qualifies. The compiled Hyperscan
255/// pattern database is the only thing keyhog writes there by default; it holds
256/// the compiled DETECTOR AUTOMATON - regex shapes - with zero scan findings or
257/// credentials, and keyhog (re)creates it early in startup. Treating it as a
258/// violation made `--lockdown` self-defeating: the gate tripped on keyhog's own
259/// freshly-compiled pattern DB on every machine, so the flag could never run.
260/// Only files with keyhog's exact `hs-<sha256>.db` shard name and `KHHS` cache
261/// header are trusted as compiled-pattern caches; everything else is a
262/// potential findings-bearing cache and therefore a lockdown violation.
263#[must_use]
264pub(crate) fn lockdown_disk_cache_violations() -> Vec<PathBuf> {
265    lockdown_disk_cache_violations_for_paths(std::iter::empty::<PathBuf>())
266}
267
268/// Return persisted keyhog cache artifacts that violate lockdown mode.
269///
270/// The default keyhog cache root is always checked. `persistence_paths` lets
271/// callers pass resolved custom Merkle/incremental cache paths that may live
272/// outside the default root.
273#[must_use]
274pub(crate) fn lockdown_disk_cache_violations_for_paths<I, P>(persistence_paths: I) -> Vec<PathBuf>
275where
276    I: IntoIterator<Item = P>,
277    P: AsRef<Path>,
278{
279    let mut hits = Vec::new();
280    if let Some(keyhog_root) = crate::keyhog_cache_root() {
281        let has_findings_cache = match std::fs::read_dir(&keyhog_root) {
282            Ok(entries) => keyhog_cache_contains_findings(&keyhog_root, entries),
283            // The cache dir simply not existing is the genuinely-clean case:
284            // there is no past-findings artifact to leak.
285            Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
286            // Law 10 / fail-closed for a SECURITY gate: any OTHER error (e.g. a
287            // permission denial on a directory that DOES exist) must NOT be read
288            // as "clean", that would let `--lockdown` start with an unaudited
289            // cache present. Surface it LOUDLY and treat the path as a violation
290            // so lockdown refuses to start rather than silently passing.
291            Err(e) => {
292                eprintln!(
293                    "keyhog: cannot inspect cache dir '{}' for past-findings artifacts: {e}; \
294                     refusing lockdown (fail-closed)",
295                    keyhog_root.display()
296                );
297                true
298            }
299        };
300        if has_findings_cache {
301            hits.push(keyhog_root);
302        }
303    }
304    for path in persistence_paths {
305        let path = path.as_ref();
306        if explicit_persistence_path_is_violation(path) {
307            push_unique_path(&mut hits, path.to_path_buf());
308        }
309    }
310    hits
311}
312
313fn explicit_persistence_path_is_violation(path: &Path) -> bool {
314    match std::fs::metadata(path) {
315        Ok(metadata) if metadata.is_dir() => match std::fs::read_dir(path) {
316            Ok(entries) => keyhog_cache_contains_findings(path, entries),
317            Err(error) => {
318                eprintln!(
319                    "keyhog: cannot inspect configured cache dir '{}' for past-findings \
320                     artifacts: {error}; refusing lockdown (fail-closed)",
321                    path.display()
322                );
323                true
324            }
325        },
326        Ok(_) => true,
327        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
328        Err(error) => {
329            eprintln!(
330                "keyhog: cannot inspect configured cache path '{}' for past-findings artifacts: \
331                 {error}; refusing lockdown (fail-closed)",
332                path.display()
333            );
334            true
335        }
336    }
337}
338
339fn push_unique_path(hits: &mut Vec<PathBuf>, path: PathBuf) {
340    if !hits.iter().any(|hit| hit == &path) {
341        hits.push(path);
342    }
343}
344
345fn keyhog_cache_contains_findings<I>(keyhog_root: &Path, entries: I) -> bool
346where
347    I: IntoIterator<Item = std::io::Result<std::fs::DirEntry>>,
348{
349    for entry in entries {
350        let entry = match entry {
351            Ok(entry) => entry,
352            Err(error) => {
353                eprintln!(
354                    "keyhog: cannot inspect an entry in cache dir '{}' for past-findings \
355                     artifacts: {error}; refusing lockdown (fail-closed)",
356                    keyhog_root.display()
357                );
358                return true;
359            }
360        };
361        match trusted_compiled_pattern_cache_entry(&entry) {
362            Ok(true) => {}
363            Ok(false) => return true,
364            Err(error) => {
365                eprintln!(
366                    "keyhog: cannot inspect candidate compiled-pattern cache entry '{}' in '{}' \
367                     for past-findings artifacts: {error}; refusing lockdown (fail-closed)",
368                    entry.file_name().to_string_lossy(),
369                    keyhog_root.display()
370                );
371                return true;
372            }
373        }
374    }
375    false
376}
377
378fn trusted_compiled_pattern_cache_entry(entry: &std::fs::DirEntry) -> std::io::Result<bool> {
379    if !compiled_pattern_cache_filename(&entry.file_name()) {
380        return Ok(false);
381    }
382    if !entry.file_type()?.is_file() {
383        return Ok(false);
384    }
385    compiled_pattern_cache_header_is_valid(&entry.path())
386}
387
388fn compiled_pattern_cache_filename(name: &OsStr) -> bool {
389    let Some(name) = name.to_str() else {
390        return false;
391    };
392    let Some(digest) = name
393        .strip_prefix(HYPERSCAN_CACHE_PREFIX)
394        .and_then(|s| s.strip_suffix(HYPERSCAN_CACHE_SUFFIX))
395    else {
396        return false;
397    };
398    digest.len() == crate::git_lfs::SHA256_HEX_LEN
399        && digest
400            .bytes()
401            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
402}
403
404fn compiled_pattern_cache_header_is_valid(path: &Path) -> std::io::Result<bool> {
405    let mut file = std::fs::File::open(path)?;
406    let mut header = [0_u8; crate::HYPERSCAN_CACHE_HEADER_LEN];
407    match file.read_exact(&mut header) {
408        Ok(()) => Ok(crate::hyperscan_cache_header_is_valid(&header)),
409        Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
410        Err(error) => Err(error),
411    }
412}
413
414pub(crate) fn lockdown_cache_entry_error_is_violation_for_test() -> bool {
415    let entries = std::iter::once(Err::<std::fs::DirEntry, std::io::Error>(
416        std::io::Error::new(std::io::ErrorKind::PermissionDenied, "entry denied"),
417    ));
418    keyhog_cache_contains_findings(Path::new("<test-cache>"), entries)
419}
420
421// Tests live in `tests/unit/hardening_sha256_len_single_owner.rs` (KH-GAP-004:
422// no inline test modules in `src/`).