Skip to main content

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}