envseal 0.3.12

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Runtime security guards.
//!
//! Defensive checks applied before any secret-touching operation. Each
//! submodule addresses a different attack vector from the threat model.
//!
//! # Submodules
//!
//! - [`mod@env`] — Environment-variable hostility assessment and child-process
//!   environment sanitization (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, …).
//! - [`binary`] — Binary integrity: SHA-256 hashing, hash verification,
//!   TOCTOU-resistant fd-pinning helpers, and the path-not-a-symlink check.
//! - [`gui_security`] — GUI-environment threat assessment: remote-session
//!   detection, X11 keylogging risk, input-injection / screen-recorder
//!   process scanning, GUI challenge codes.
//! - [`preload`] — Process hardening (ptrace blocking, core-dump suppression,
//!   `PR_SET_NO_NEW_PRIVS`), `memfd_secret` capability probe, and the
//!   self-`LD_PRELOAD` startup check.
//! - [`signal`] — The detection→policy taxonomy. Every threat, from
//!   screen recorders to env injection to disk permissions, becomes a
//!   uniform [`signal::Signal`] with a stable id, category, and
//!   severity. The [`signal::Policy`] table maps `(severity, tier) →
//!   action`; [`signal::evaluate`] aggregates a decision. New
//!   detectors plug in without modifying any approval-pipeline code.
//!
//! Public functions are re-exported at this module's root so the
//! `crate::guard::*` import path stays compatible with everything that
//! existed before the split.

pub mod binary;
pub mod context;
pub mod env;
pub mod gui_security;
pub mod preload;
pub mod signal;
pub mod target_binary;

pub use binary::{
    hash_binary, hash_open_file, verify_binary_hash, verify_file_hash, verify_not_symlink,
};
pub use context::{
    assess_io_context_signals, detect_stdin_kind, DetectorContext, DetectorContextBuilder,
    StdinKind,
};
pub use env::{
    assess_env_signals, assess_environment, enforce_env_policy, sanitized_env,
    EnvironmentThreatLevel,
};
pub use gui_security::{
    approval_delay_seconds, assess_gui_security, assess_gui_signals, check_physical_presence,
    check_x11_keylog_risk, generate_gui_challenge, verify_gui_binary, verify_gui_challenge,
    GuiSecurityReport, GuiThreatLevel, PhysicalPresence,
};
#[cfg(target_os = "linux")]
pub use preload::mark_dontdump;
pub use preload::{
    assess_preload_signals, assess_ptrace_signals, check_macos_hardened_runtime,
    check_ptrace_scope, check_self_preload, harden_process, test_memfd_secret,
    vm_read_access_blocked,
};
pub use signal::{evaluate, Action, Category, Decision, Policy, Severity, Signal, SignalId};
pub use target_binary::{assess_target_binary_signals, is_interpreter};

/// The signal-producing detector registry.
///
/// Each entry is a function that takes a [`DetectorContext`] and
/// returns the signals it can detect right now. Adding a detector =
/// add one line here; no glue code, no editing of
/// [`assess_all_signals`]. Anything not in this slice is invisible
/// to the approval pipeline.
///
/// Ambient detectors (host-state scans, env-var checks) ignore the
/// context entirely. Per-operation detectors
/// (`assess_target_binary_signals`, `assess_io_context_signals`)
/// read the context fields they need and emit nothing when those
/// fields are unset — so an ambient call site (`doctor`,
/// `startup_audit`) gets a quiet result without needing to special-
/// case which detectors to run.
///
/// The slice is intentionally `pub` so tests, documentation tooling,
/// and the `doctor --json` introspection surface can iterate over it
/// without a parallel hardcoded list drifting out of sync.
pub const DETECTORS: &[fn(&DetectorContext) -> Vec<Signal>] = &[
    assess_gui_signals,
    assess_env_signals,
    assess_target_binary_signals,
    assess_io_context_signals,
    assess_ptrace_signals,
    assess_preload_signals,
    // Future producers plug in here:
    //   assess_disk_signals,
    //   assess_sandbox_signals,
];

/// Run every registered detector against `ctx` and concatenate the
/// results into a single stream. Canonical input to
/// [`signal::evaluate`].
///
/// For ambient (no-operation-context) call sites, pass
/// [`DetectorContext::ambient()`] — every per-operation detector
/// will see an empty context and emit nothing, while ambient
/// detectors run normally.
#[must_use]
pub fn assess_all_signals(ctx: &DetectorContext) -> Vec<Signal> {
    DETECTORS
        .iter()
        .flat_map(|detector| detector(ctx))
        .collect()
}

