Skip to main content

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}