envseal 0.3.12

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Detectors that classify the *target* binary of an inject/run/shell
//! flow — the program envseal is about to hand the secret to.
//!
//! Two emit paths today:
//!
//! - **Interpreter risk** — `python`, `node`, `bash`, etc. The hash
//!   gate guarantees the binary itself is unchanged, but interpreters
//!   execute attacker-controlled *scripts*, so a clean hash means
//!   nothing about what code actually runs with the secret in scope.
//! - **Untrusted-path risk** — the binary lives in `/tmp`,
//!   `/var/tmp`, `/dev/shm`, `~/Downloads`, `~/Desktop`, `~/.cache`,
//!   `~/.local/tmp`. Any process or social-engineered file drop
//!   could have planted it; user should double-check before
//!   approving.
//!
//! Both used to live as ad-hoc string-concatenation in `gui::mod`'s
//! `shell_warning` / `check_untrusted_binary`, bolted onto the
//! warnings string *after* `evaluate(...)` already produced a
//! `Decision`. They couldn't be policy-overridden, couldn't be
//! audit-logged through the unified path, and showed up with their
//! own ⚠️/🚨 prefixes that drifted from the rest of the system.
//! Now they're plain [`Signal`]s like any other.

use super::{Category, DetectorContext, Severity, Signal, SignalId};

/// Detector entry registered in [`super::DETECTORS`]. Reads
/// `ctx.binary_path` and emits one or more signals when the target
/// is risky. Quiet (returns empty `Vec`) when `binary_path` is
/// `None` — i.e. ambient host scans don't fire it.
#[must_use]
pub fn assess_target_binary_signals(ctx: &DetectorContext) -> Vec<Signal> {
    let Some(binary_path) = ctx.binary_path else {
        return Vec::new();
    };
    let mut signals = Vec::new();

    if let Some(reason) = is_interpreter_basename(binary_path) {
        signals.push(Signal::new(
            SignalId::scoped("binary.interpreter", reason),
            Category::BinaryRisk,
            Severity::Warn,
            "script interpreter target",
            format!(
                "'{binary_path}' is a script interpreter — the hash gate verifies the \
                 binary, not the script it runs, so an attacker-controlled script can \
                 still exfiltrate the secret"
            ),
            "review the script source before approving, or invoke a fixed binary directly",
        ));
    } else if interpreter_spoof_match(binary_path) {
        // Renamed copy of a system interpreter — same content, different
        // basename. More suspicious than a normal interpreter call
        // because the rename is the social-engineering payload itself.
        signals.push(Signal::new(
            SignalId::new("binary.interpreter.spoof"),
            Category::BinaryRisk,
            Severity::Hostile,
            "renamed script interpreter",
            format!(
                "'{binary_path}' is a renamed copy of a system interpreter \
                 (size + SHA-256 match) — someone is hiding a script runner \
                 behind a benign-looking name"
            ),
            "do not approve; verify the binary's true identity",
        ));
    }

    if let Some(untrusted) = untrusted_prefix(binary_path) {
        // Split severity by who-can-write-here: world-writable
        // directories (`/tmp`, `/var/tmp`, `/dev/shm`) get Hostile —
        // any process can drop a binary there. User-writable
        // directories (`~/Downloads`, `~/Desktop`, `~/.cache`,
        // `~/.local/tmp`) get Warn — the user themselves typically
        // put the file there, so it's a "double-check provenance"
        // signal rather than a "this is probably an attack" one.
        signals.push(Signal::new(
            SignalId::scoped("binary.untrusted_path", untrusted.scope),
            Category::BinaryRisk,
            untrusted.severity,
            "binary in untrusted directory",
            format!(
                "'{binary_path}' is in {prefix} ({reason})",
                prefix = untrusted.prefix,
                reason = untrusted.reason
            ),
            "move the binary to a system-managed location or invoke an alternate path",
        ));
    }

    signals
}

/// Recognize a known interpreter by basename. Returns the canonical
/// short name (used as the `SignalId` scope) when matched, `None`
/// otherwise.
///
/// Uses [`std::path::Path`] to extract the basename, so this works
/// with both `/usr/bin/python3` (Unix) and `C:\\Python311\\python.exe`
/// (Windows) — the previous `rsplit('/')` form silently failed for
/// every Windows path that didn't happen to contain a forward slash.
fn is_interpreter_basename(binary_path: &str) -> Option<&'static str> {
    let basename = std::path::Path::new(binary_path)
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or(binary_path);
    // Strip the .exe suffix on Windows so `python.exe` and `python`
    // both map to the same SignalId scope.
    let stem = basename.strip_suffix(".exe").unwrap_or(basename);
    match stem {
        "bash" => Some("bash"),
        "sh" => Some("sh"),
        "zsh" => Some("zsh"),
        "fish" => Some("fish"),
        "dash" => Some("dash"),
        "csh" => Some("csh"),
        "tcsh" => Some("tcsh"),
        "ksh" => Some("ksh"),
        "powershell" => Some("powershell"),
        "pwsh" => Some("pwsh"),
        "cmd" => Some("cmd"),
        "python" | "python3" => Some("python"),
        "node" => Some("node"),
        "ruby" => Some("ruby"),
        "perl" => Some("perl"),
        _ => None,
    }
}

