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/`).