/// Process one or more signals under the active policy from a
/// non-popup code path: render warnings to stderr, audit-log every
/// `log_entry`, and convert the first blocking signal into
/// [`crate::error::Error::EnvironmentCompromised`].
///
/// This is the canonical sink for in-flight warnings — every place
/// that used to call `eprintln!("envseal: ⚠️  …")` or
/// `eprintln!("envseal: WARNING — …")` directly should construct a
/// [`Signal`] and route it through here instead. That gets you:
///
/// - Tier-aware policy: a `Warn` signal at Lockdown can be promoted
///   to a friction gate or a block via `security.toml`, with no
///   call-site change.
/// - Per-signal-id muting: a noisy detector can be muted via
///   `[signal_overrides] "audit.chain.rotated_corruption" = "log"`.
/// - Uniform stderr formatting (one `envseal: ⚠️ <label>: <detail> — <mitigation>` line).
/// - Forensic audit trail without each call site re-implementing the `audit::log` call.
///
/// Use [`evaluate`] + a popup renderer (`gui::request_approval`) when
/// the decision needs to gate a user-visible challenge or popup.
#[allow(clippy::needless_pass_by_value)] // Vec is the natural call-site shape — every detector returns Vec<Signal>
pub fn emit_signals_inline(
    signals: Vec<Signal>,
    config: &crate::security_config::SecurityConfig,
) -> Result<(), crate::error::Error> {
    if signals.is_empty() {
        return Ok(());
    }
    let policy = config.build_policy();
    let decision = evaluate(&signals, &policy, config.tier);
    for entry in &decision.log_entries {
        if let Err(e) = crate::audit::log(&crate::audit::AuditEvent::SignalRecorded {
            tier: format!("{:?}", config.tier),
            classification: format!("{} [{}] {}", entry.severity.as_str(), entry.id, entry.label),
        }) {
            eprintln!("envseal: audit log failed: {e}");
        }
    }
    for w in &decision.warnings {
        eprintln!("envseal: ⚠️  {w}");
    }
    if let Some(blocking) = decision.blocking_signal {
        return Err(crate::error::Error::EnvironmentCompromised(format!(
            "{label} ({id}): {detail}{mitigation}",
            label = blocking.label,
            id = blocking.id,
            detail = blocking.detail,
            mitigation = blocking.mitigation,
        )));
    }
    Ok(())
}

/// Convenience wrapper for sites that have a single signal to emit.
pub fn emit_signal_inline(
    signal: Signal,
    config: &crate::security_config::SecurityConfig,
) -> Result<(), crate::error::Error> {
    emit_signals_inline(vec![signal], config)
}

/// Constant-time comparison for HMAC digests and similar fixed-length tokens.
///
/// Prevents timing side-channel attacks where an attacker measures how long
/// a comparison takes to determine how many bytes matched. Uses XOR
/// accumulation — always examines every byte regardless of where the first
/// mismatch is, with a `black_box` to defeat compiler short-circuiting.
#[must_use]
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    std::hint::black_box(diff) == 0
}

/// Run every registered detector under an ambient context and
/// return human-readable warning strings — one per signal whose
/// policy action under the default tier produces a `Warn`. This is
/// the canonical startup-time threat surface; CLI and MCP both
/// render the resulting strings to stderr at process start.
///
/// Returns `Vec<String>` rather than `Decision` because the legacy
/// CLI / MCP / desktop callers iterate the strings directly. The
/// single source of truth is still the unified pipeline:
/// `assess_all_signals(ambient) → evaluate(default_policy, default_tier) → Decision.warnings`.
/// Pre-0.3.0 versions of this function had hand-rolled `Vec<String>`
/// generation that ran every individual `check_*` separately and
/// formatted with bespoke prefixes — that was the last parallel
/// rendering path outside the unified taxonomy and has been removed.
#[must_use]
pub fn startup_audit() -> Vec<String> {
    startup_audit_decision().warnings
}

/// Like [`startup_audit`] but returns the full [`signal::Decision`]
/// so callers (CLI startup, MCP entrypoint) can honor the
/// `blocked` verdict — *not* just print the warnings.
///
/// Pre-existing `startup_audit()` only ever surfaced
/// `decision.warnings`, which silently dropped `Action::Block`
/// outcomes (e.g. an `LD_PRELOAD`-injected library detected against
/// a `Lockdown`-tier policy). Anything that touches secrets must
/// branch on `decision.blocked` and refuse to proceed when set.
#[must_use]
pub fn startup_audit_decision() -> signal::Decision {
    let ctx = DetectorContext::ambient();
    let signals = assess_all_signals(&ctx);
    if signals.is_empty() {
        return signal::Decision::default();
    }
    let policy = signal::Policy::new();
    let tier = crate::security_config::load_system_defaults().tier;
    evaluate(&signals, &policy, tier)
}

#[cfg(test)]
mod registry_tests {
    use super::{DetectorContext, DETECTORS};

    #[test]
    fn registry_is_non_empty_and_deterministic() {
        assert!(
            !DETECTORS.is_empty(),
            "DETECTORS slice must register at least one detector — \
             an empty registry silently disables the entire signal pipeline"
        );
        let ctx = DetectorContext::ambient();
        // Calling each detector must not panic and must produce a Vec
        // (semantic content varies per environment).
        for detector in DETECTORS {
            let _ = detector(&ctx);
        }
    }

    #[test]
    fn assess_all_signals_aggregates_every_detector() {
        let ctx = DetectorContext::ambient();
        // Sum of per-detector lengths must equal the aggregate.
        let total: usize = DETECTORS.iter().map(|d| d(&ctx).len()).sum();
        assert_eq!(super::assess_all_signals(&ctx).len(), total);
    }
}