/// Classification result for an untrusted-directory match.
struct UntrustedMatch {
    /// Stable `SignalId` scope (`tmp`, `var_tmp`, `dev_shm`,
    /// `home_downloads`, etc.). Becomes the suffix on
    /// `binary.untrusted_path.*`.
    scope: &'static str,
    /// Display path prefix.
    prefix: &'static str,
    /// One-line reason for the human-readable detail.
    reason: &'static str,
    /// Severity. World-writable directories
    /// (`/tmp`, `/var/tmp`, `/dev/shm`) get [`Severity::Hostile`] —
    /// any process can drop a binary there. User-writable directories
    /// (`~/Downloads`, `~/Desktop`, `~/.cache`, `~/.local/tmp`) get
    /// [`Severity::Warn`] because the user themselves typically put
    /// the file there.
    severity: Severity,
}

fn untrusted_prefix(binary_path: &str) -> Option<UntrustedMatch> {
    // World-writable system dirs — Hostile severity.
    const WORLD_WRITABLE: &[(&str, &str, &str)] = &[
        ("/tmp", "tmp", "any process can write here"),
        ("/var/tmp", "var_tmp", "persistent temporary directory"),
        (
            "/dev/shm",
            "dev_shm",
            "shared memory — any process can write here",
        ),
    ];
    // Per-user dirs — Warn severity. Hoisted to the top of the
    // function so clippy's `items_after_statements` lint stays
    // happy; the const lookups are conceptually paired with
    // `WORLD_WRITABLE` above.
    const USER_WRITABLE: &[(&str, &str, &str, &str)] = &[
        (
            "Downloads",
            "home_downloads",
            "~/Downloads",
            "user download directory — verify provenance",
        ),
        (
            "Desktop",
            "home_desktop",
            "~/Desktop",
            "user desktop — verify provenance",
        ),
        (
            ".cache",
            "home_cache",
            "~/.cache",
            "user cache — overwriteable by any user-process",
        ),
        (
            ".local/tmp",
            "home_local_tmp",
            "~/.local/tmp",
            "user-local temp — overwriteable by any user-process",
        ),
    ];
    for (prefix, scope, reason) in WORLD_WRITABLE {
        if binary_path.starts_with(prefix) {
            return Some(UntrustedMatch {
                scope,
                prefix,
                reason,
                severity: Severity::Hostile,
            });
        }
    }

    // Per-user dirs — Warn severity. Name-only matches against
    // $HOME / $USERPROFILE prefix.
    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .unwrap_or_default();
    if home.is_empty() {
        return None;
    }
    for (dir, scope, display_prefix, reason) in USER_WRITABLE {
        let mut probe = std::path::PathBuf::from(&home);
        for seg in dir.split('/') {
            probe.push(seg);
        }
        if binary_path.starts_with(probe.to_string_lossy().as_ref()) {
            return Some(UntrustedMatch {
                scope,
                prefix: display_prefix,
                reason,
                severity: Severity::Warn,
            });
        }
    }
    None
}

/// Public back-compat: external callers (and a small handful of
/// in-tree spots) used to call `crate::gui::is_interpreter`. The
/// canonical home is here in `guard::target_binary`; the `gui` shim
/// just forwards.
///
/// Returns `true` if the basename matches a known interpreter OR if
/// the binary's size + SHA-256 match a known interpreter on disk
/// (defeating rename-only spoofing of a script-runner binary).
#[must_use]
pub fn is_interpreter(binary_path: &str) -> bool {
    if is_interpreter_basename(binary_path).is_some() {
        return true;
    }
    interpreter_spoof_match(binary_path)
}

