kz-proxy 0.3.0

MITM proxy and subprocess sandbox for blind secret injection
Documentation
//! Public API types: secret/string mappings, host patterns, connection policies, sandbox config.

use regex::Regex;

/// Format a struct with a masked `value` field for Debug output (security: redact secrets).
fn debug_masked(f: &mut std::fmt::Formatter<'_>, name: &str, key_field: &str, key_val: &str) -> std::fmt::Result {
    f.debug_struct(name).field(key_field, &key_val).field("value", &"[REDACTED]").finish()
}

/// Mapping from environment variable name to the real secret value.
/// The sandbox will inject a masked token into the subprocess env and
/// the proxy will replace that token with this value in outgoing HTTP requests.
/// Real values must not contain CR, LF, or NUL (validated at run time).
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct SecretMapping {
    pub var: String,
    pub value: String,
}

impl std::fmt::Debug for SecretMapping {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        debug_masked(f, "SecretMapping", "var", &self.var)
    }
}

impl SecretMapping {
    /// Create a secret mapping from env var name to real value.
    pub fn new(var: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            var: var.into(),
            value: value.into(),
        }
    }
}

/// Mapping from a unique string identifier (token) to the actual value.
/// The proxy will replace occurrences of the token with the value in URIs, headers, and body.
/// Used when the process already uses placeholders; no env injection. Values must not contain CR, LF, or NUL.
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct StringMapping {
    pub token: String,
    pub value: String,
}

impl std::fmt::Debug for StringMapping {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        debug_masked(f, "StringMapping", "token", &self.token)
    }
}

impl StringMapping {
    /// Create a string mapping from token to value.
    pub fn new(token: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            token: token.into(),
            value: value.into(),
        }
    }
}

/// Pattern for matching a host in connection allow/deny rules.
#[derive(Clone, Debug)]
pub enum HostPattern {
    /// Exact host string match (e.g. `example.com`).
    Exact(String),
    /// Regex match over the host (e.g. `^api\.example\.com$`). Pattern string is kept for serialization.
    Regex { pattern: String, re: Regex },
}

impl HostPattern {
    /// Create an exact host pattern.
    pub fn exact(host: impl Into<String>) -> Self {
        Self::Exact(host.into())
    }

    /// Create a regex host pattern. Returns an error if the pattern is invalid.
    pub fn regex(pattern: &str) -> Result<Self, regex::Error> {
        let re = Regex::new(pattern)?;
        Ok(Self::Regex {
            pattern: pattern.to_string(),
            re,
        })
    }

    /// Returns true if the given host matches this pattern.
    pub fn matches(&self, host: &str) -> bool {
        match self {
            HostPattern::Exact(s) => s == host,
            HostPattern::Regex { re, .. } => re.is_match(host),
        }
    }
}

/// Allow or deny outbound connections to a host (or hosts matching a regex).
/// Policies are evaluated in order; the first matching policy wins. If no policy matches and rules exist, the connection is denied (allowlist behavior).
#[derive(Clone, Debug)]
pub struct ConnectionPolicy {
    pub pattern: HostPattern,
    /// If true, allow the connection; if false, deny (proxy returns an error to the client).
    pub allow: bool,
}

impl ConnectionPolicy {
    /// Create an allow rule for the given host pattern.
    pub fn allow(pattern: HostPattern) -> Self {
        Self {
            pattern,
            allow: true,
        }
    }

    /// Create a deny rule for the given host pattern.
    pub fn deny(pattern: HostPattern) -> Self {
        Self {
            pattern,
            allow: false,
        }
    }
}

/// Configuration for the sandbox: secrets, string mappings, connection allow/deny, and proxy options.
#[derive(Clone, Debug)]
pub struct SandboxConfig {
    /// Env-based secret mappings (env var name → value). Default empty.
    pub secrets: Vec<SecretMapping>,
    /// String token → value mappings. Default empty.
    pub strings: Vec<StringMapping>,
    /// Allow/deny rules for outbound connections (host or host regex). Evaluated in order; first match wins; no match = allow. Default empty.
    pub connections: Vec<ConnectionPolicy>,
    /// If true, CONNECT to private/local addresses (e.g. 127.0.0.1) is allowed. For testing only; default false.
    pub allow_private_connect: bool,
    /// Optional path to PEM file with extra CA cert(s) to trust for upstream (e.g. self-signed server).
    pub upstream_ca: Option<std::path::PathBuf>,
}

impl Default for SandboxConfig {
    fn default() -> Self {
        Self {
            secrets: Vec::new(),
            strings: Vec::new(),
            connections: Vec::new(),
            allow_private_connect: false,
            upstream_ca: None,
        }
    }
}

/// Sandbox for running a subprocess with masked secrets and an HTTP proxy that rewrites tokens.
///
/// HTTP requests are forwarded with token replacement. HTTPS (CONNECT) is always handled by MITM:
/// the proxy decrypts, rewrites tokens, and re-encrypts to upstream. The subprocess must trust our CA
/// (we set `SSL_CERT_FILE`). For self-signed or custom upstream servers, set `upstream_ca` on [`SandboxConfig`].
#[derive(Clone, Debug)]
pub struct Sandbox {
    pub(crate) config: SandboxConfig,
}

impl Sandbox {
    /// Create a sandbox from the given config.
    pub fn new(config: SandboxConfig) -> Self {
        Self { config }
    }

    /// Run a command in the sandbox: start an HTTP proxy that rewrites masked tokens
    /// and string tokens to real values, set subprocess env with masked tokens (if any) and
    /// HTTP_PROXY/HTTPS_PROXY, then wait for the process to exit.
    pub async fn run(
        &self,
        program: &str,
        args: &[String],
    ) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
        crate::proxy::run_impl(
            program,
            args,
            self.config.secrets.clone(),
            self.config.strings.clone(),
            self.config.allow_private_connect,
            self.config.upstream_ca.clone(),
            self.config.connections.clone(),
        )
        .await
    }
}

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

    #[test]
    fn secret_mapping_creation() {
        let m = SecretMapping {
            var: "API_KEY".to_string(),
            value: "secret123".to_string(),
        };
        assert_eq!(m.var, "API_KEY");
        assert_eq!(m.value, "secret123");
    }

    #[test]
    fn string_mapping_creation() {
        let m = StringMapping {
            token: "__API_KEY__".to_string(),
            value: "secret123".to_string(),
        };
        assert_eq!(m.token, "__API_KEY__");
        assert_eq!(m.value, "secret123");
    }
}