murk_cli/hardening.rs
1//! Process hardening: best-effort defense-in-depth measures.
2
3/// Disable core dumps for this process.
4///
5/// Sets `RLIMIT_CORE` to 0 on Unix. A core file written after a crash while
6/// murk holds decrypted secret material is the worst possible leak — disabling
7/// it up front removes the failure mode regardless of system defaults, which
8/// vary across Linux distros, macOS, and BSDs.
9///
10/// Best-effort: a failed syscall is swallowed rather than blocking the
11/// command. Idempotent — a second call on a process that's already at zero is
12/// a no-op. On non-Unix targets this is a no-op.
13pub fn disable_core_dumps() {
14 #[cfg(unix)]
15 {
16 let limit = libc::rlimit {
17 rlim_cur: 0,
18 rlim_max: 0,
19 };
20 // SAFETY: setrlimit reads through the pointer for the duration of
21 // the call; `limit` lives for that scope. Return code is intentionally
22 // ignored — see the doc comment.
23 unsafe {
24 libc::setrlimit(libc::RLIMIT_CORE, &raw const limit);
25 }
26 }
27}
28
29/// Whether `MURK_STRICT` is enabled. Strict mode trades convenience for a
30/// hard "never let a secret touch the disk" guarantee — see [`is_ram_backed`].
31///
32/// On for `1`, `true`, or `yes` (case-insensitive). Off when unset, empty, or
33/// any other value (including `0`). This is the user/session-level strict
34/// toggle; vault-declared policy will later flip the same behaviors.
35pub fn strict_mode() -> bool {
36 strict_from(&std::env::var("MURK_STRICT").unwrap_or_default())
37}
38
39/// Parse a `MURK_STRICT` value. Split out from [`strict_mode`] so the truthiness
40/// rules are testable without mutating process-global env state.
41fn strict_from(val: &str) -> bool {
42 matches!(
43 val.trim().to_ascii_lowercase().as_str(),
44 "1" | "true" | "yes"
45 )
46}
47
48/// Whether `path` lives on a RAM-backed filesystem (tmpfs/ramfs), meaning data
49/// written there never hits persistent storage.
50///
51/// Used to fail closed in strict mode before `murk edit` writes a decrypted
52/// secret to a scratch file: a best-effort overwrite-and-unlink can't undo a
53/// write to a journaled or copy-on-write disk, and editors leave their own swap
54/// files behind, so the only real guarantee is to never write to disk at all.
55///
56/// On Linux a tmpfs/ramfs mount is identified by its `statfs` magic number. On
57/// macOS there is no tmpfs by default, so this returns `false` for the usual
58/// `/tmp` — which is the honest answer. On non-Unix targets this returns
59/// `false` (assume disk-backed; we can't prove otherwise).
60pub fn is_ram_backed(path: &std::path::Path) -> bool {
61 #[cfg(target_os = "linux")]
62 {
63 use std::os::unix::ffi::OsStrExt;
64 // TMPFS_MAGIC and RAMFS_MAGIC from <linux/magic.h>.
65 const TMPFS_MAGIC: libc::c_long = 0x0102_1994;
66 const RAMFS_MAGIC: libc::c_long = 0x858_458f6_u64 as libc::c_long;
67
68 let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
69 return false;
70 };
71 // SAFETY: zeroed statfs is a valid initial state; statfs writes into it
72 // for the duration of the call, and `c_path` outlives the call.
73 let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
74 let rc = unsafe { libc::statfs(c_path.as_ptr(), &raw mut buf) };
75 rc == 0 && matches!(buf.f_type as libc::c_long, TMPFS_MAGIC | RAMFS_MAGIC)
76 }
77 #[cfg(target_os = "macos")]
78 {
79 use std::os::unix::ffi::OsStrExt;
80 let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
81 return false;
82 };
83 // SAFETY: as above; macOS statfs reports the filesystem type by name.
84 let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
85 let rc = unsafe { libc::statfs(c_path.as_ptr(), &raw mut buf) };
86 if rc != 0 {
87 return false;
88 }
89 // f_fstypename is a fixed-size [c_char] holding a NUL-terminated name.
90 let name: Vec<u8> = buf
91 .f_fstypename
92 .iter()
93 .take_while(|&&c| c != 0)
94 .map(|&c| c.cast_unsigned())
95 .collect();
96 name == b"tmpfs"
97 }
98 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
99 {
100 let _ = path;
101 false
102 }
103}
104
105/// Whether this process's stdout is a regular file (as opposed to a pipe,
106/// terminal, or device).
107///
108/// Strict mode uses this to catch `murk export > secrets.env` style redirects
109/// that would persist plaintext secrets to disk, while still allowing the
110/// `eval "$(murk export)"` pipe that direnv relies on. Unix-only; returns
111/// `false` elsewhere (can't determine — don't block).
112pub fn stdout_is_regular_file() -> bool {
113 #[cfg(unix)]
114 {
115 use std::os::unix::io::{AsRawFd, FromRawFd};
116 // Borrow stdout's fd as a File to read its metadata. ManuallyDrop keeps
117 // dropping the File from closing the real stdout — we only borrowed it.
118 let fd = std::io::stdout().as_raw_fd();
119 let f = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
120 f.metadata().is_ok_and(|m| m.is_file())
121 }
122 #[cfg(not(unix))]
123 {
124 false
125 }
126}
127
128#[cfg(all(test, unix))]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn disables_core_dumps() {
134 disable_core_dumps();
135
136 let mut current = libc::rlimit {
137 rlim_cur: 1,
138 rlim_max: 1,
139 };
140 // SAFETY: getrlimit writes into `&mut current` for the duration of
141 // the call.
142 let rc = unsafe { libc::getrlimit(libc::RLIMIT_CORE, &raw mut current) };
143 assert_eq!(rc, 0, "getrlimit failed");
144 assert_eq!(current.rlim_cur, 0);
145 assert_eq!(current.rlim_max, 0);
146 }
147
148 #[test]
149 fn strict_truthiness() {
150 for on in ["1", "true", "yes", "YES", " True ", "Yes"] {
151 assert!(strict_from(on), "{on:?} should enable strict mode");
152 }
153 for off in ["", "0", "false", "no", "off", "enabled", "2"] {
154 assert!(!strict_from(off), "{off:?} should not enable strict mode");
155 }
156 }
157
158 #[test]
159 fn nonexistent_path_is_not_ram_backed() {
160 // statfs fails on a missing path; we must not report it as RAM-backed.
161 assert!(!is_ram_backed(std::path::Path::new(
162 "/no/such/murk/path/exists"
163 )));
164 }
165
166 #[cfg(target_os = "linux")]
167 #[test]
168 fn dev_shm_is_ram_backed() {
169 let shm = std::path::Path::new("/dev/shm");
170 if shm.is_dir() {
171 assert!(is_ram_backed(shm), "/dev/shm should be tmpfs");
172 }
173 }
174
175 #[cfg(target_os = "macos")]
176 #[test]
177 fn macos_tmp_is_not_ram_backed() {
178 // macOS has no tmpfs by default; the disk-backed temp dir must read false.
179 assert!(!is_ram_backed(&std::env::temp_dir()));
180 }
181}