Skip to main content

kz_proxy/
types.rs

1//! Public API types: secret/string mappings, host patterns, connection policies, sandbox config.
2
3use regex::Regex;
4
5/// Format a struct with a masked `value` field for Debug output (security: redact secrets).
6fn debug_masked(f: &mut std::fmt::Formatter<'_>, name: &str, key_field: &str, key_val: &str) -> std::fmt::Result {
7    f.debug_struct(name).field(key_field, &key_val).field("value", &"[REDACTED]").finish()
8}
9
10/// Mapping from environment variable name to the real secret value.
11/// The sandbox will inject a masked token into the subprocess env and
12/// the proxy will replace that token with this value in outgoing HTTP requests.
13/// Real values must not contain CR, LF, or NUL (validated at run time).
14#[derive(Clone, serde::Serialize, serde::Deserialize)]
15pub struct SecretMapping {
16    pub var: String,
17    pub value: String,
18}
19
20impl std::fmt::Debug for SecretMapping {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        debug_masked(f, "SecretMapping", "var", &self.var)
23    }
24}
25
26impl SecretMapping {
27    /// Create a secret mapping from env var name to real value.
28    pub fn new(var: impl Into<String>, value: impl Into<String>) -> Self {
29        Self {
30            var: var.into(),
31            value: value.into(),
32        }
33    }
34}
35
36/// Mapping from a unique string identifier (token) to the actual value.
37/// The proxy will replace occurrences of the token with the value in URIs, headers, and body.
38/// Used when the process already uses placeholders; no env injection. Values must not contain CR, LF, or NUL.
39#[derive(Clone, serde::Serialize, serde::Deserialize)]
40pub struct StringMapping {
41    pub token: String,
42    pub value: String,
43}
44
45impl std::fmt::Debug for StringMapping {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        debug_masked(f, "StringMapping", "token", &self.token)
48    }
49}
50
51impl StringMapping {
52    /// Create a string mapping from token to value.
53    pub fn new(token: impl Into<String>, value: impl Into<String>) -> Self {
54        Self {
55            token: token.into(),
56            value: value.into(),
57        }
58    }
59}
60
61/// Pattern for matching a host in connection allow/deny rules.
62#[derive(Clone, Debug)]
63pub enum HostPattern {
64    /// Exact host string match (e.g. `example.com`).
65    Exact(String),
66    /// Regex match over the host (e.g. `^api\.example\.com$`). Pattern string is kept for serialization.
67    Regex { pattern: String, re: Regex },
68}
69
70impl HostPattern {
71    /// Create an exact host pattern.
72    pub fn exact(host: impl Into<String>) -> Self {
73        Self::Exact(host.into())
74    }
75
76    /// Create a regex host pattern. Returns an error if the pattern is invalid.
77    pub fn regex(pattern: &str) -> Result<Self, regex::Error> {
78        let re = Regex::new(pattern)?;
79        Ok(Self::Regex {
80            pattern: pattern.to_string(),
81            re,
82        })
83    }
84
85    /// Returns true if the given host matches this pattern.
86    pub fn matches(&self, host: &str) -> bool {
87        match self {
88            HostPattern::Exact(s) => s == host,
89            HostPattern::Regex { re, .. } => re.is_match(host),
90        }
91    }
92}
93
94/// Allow or deny outbound connections to a host (or hosts matching a regex).
95/// Policies are evaluated in order; the first matching policy wins. If no policy matches and rules exist, the connection is denied (allowlist behavior).
96#[derive(Clone, Debug)]
97pub struct ConnectionPolicy {
98    pub pattern: HostPattern,
99    /// If true, allow the connection; if false, deny (proxy returns an error to the client).
100    pub allow: bool,
101}
102
103impl ConnectionPolicy {
104    /// Create an allow rule for the given host pattern.
105    pub fn allow(pattern: HostPattern) -> Self {
106        Self {
107            pattern,
108            allow: true,
109        }
110    }
111
112    /// Create a deny rule for the given host pattern.
113    pub fn deny(pattern: HostPattern) -> Self {
114        Self {
115            pattern,
116            allow: false,
117        }
118    }
119}
120
121/// Configuration for the sandbox: secrets, string mappings, connection allow/deny, and proxy options.
122#[derive(Clone, Debug)]
123pub struct SandboxConfig {
124    /// Env-based secret mappings (env var name → value). Default empty.
125    pub secrets: Vec<SecretMapping>,
126    /// String token → value mappings. Default empty.
127    pub strings: Vec<StringMapping>,
128    /// Allow/deny rules for outbound connections (host or host regex). Evaluated in order; first match wins; no match = allow. Default empty.
129    pub connections: Vec<ConnectionPolicy>,
130    /// If true, CONNECT to private/local addresses (e.g. 127.0.0.1) is allowed. For testing only; default false.
131    pub allow_private_connect: bool,
132    /// Optional path to PEM file with extra CA cert(s) to trust for upstream (e.g. self-signed server).
133    pub upstream_ca: Option<std::path::PathBuf>,
134}
135
136impl Default for SandboxConfig {
137    fn default() -> Self {
138        Self {
139            secrets: Vec::new(),
140            strings: Vec::new(),
141            connections: Vec::new(),
142            allow_private_connect: false,
143            upstream_ca: None,
144        }
145    }
146}
147
148/// Sandbox for running a subprocess with masked secrets and an HTTP proxy that rewrites tokens.
149///
150/// HTTP requests are forwarded with token replacement. HTTPS (CONNECT) is always handled by MITM:
151/// the proxy decrypts, rewrites tokens, and re-encrypts to upstream. The subprocess must trust our CA
152/// (we set `SSL_CERT_FILE`). For self-signed or custom upstream servers, set `upstream_ca` on [`SandboxConfig`].
153#[derive(Clone, Debug)]
154pub struct Sandbox {
155    pub(crate) config: SandboxConfig,
156}
157
158impl Sandbox {
159    /// Create a sandbox from the given config.
160    pub fn new(config: SandboxConfig) -> Self {
161        Self { config }
162    }
163
164    /// Run a command in the sandbox: start an HTTP proxy that rewrites masked tokens
165    /// and string tokens to real values, set subprocess env with masked tokens (if any) and
166    /// HTTP_PROXY/HTTPS_PROXY, then wait for the process to exit.
167    pub async fn run(
168        &self,
169        program: &str,
170        args: &[String],
171    ) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
172        crate::proxy::run_impl(
173            program,
174            args,
175            self.config.secrets.clone(),
176            self.config.strings.clone(),
177            self.config.allow_private_connect,
178            self.config.upstream_ca.clone(),
179            self.config.connections.clone(),
180        )
181        .await
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn secret_mapping_creation() {
191        let m = SecretMapping {
192            var: "API_KEY".to_string(),
193            value: "secret123".to_string(),
194        };
195        assert_eq!(m.var, "API_KEY");
196        assert_eq!(m.value, "secret123");
197    }
198
199    #[test]
200    fn string_mapping_creation() {
201        let m = StringMapping {
202            token: "__API_KEY__".to_string(),
203            value: "secret123".to_string(),
204        };
205        assert_eq!(m.token, "__API_KEY__");
206        assert_eq!(m.value, "secret123");
207    }
208}