/// Scan well-known system interpreter paths and return `true` if
/// `binary_path` is a renamed copy of one — same size, same SHA-256
/// hash. Used by [`is_interpreter`] so a binary called `helper`
/// that is byte-for-byte `/usr/bin/python3` still gets flagged.
fn interpreter_spoof_match(binary_path: &str) -> bool {
    let path = std::path::Path::new(binary_path);
    if !path.exists() {
        return false;
    }
    let Ok(meta) = std::fs::metadata(path) else {
        return false;
    };
    let size = meta.len();
    let common_interpreters = [
        "/bin/bash",
        "/usr/bin/bash",
        "/bin/sh",
        "/usr/bin/sh",
        "/usr/bin/python3",
        "/usr/bin/python",
        "/usr/bin/node",
        "/usr/bin/ruby",
        "/usr/bin/perl",
        "/usr/bin/zsh",
    ];
    for interp in &common_interpreters {
        if path.as_os_str() == *interp {
            continue;
        }
        let p = std::path::Path::new(interp);
        if let Ok(sys_meta) = std::fs::metadata(p) {
            if sys_meta.len() == size {
                if let (Ok(hash), Ok(sys_hash)) = (
                    crate::guard::hash_binary(path),
                    crate::guard::hash_binary(p),
                ) {
                    if hash == sys_hash {
                        return true;
                    }
                }
            }
        }
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::guard::DetectorContext;

    #[test]
    fn ambient_context_emits_no_signals() {
        let signals = assess_target_binary_signals(&DetectorContext::ambient());
        assert!(signals.is_empty());
    }

    #[test]
    fn interpreter_basename_matches_unix_path() {
        assert_eq!(is_interpreter_basename("/usr/bin/python3"), Some("python"));
        assert_eq!(is_interpreter_basename("/bin/bash"), Some("bash"));
    }

    // `Path::file_name` is platform-aware: on Linux it treats `\` as a
    // regular filename character (so `C:\foo\python.exe` parses as one
    // filename), only on Windows is `\` a separator. This regression
    // pins the Windows-only behavior — building it as `#[cfg(windows)]`
    // means the test runs on the platform whose semantics it documents.
    #[cfg(windows)]
    #[test]
    fn interpreter_basename_matches_windows_path() {
        assert_eq!(
            is_interpreter_basename("C:\\Python311\\python.exe"),
            Some("python")
        );
        assert_eq!(
            is_interpreter_basename("C:\\Windows\\System32\\cmd.exe"),
            Some("cmd")
        );
    }

    #[test]
    fn non_interpreter_returns_none() {
        assert!(is_interpreter_basename("/usr/bin/git").is_none());
        assert!(is_interpreter_basename("C:\\bin\\git.exe").is_none());
    }

    #[test]
    fn target_binary_emits_interpreter_signal() {
        let ctx = DetectorContext::builder()
            .binary_path("/usr/bin/python3")
            .build();
        let signals = assess_target_binary_signals(&ctx);
        assert!(signals
            .iter()
            .any(|s| s.id.as_str() == "binary.interpreter.python"));
    }

    #[test]
    fn target_binary_emits_untrusted_path_for_tmp() {
        let ctx = DetectorContext::builder().binary_path("/tmp/evil").build();
        let signals = assess_target_binary_signals(&ctx);
        assert!(signals
            .iter()
            .any(|s| s.id.as_str().starts_with("binary.untrusted_path.")));
    }

    #[test]
    fn world_writable_paths_are_hostile_user_writable_are_warn() {
        // World-writable dirs (`/tmp`, `/var/tmp`, `/dev/shm`) any
        // process can drop into → Hostile. User-writable dirs that
        // only the user can write to → Warn (verify-provenance).
        // This split lives in `untrusted_prefix`; the test pins the
        // contract so it doesn't drift.
        let ctx = DetectorContext::builder().binary_path("/tmp/evil").build();
        let s = assess_target_binary_signals(&ctx);
        let untrusted: Vec<_> = s
            .iter()
            .filter(|s| s.id.as_str().starts_with("binary.untrusted_path."))
            .collect();
        assert_eq!(untrusted.len(), 1);
        assert_eq!(untrusted[0].severity, super::Severity::Hostile);

        // For the user-writable case we need a real $HOME. Use a
        // stub: temporarily set HOME to a deterministic path and
        // probe a binary inside it. Test must not modify global env
        // permanently — restore after.
        let old_home = std::env::var("HOME").ok();
        std::env::set_var("HOME", "/test-home-stub");
        let ctx = DetectorContext::builder()
            .binary_path("/test-home-stub/Downloads/maybe-bad")
            .build();
        let s = assess_target_binary_signals(&ctx);
        let untrusted: Vec<_> = s
            .iter()
            .filter(|s| s.id.as_str().starts_with("binary.untrusted_path."))
            .collect();
        match old_home {
            Some(v) => std::env::set_var("HOME", v),
            None => std::env::remove_var("HOME"),
        }
        // Either fires Warn (Linux/macOS where HOME is the canonical
        // env var) or doesn't fire at all (Windows where USERPROFILE
        // is checked first). Either is acceptable; severity must NOT
        // be Hostile if it fires.
        for sig in &untrusted {
            assert!(
                sig.severity != super::Severity::Hostile,
                "user-writable dir wrongly flagged Hostile: {}",
                sig.id
            );
        }
    }
}