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/// MatcherArtifact `.khm` files live under `keyhog-matcher-artifacts/` (sibling
264/// of `keyhog/`). That sibling is inspected with the same fail-closed rules:
265/// only files with a trusted compiled-pattern header are allowed.
266#[must_use]
267pub(crate) fn lockdown_disk_cache_violations() -> Vec<PathBuf> {
268 lockdown_disk_cache_violations_for_paths(std::iter::empty::<PathBuf>())
269}
270
271/// Return persisted keyhog cache artifacts that violate lockdown mode.
272///
273/// The default keyhog cache root and the MatcherArtifact sibling root are
274/// always checked. `persistence_paths` lets callers pass resolved custom
275/// Merkle/incremental cache paths that may live outside those defaults.
276#[must_use]
277pub(crate) fn lockdown_disk_cache_violations_for_paths<I, P>(persistence_paths: I) -> Vec<PathBuf>
278where
279 I: IntoIterator<Item = P>,
280 P: AsRef<Path>,
281{
282 let mut hits = Vec::new();
283 for root in [
284 crate::keyhog_cache_root(),
285 crate::keyhog_matcher_artifacts_root(),
286 ]
287 .into_iter()
288 .flatten()
289 {
290 let has_findings_cache = match std::fs::read_dir(&root) {
291 Ok(entries) => keyhog_cache_contains_findings(&root, entries),
292 // The cache dir simply not existing is the genuinely-clean case:
293 // there is no past-findings artifact to leak.
294 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
295 // Law 10 / fail-closed for a SECURITY gate: any OTHER error (e.g. a
296 // permission denial on a directory that DOES exist) must NOT be read
297 // as "clean", that would let `--lockdown` start with an unaudited
298 // cache present. Surface it LOUDLY and treat the path as a violation
299 // so lockdown refuses to start rather than silently passing.
300 Err(e) => {
301 eprintln!(
302 "keyhog: cannot inspect cache dir '{}' for past-findings artifacts: {e}; \
303 refusing lockdown (fail-closed)",
304 root.display()
305 );
306 true
307 }
308 };
309 if has_findings_cache {
310 hits.push(root);
311 }
312 }
313 for path in persistence_paths {
314 let path = path.as_ref();
315 if explicit_persistence_path_is_violation(path) {
316 push_unique_path(&mut hits, path.to_path_buf());
317 }
318 }
319 hits
320}
321
322fn explicit_persistence_path_is_violation(path: &Path) -> bool {
323 match std::fs::metadata(path) {
324 Ok(metadata) if metadata.is_dir() => match std::fs::read_dir(path) {
325 Ok(entries) => keyhog_cache_contains_findings(path, entries),
326 Err(error) => {
327 eprintln!(
328 "keyhog: cannot inspect configured cache dir '{}' for past-findings \
329 artifacts: {error}; refusing lockdown (fail-closed)",
330 path.display()
331 );
332 true
333 }
334 },
335 Ok(_) => true,
336 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
337 Err(error) => {
338 eprintln!(
339 "keyhog: cannot inspect configured cache path '{}' for past-findings artifacts: \
340 {error}; refusing lockdown (fail-closed)",
341 path.display()
342 );
343 true
344 }
345 }
346}
347
348fn push_unique_path(hits: &mut Vec<PathBuf>, path: PathBuf) {
349 if !hits.iter().any(|hit| hit == &path) {
350 hits.push(path);
351 }
352}
353
354fn keyhog_cache_contains_findings<I>(keyhog_root: &Path, entries: I) -> bool
355where
356 I: IntoIterator<Item = std::io::Result<std::fs::DirEntry>>,
357{
358 for entry in entries {
359 let entry = match entry {
360 Ok(entry) => entry,
361 Err(error) => {
362 eprintln!(
363 "keyhog: cannot inspect an entry in cache dir '{}' for past-findings \
364 artifacts: {error}; refusing lockdown (fail-closed)",
365 keyhog_root.display()
366 );
367 return true;
368 }
369 };
370 match trusted_compiled_pattern_cache_entry(&entry) {
371 Ok(true) => {}
372 Ok(false) => return true,
373 Err(error) => {
374 eprintln!(
375 "keyhog: cannot inspect candidate compiled-pattern cache entry '{}' in '{}' \
376 for past-findings artifacts: {error}; refusing lockdown (fail-closed)",
377 entry.file_name().to_string_lossy(),
378 keyhog_root.display()
379 );
380 return true;
381 }
382 }
383 }
384 false
385}
386
387fn trusted_compiled_pattern_cache_entry(entry: &std::fs::DirEntry) -> std::io::Result<bool> {
388 let name = entry.file_name();
389 let is_matcher = matcher_artifact_cache_filename(&name);
390 let is_hyperscan = hyperscan_compiled_cache_filename(&name);
391 if !is_matcher && !is_hyperscan {
392 return Ok(false);
393 }
394 if !entry.file_type()?.is_file() {
395 return Ok(false);
396 }
397 // MatcherArtifact .khm files are only trusted under the dedicated sibling
398 // root. The same filename under `<cache>/keyhog` must still fail closed so
399 // a findings-bearing file cannot hide behind a KHMA prefix.
400 if is_matcher {
401 let path = entry.path();
402 let parent_name = path
403 .parent()
404 .and_then(|parent| parent.file_name())
405 .and_then(|name| name.to_str());
406 if parent_name != Some(crate::KEYHOG_MATCHER_ARTIFACTS_SUBDIR) {
407 return Ok(false);
408 }
409 }
410 compiled_pattern_cache_header_is_valid(&entry.path())
411}
412
413fn hyperscan_compiled_cache_filename(name: &OsStr) -> bool {
414 let Some(name) = name.to_str() else {
415 return false;
416 };
417 let Some(digest) = name
418 .strip_prefix(HYPERSCAN_CACHE_PREFIX)
419 .and_then(|s| s.strip_suffix(HYPERSCAN_CACHE_SUFFIX))
420 else {
421 return false;
422 };
423 digest.len() == crate::git_lfs::SHA256_HEX_LEN
424 && digest
425 .bytes()
426 .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
427}
428
429fn matcher_artifact_cache_filename(name: &OsStr) -> bool {
430 let Some(name) = name.to_str() else {
431 return false;
432 };
433 let Some(digest) = name
434 .strip_prefix(crate::MATCHER_ARTIFACT_FILENAME_PREFIX)
435 .and_then(|s| s.strip_suffix(crate::MATCHER_ARTIFACT_SUFFIX))
436 else {
437 return false;
438 };
439 digest.len() == crate::git_lfs::SHA256_HEX_LEN
440 && digest
441 .bytes()
442 .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
443}
444
445fn compiled_pattern_cache_header_is_valid(path: &Path) -> std::io::Result<bool> {
446 let mut file = std::fs::File::open(path)?;
447 if path
448 .extension()
449 .and_then(|ext| ext.to_str())
450 .is_some_and(|ext| ext == crate::MATCHER_ARTIFACT_SUFFIX.trim_start_matches('.'))
451 {
452 // Magic + current format version (parity with Hyperscan's exact
453 // header check). Stale versions fail closed under lockdown; operators
454 // clear leftover `.khm` files (uninstall surfaces the cache root).
455 let mut header = [0_u8; 8];
456 match file.read_exact(&mut header) {
457 Ok(()) => {
458 let magic_ok = &header[..4] == crate::MATCHER_ARTIFACT_MAGIC;
459 let version = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
460 Ok(magic_ok && version == crate::MATCHER_ARTIFACT_FORMAT_VERSION)
461 }
462 Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
463 Err(error) => Err(error),
464 }
465 } else {
466 let mut header = [0_u8; crate::HYPERSCAN_CACHE_HEADER_LEN];
467 match file.read_exact(&mut header) {
468 Ok(()) => Ok(crate::hyperscan_cache_header_is_valid(&header)),
469 Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
470 Err(error) => Err(error),
471 }
472 }
473}
474
475pub(crate) fn lockdown_cache_entry_error_is_violation_for_test() -> bool {
476 let entries = std::iter::once(Err::<std::fs::DirEntry, std::io::Error>(
477 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "entry denied"),
478 ));
479 keyhog_cache_contains_findings(Path::new("<test-cache>"), entries)
480}
481
482// Tests live in `tests/unit/hardening_sha256_len_single_owner.rs` (KH-GAP-004:
483// no inline test modules in `src/`).