keyhog_core/safe_bin.rs
1//! Safe absolute-path resolution for external binaries we shell out to.
2//!
3//! Defends against `PATH` injection (kimi-wave1 audit finding 3.PATH-x):
4//! `Command::new("git")` lets the user's `PATH` decide which `git` we
5//! actually invoke. An attacker who can prepend a directory to `PATH` -
6//! a CI runner stage, a malicious dotfile, an override in
7//! `~/.config/fish/config.fish` - substitutes their own binary. Since
8//! keyhog feeds the binary credential bytes (via env vars / argv / stdin
9//! during git scans), that's a credential-exfil pivot.
10//!
11//! This module enumerates a hardcoded allowlist of system binary directories
12//! plus caller-configured trusted directories loaded from `.keyhog.toml`.
13//! Anything not in those dirs is refused. The allowlist is intentionally narrow
14//! - distro-shipped binaries by default. Environments with Nix/Guix or other
15//! non-standard binary roots must configure explicit trusted dirs through the
16//! CLI config layer; no environment variable can expand this trust boundary.
17
18use std::path::{Path, PathBuf};
19use std::sync::{OnceLock, RwLock};
20
21#[cfg(unix)]
22const SYSTEM_BIN_DIRS: &[&str] = &[
23 "/usr/bin",
24 "/usr/local/bin",
25 "/usr/local/sbin",
26 "/usr/sbin",
27 "/bin",
28 "/sbin",
29 "/opt/homebrew/bin", // macOS Apple Silicon
30 "/opt/homebrew/sbin",
31];
32
33#[cfg(windows)]
34const SYSTEM_BIN_DIRS: &[&str] = &[
35 "C:\\Windows\\System32",
36 "C:\\Windows",
37 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0",
38 "C:\\Program Files\\Git\\cmd",
39 "C:\\Program Files\\Git\\bin",
40];
41
42#[cfg(unix)]
43const EXE_SUFFIXES: &[&str] = &[""];
44
45#[cfg(windows)]
46const EXE_SUFFIXES: &[&str] = &[".exe", ".com", ".bat", ".cmd"];
47
48static EXTRA_TRUSTED_BIN_DIRS: OnceLock<RwLock<Vec<PathBuf>>> = OnceLock::new();
49
50/// Replace the caller-configured trusted binary directories.
51///
52/// Only absolute paths are retained. Relative paths would make the trust
53/// boundary depend on the process working directory, reopening the PATH-style
54/// ambiguity this module exists to avoid.
55pub fn set_extra_trusted_dirs(dirs: Vec<PathBuf>) {
56 // Law 10: refuse relative paths (they would make the trust boundary depend
57 // on CWD) but never DROP operator config silently (surface each rejection).
58 let mut filtered = Vec::with_capacity(dirs.len());
59 for dir in dirs {
60 if dir.is_absolute() {
61 filtered.push(dir);
62 } else {
63 tracing::warn!(
64 dir = %dir.display(),
65 "ignoring relative trusted binary directory; only absolute paths are trusted"
66 );
67 }
68 }
69 let lock = EXTRA_TRUSTED_BIN_DIRS.get_or_init(|| RwLock::new(Vec::new()));
70 match lock.write() {
71 Ok(mut guard) => *guard = filtered,
72 Err(poisoned) => {
73 let mut guard = poisoned.into_inner();
74 *guard = filtered;
75 }
76 }
77}
78
79fn configured_trusted_dirs() -> Vec<PathBuf> {
80 let Some(lock) = EXTRA_TRUSTED_BIN_DIRS.get() else {
81 return Vec::new();
82 };
83 match lock.read() {
84 Ok(guard) => guard.clone(),
85 Err(poisoned) => poisoned.into_inner().clone(),
86 }
87}
88
89fn trusted_dirs() -> Vec<PathBuf> {
90 let mut dirs: Vec<PathBuf> = SYSTEM_BIN_DIRS.iter().map(PathBuf::from).collect();
91 dirs.extend(configured_trusted_dirs());
92 dirs
93}
94
95/// Resolve `name` to an absolute path inside one of the trusted system
96/// binary directories. Returns `None` if not found in any trusted dir
97/// (do NOT fall back to `Command::new(name)` - that's exactly the bug).
98///
99/// A candidate is accepted only when its lexical parent is a trusted dir AND its
100/// real target (following symlinks) is a regular file owned by root or the
101/// current effective uid, see [`is_safe_target`]. That ownership gate narrows
102/// the symlink-swap vector AT CHECK TIME without breaking legitimate cross-dir
103/// symlinks (e.g. Homebrew's `/opt/homebrew/bin/git -> ../Cellar/.../git`, which
104/// is owned by the installing user). The returned path is the trusted-dir path
105/// (not the resolved target), so the allowlist contract is preserved.
106///
107/// RESIDUAL TOCTOU: the ownership check and the eventual `Command` exec are two
108/// separate resolutions of the same path. In a group/user-writable trusted dir
109/// an attacker who owns the dir can pass the check with a root-owned target and
110/// then swap the symlink before exec. Closing this fully requires the spawn site
111/// to exec the checked fd directly (fexecve / `O_PATH|O_NOFOLLOW`) or re-stat
112/// immediately before spawn; this function guarantees check-time safety only.
113pub fn resolve_safe_bin(name: &str) -> Option<PathBuf> {
114 if name.contains('/') || name.contains('\\') {
115 // Caller already passed a path; only accept if it's absolute, its
116 // parent is a trusted dir, and its real target passes the safety gate.
117 let p = PathBuf::from(name);
118 if p.is_absolute() && in_trusted_dir(&p) && is_safe_target(&p) {
119 return Some(p);
120 }
121 return None;
122 }
123
124 for dir in trusted_dirs() {
125 for suffix in EXE_SUFFIXES {
126 let candidate = dir.join(format!("{name}{suffix}"));
127 if is_safe_target(&candidate) {
128 return Some(candidate);
129 }
130 }
131 }
132 None
133}
134
135/// True when `candidate` resolves (through any symlinks) to an existing REGULAR
136/// FILE whose owner is root or the current effective uid.
137///
138/// The ownership check is the trust boundary: a symlink planted in a
139/// group/user-writable trusted dir (`/usr/local/bin`, `/opt/homebrew/bin`) by
140/// another, lower-privileged user points at a file THAT user owns, so its
141/// uid is neither 0 nor our euid and it is refused, while root-owned system
142/// binaries and self-owned package binaries (Homebrew Cellar, `cargo install`
143/// shims) pass. A dangling symlink, a directory, or a device node also fails.
144#[cfg(unix)]
145fn is_safe_target(candidate: &Path) -> bool {
146 use std::os::unix::fs::MetadataExt;
147 // `metadata` follows symlinks, so this reflects the real target's type and
148 // owner AT CHECK TIME. The target can still be swapped between this stat and
149 // the later exec; see `resolve_safe_bin`'s residual-TOCTOU note.
150 let Ok(meta) = std::fs::metadata(candidate) else {
151 return false; // missing file or dangling symlink
152 };
153 if !meta.is_file() {
154 return false;
155 }
156 // SAFETY: `geteuid` has no preconditions and cannot fail.
157 let euid = unsafe { libc::geteuid() };
158 let owner = meta.uid();
159 owner == 0 || owner == euid
160}
161
162#[cfg(not(unix))]
163fn is_safe_target(candidate: &Path) -> bool {
164 candidate.is_file()
165}
166
167fn in_trusted_dir(p: &Path) -> bool {
168 let parent = match p.parent() {
169 Some(p) => p,
170 None => return false,
171 };
172 trusted_dirs().iter().any(|dir| parent == dir.as_path())
173}