Skip to main content

kz_proxy/
lib.rs

1//! kz-proxy: run a subprocess with masked secrets and an HTTP proxy that rewrites tokens.
2//!
3//! The main type is [`Sandbox`]: build it from a [`SandboxConfig`] (with optional [`SecretMapping`]s,
4//! [`StringMapping`]s, and connection allow/deny rules), then call [`Sandbox::run`] to execute a shell
5//! command with masked env vars and proxied HTTP that rewrites tokens to real secrets.
6//!
7//! The proxy is implemented with the [hyper](https://github.com/hyperium/hyper) stack so that
8//! HTTP parsing, Content-Length, chunked encoding, and CONNECT tunneling follow RFC 7230/9110.
9
10pub mod backend;
11mod enforce;
12mod runner;
13pub use enforce::Enforce;
14#[cfg(target_os = "macos")]
15mod enforce_docker;
16#[cfg(target_os = "linux")]
17mod enforce_linux;
18mod mitm;
19
20use std::collections::HashMap;
21use std::sync::Arc;
22
23use bytes::Bytes;
24use http_body_util::{combinators::BoxBody, BodyExt, Full};
25use hyper::service::service_fn;
26use hyper::{Method, Request, Response};
27use hyper_util::rt::TokioIo;
28use regex::Regex;
29use tokio::net::{TcpListener, TcpStream};
30use tokio::sync::mpsc;
31
32/// Mapping from environment variable name to the real secret value.
33/// The sandbox will inject a masked token into the subprocess env and
34/// the proxy will replace that token with this value in outgoing HTTP requests.
35/// Real values must not contain CR, LF, or NUL (validated at run time).
36#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
37pub struct SecretMapping {
38    pub var: String,
39    pub value: String,
40}
41
42impl SecretMapping {
43    /// Create a secret mapping from env var name to real value.
44    pub fn new(var: impl Into<String>, value: impl Into<String>) -> Self {
45        Self {
46            var: var.into(),
47            value: value.into(),
48        }
49    }
50}
51
52/// Mapping from a unique string identifier (token) to the actual value.
53/// The proxy will replace occurrences of the token with the value in URIs, headers, and body.
54/// Used when the process already uses placeholders; no env injection. Values must not contain CR, LF, or NUL.
55#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
56pub struct StringMapping {
57    pub token: String,
58    pub value: String,
59}
60
61impl StringMapping {
62    /// Create a string mapping from token to value.
63    pub fn new(token: impl Into<String>, value: impl Into<String>) -> Self {
64        Self {
65            token: token.into(),
66            value: value.into(),
67        }
68    }
69}
70
71/// Pattern for matching a host in connection allow/deny rules.
72#[derive(Clone, Debug)]
73pub enum HostPattern {
74    /// Exact host string match (e.g. `example.com`).
75    Exact(String),
76    /// Regex match over the host (e.g. `^api\.example\.com$`). Pattern string is kept for serialization.
77    Regex { pattern: String, re: Regex },
78}
79
80impl HostPattern {
81    /// Create an exact host pattern.
82    pub fn exact(host: impl Into<String>) -> Self {
83        Self::Exact(host.into())
84    }
85
86    /// Create a regex host pattern. Returns an error if the pattern is invalid.
87    pub fn regex(pattern: &str) -> Result<Self, regex::Error> {
88        let re = Regex::new(pattern)?;
89        Ok(Self::Regex {
90            pattern: pattern.to_string(),
91            re,
92        })
93    }
94
95    /// Returns true if the given host matches this pattern.
96    pub fn matches(&self, host: &str) -> bool {
97        match self {
98            HostPattern::Exact(s) => s == host,
99            HostPattern::Regex { re, .. } => re.is_match(host),
100        }
101    }
102}
103
104/// Allow or deny outbound connections to a host (or hosts matching a regex).
105/// Policies are evaluated in order; the first matching policy wins. If no policy matches, the connection is allowed.
106#[derive(Clone, Debug)]
107pub struct ConnectionPolicy {
108    pub pattern: HostPattern,
109    /// If true, allow the connection; if false, deny (proxy returns an error to the client).
110    pub allow: bool,
111}
112
113impl ConnectionPolicy {
114    /// Create an allow rule for the given host pattern.
115    pub fn allow(pattern: HostPattern) -> Self {
116        Self {
117            pattern,
118            allow: true,
119        }
120    }
121
122    /// Create a deny rule for the given host pattern.
123    pub fn deny(pattern: HostPattern) -> Self {
124        Self {
125            pattern,
126            allow: false,
127        }
128    }
129}
130
131/// Configuration for the sandbox: secrets, string mappings, connection allow/deny, and proxy options.
132#[derive(Clone, Debug)]
133pub struct SandboxConfig {
134    /// Env-based secret mappings (env var name → value). Default empty.
135    pub secrets: Vec<SecretMapping>,
136    /// String token → value mappings. Default empty.
137    pub strings: Vec<StringMapping>,
138    /// Allow/deny rules for outbound connections (host or host regex). Evaluated in order; first match wins; no match = allow. Default empty.
139    pub connections: Vec<ConnectionPolicy>,
140    /// If true, CONNECT to private/local addresses (e.g. 127.0.0.1) is allowed. For testing only; default false.
141    pub allow_private_connect: bool,
142    /// Optional path to PEM file with extra CA cert(s) to trust for upstream (e.g. self-signed server).
143    pub upstream_ca: Option<std::path::PathBuf>,
144    /// If true, run the child in a sandbox backend (Firecracker on Linux, Docker on macOS) so all traffic is forced through the proxy. Default true.
145    pub force_traffic_through_proxy: bool,
146    /// Sandbox backend when force_traffic_through_proxy is true. Default: Firecracker on Linux, Docker on macOS.
147    pub sandbox_backend: Option<crate::backend::SandboxBackend>,
148}
149
150impl Default for SandboxConfig {
151    fn default() -> Self {
152        Self {
153            secrets: Vec::new(),
154            strings: Vec::new(),
155            connections: Vec::new(),
156            allow_private_connect: false,
157            upstream_ca: None,
158            force_traffic_through_proxy: true,
159            sandbox_backend: None,
160        }
161    }
162}
163
164/// Sandbox for running a subprocess with masked secrets and an HTTP proxy that rewrites tokens.
165///
166/// HTTP requests are forwarded with token replacement. HTTPS (CONNECT) is always handled by MITM:
167/// the proxy decrypts, rewrites tokens, and re-encrypts to upstream. The subprocess must trust our CA
168/// (we set `SSL_CERT_FILE`). For self-signed or custom upstream servers, set `upstream_ca` on [`SandboxConfig`].
169#[derive(Clone, Debug)]
170pub struct Sandbox {
171    config: SandboxConfig,
172}
173
174impl Sandbox {
175    /// Create a sandbox from the given config.
176    pub fn new(config: SandboxConfig) -> Self {
177        Self { config }
178    }
179
180    /// Run a shell command in the sandbox: start an HTTP proxy that rewrites masked tokens
181    /// and string tokens to real values, set subprocess env with masked tokens (if any) and
182    /// HTTP_PROXY/HTTPS_PROXY, then wait for the process to exit.
183    pub async fn run(
184        &self,
185        cmd: &str,
186    ) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
187        run_impl(
188            cmd,
189            self.config.secrets.clone(),
190            self.config.strings.clone(),
191            self.config.allow_private_connect,
192            self.config.upstream_ca.clone(),
193            self.config.connections.clone(),
194            self.config.force_traffic_through_proxy,
195            self.config.sandbox_backend,
196        )
197        .await
198    }
199}
200
201/// Returns true if an outbound connection to `host` is allowed by the given policies.
202/// If policies is None or empty, returns true (allow all). Otherwise first matching policy wins; no match = allow.
203pub(crate) fn connection_allowed(host: &str, policies: Option<&[ConnectionPolicy]>) -> bool {
204    let Some(policies) = policies else {
205        return true;
206    };
207    if policies.is_empty() {
208        return true;
209    }
210    for p in policies {
211        if p.pattern.matches(host) {
212            return p.allow;
213        }
214    }
215    true
216}
217
218/// Characters that must not appear in real (or masked) secrets to avoid header/body injection.
219const FORBIDDEN_IN_SECRET: &[u8] = b"\r\n\0";
220
221fn validate_secret(value: &str) -> Result<(), String> {
222    if value.bytes().any(|b| FORBIDDEN_IN_SECRET.contains(&b)) {
223        return Err("secret must not contain CR, LF, or NUL".to_string());
224    }
225    Ok(())
226}
227
228/// Run as the runner (proxy + child) inside the sandbox backend (Docker/Firecracker). Reads RunnerConfig JSON from stdin.
229/// On Linux, brings up loopback first. Used when the process is invoked as `blinders --runner` inside the container/VM.
230#[cfg(target_os = "linux")]
231pub fn run_as_linux_runner(
232    config_json: String,
233) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
234    let config: runner::RunnerConfig =
235        serde_json::from_str(&config_json).map_err(|e| format!("runner config JSON: {}", e))?;
236    // In Docker, loopback is already up; only bring it up when needed (e.g. Firecracker VM).
237    if !std::path::Path::new("/.dockerenv").exists() {
238        enforce_linux::bring_up_loopback()?;
239    }
240    let connection_policies =
241        runner::runner_rules_to_connection_policies(&config.connection_policies)?;
242    let upstream_ca = config.upstream_ca.map(std::path::PathBuf::from);
243    let rt = tokio::runtime::Runtime::new().map_err(|e| format!("tokio runtime: {}", e))?;
244    rt.block_on(run_impl_inner(
245        &config.cmd,
246        config.secret_mappings,
247        config.string_mappings,
248        config.allow_private_connect,
249        upstream_ca,
250        connection_policies,
251        false, // already in isolate, no need to force again
252        &enforce::ENV_ONLY_ENFORCER,
253    ))
254}
255
256#[allow(clippy::too_many_arguments)]
257async fn run_impl(
258    cmd: &str,
259    secret_mappings: Vec<SecretMapping>,
260    string_mappings: Vec<StringMapping>,
261    allow_private_connect: bool,
262    upstream_ca: Option<std::path::PathBuf>,
263    connection_policies: Vec<ConnectionPolicy>,
264    force_traffic_through_proxy: bool,
265    sandbox_backend: Option<backend::SandboxBackend>,
266) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
267    let enforcer = enforce::enforcer_for(sandbox_backend, force_traffic_through_proxy)?;
268    if force_traffic_through_proxy {
269        if let Some(status) = enforcer.maybe_spawn_runner(
270            cmd,
271            &secret_mappings,
272            &string_mappings,
273            allow_private_connect,
274            &upstream_ca,
275            &connection_policies,
276        )? {
277            return Ok(status);
278        }
279    }
280
281    run_impl_inner(
282        cmd,
283        secret_mappings,
284        string_mappings,
285        allow_private_connect,
286        upstream_ca,
287        connection_policies,
288        force_traffic_through_proxy,
289        enforcer,
290    )
291    .await
292}
293
294#[allow(clippy::too_many_arguments)]
295async fn run_impl_inner(
296    cmd: &str,
297    secret_mappings: Vec<SecretMapping>,
298    string_mappings: Vec<StringMapping>,
299    allow_private_connect: bool,
300    upstream_ca: Option<std::path::PathBuf>,
301    connection_policies: Vec<ConnectionPolicy>,
302    force_traffic_through_proxy: bool,
303    enforcer: &'static dyn enforce::Enforce,
304) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
305    if secret_mappings.is_empty() && string_mappings.is_empty() {
306        return Err("sandbox requires at least one secret mapping or string mapping".into());
307    }
308
309    for m in &secret_mappings {
310        validate_secret(&m.value).map_err(|e| format!("{}: {}", m.var, e))?;
311    }
312    for m in &string_mappings {
313        validate_secret(&m.value).map_err(|e| format!("{}: {}", m.token, e))?;
314    }
315
316    let mut env_vars_with_masked: Vec<(String, String)> = Vec::with_capacity(secret_mappings.len());
317    let mut proxy_map: HashMap<String, String> =
318        HashMap::with_capacity(secret_mappings.len() + string_mappings.len());
319
320    for m in &secret_mappings {
321        let masked = format!(
322            "{}-{}",
323            m.var.to_lowercase().replace('_', "-"),
324            uuid::Uuid::new_v4()
325        );
326        env_vars_with_masked.push((m.var.clone(), masked.clone()));
327        proxy_map.insert(masked, m.value.clone());
328    }
329    for m in &string_mappings {
330        proxy_map.insert(m.token.clone(), m.value.clone());
331    }
332
333    // Deterministic replacement order: longest token first so substrings are not partially replaced.
334    let replacement_order: Vec<(String, String)> = {
335        let mut v: Vec<_> = proxy_map.into_iter().collect();
336        v.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
337        v
338    };
339    let token_map = Arc::new(replacement_order);
340
341    let (mitm_config, ssl_cert_file) = {
342        let (config, ca_pem) =
343            mitm::MitmConfig::new(upstream_ca).map_err(|e| format!("MITM config: {}", e))?;
344        let temp = std::env::temp_dir().join(format!("blinders-ca-{}.pem", uuid::Uuid::new_v4()));
345        std::fs::write(&temp, &ca_pem).map_err(|e| format!("write CA cert: {}", e))?;
346        (Arc::new(config), temp)
347    };
348
349    let listener = TcpListener::bind("127.0.0.1:0").await?;
350    let port = listener.local_addr()?.port();
351    let proxy_url = format!("http://127.0.0.1:{}", port);
352
353    let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
354    let server_handle = tokio::spawn(async move {
355        loop {
356            tokio::select! {
357                _ = shutdown_rx.recv() => break,
358                accept_result = listener.accept() => {
359                    let (stream, _) = match accept_result {
360                        Ok(x) => x,
361                        Err(_) => continue,
362                    };
363                    let token_map = Arc::clone(&token_map);
364                    let allow_private = allow_private_connect;
365                    let mitm_config = Arc::clone(&mitm_config);
366                    let connection_policies = connection_policies.clone();
367                    tokio::spawn(async move {
368                        let io = TokioIo::new(stream);
369                        let service = service_fn(move |req| {
370                            let token_map = Arc::clone(&token_map);
371                            let mitm_config = Arc::clone(&mitm_config);
372                            let connection_policies = connection_policies.clone();
373                            async move { proxy_handler(req, token_map, allow_private, mitm_config, connection_policies).await }
374                        });
375                        let conn = hyper::server::conn::http1::Builder::new()
376                            .serve_connection(io, service)
377                            .with_upgrades();
378                        if let Err(e) = conn.await {
379                            eprintln!("proxy connection error: {}", e);
380                        }
381                    });
382                }
383            }
384        }
385    });
386
387    let cmd = cmd.to_string();
388    let env_vars_with_masked = env_vars_with_masked;
389    let exit_status = tokio::task::spawn_blocking(move || {
390        enforcer.run_child(
391            &cmd,
392            &proxy_url,
393            &env_vars_with_masked,
394            &ssl_cert_file,
395            force_traffic_through_proxy,
396        )
397    })
398    .await
399    .map_err(|e| format!("subprocess join: {}", e))??;
400
401    let _ = shutdown_tx.send(()).await;
402    let _ = server_handle.await;
403
404    Ok(exit_status)
405}
406
407/// Re-exported for the MITM module when feature "mitm" is enabled.
408pub(crate) type BoxBodyType = BoxBody<Bytes, hyper::Error>;
409
410fn full_body(chunk: Bytes) -> BoxBodyType {
411    Full::new(chunk).map_err(|never| match never {}).boxed()
412}
413
414pub(crate) fn bad_request(msg: &str) -> Response<BoxBodyType> {
415    Response::builder()
416        .status(http::StatusCode::BAD_REQUEST)
417        .body(full_body(Bytes::from(msg.to_string())))
418        .unwrap()
419}
420
421pub(crate) fn bad_gateway(msg: &str) -> Response<BoxBodyType> {
422    Response::builder()
423        .status(http::StatusCode::BAD_GATEWAY)
424        .body(full_body(Bytes::from(msg.to_string())))
425        .unwrap()
426}
427
428/// Replace all occurrences of `from` with `to` in `buf` (byte-wise).
429fn replace_bytes(buf: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
430    if from.is_empty() || buf.is_empty() {
431        return buf.to_vec();
432    }
433    let mut out = Vec::with_capacity(buf.len());
434    let mut i = 0;
435    while i <= buf.len().saturating_sub(from.len()) {
436        if buf[i..].starts_with(from) {
437            out.extend_from_slice(to);
438            i += from.len();
439        } else {
440            out.push(buf[i]);
441            i += 1;
442        }
443    }
444    out.extend_from_slice(&buf[i..]);
445    out
446}
447
448/// Apply token replacements (longest first) to a byte buffer.
449pub(crate) fn replace_tokens_in_bytes(
450    buf: &[u8],
451    replacement_order: &[(String, String)],
452) -> Vec<u8> {
453    let mut current = buf.to_vec();
454    for (masked, real) in replacement_order {
455        current = replace_bytes(&current, masked.as_bytes(), real.as_bytes());
456    }
457    current
458}
459
460/// Apply token replacements to a header value string; returns None if result would be invalid.
461pub(crate) fn replace_tokens_in_header_value(
462    value: &str,
463    replacement_order: &[(String, String)],
464) -> Option<String> {
465    let mut s = value.to_string();
466    for (masked, real) in replacement_order {
467        s = s.replace(masked, real);
468    }
469    if s.bytes().any(|b| FORBIDDEN_IN_SECRET.contains(&b)) {
470        return None;
471    }
472    Some(s)
473}
474
475/// Check if the authority host is a private/local address (SSRF mitigation).
476fn is_private_authority(authority: &str) -> bool {
477    let (host, _port) = authority.split_once(':').unwrap_or((authority, "80"));
478    let host = host.trim_start_matches('[').trim_end_matches(']');
479    if host == "localhost" || host.is_empty() {
480        return true;
481    }
482    if let Ok(ip) = host.parse::<std::net::IpAddr>() {
483        return is_private_ip(ip);
484    }
485    false
486}
487
488fn is_private_ip(ip: std::net::IpAddr) -> bool {
489    match ip {
490        std::net::IpAddr::V4(a) => {
491            a.is_loopback()
492                || a.is_private()
493                || a.is_link_local()
494                || a.is_broadcast()
495                || a.is_documentation()
496        }
497        std::net::IpAddr::V6(a) => a.is_loopback() || a.is_unspecified(),
498    }
499}
500
501async fn proxy_handler(
502    req: Request<hyper::body::Incoming>,
503    token_map: Arc<Vec<(String, String)>>,
504    allow_private_connect: bool,
505    mitm_config: Arc<mitm::MitmConfig>,
506    connection_policies: Vec<ConnectionPolicy>,
507) -> Result<Response<BoxBodyType>, hyper::Error> {
508    if req.method() == Method::CONNECT {
509        let authority = match req.uri().authority() {
510            Some(a) => a.to_string(),
511            None => return Ok(bad_request("CONNECT must include authority (host:port)")),
512        };
513        let host = req
514            .uri()
515            .authority()
516            .map(|a| a.host().to_string())
517            .unwrap_or_default();
518        if !connection_allowed(host.as_str(), Some(&connection_policies)) {
519            return Ok(bad_request("CONNECT to this host is not allowed by policy"));
520        }
521        if !allow_private_connect && is_private_authority(&authority) {
522            return Ok(bad_request("CONNECT to private/local address not allowed"));
523        }
524        return mitm::handle_connect_mitm(req, token_map, mitm_config, connection_policies).await;
525    }
526    handle_forward(req, token_map, connection_policies).await
527}
528
529async fn handle_forward(
530    req: Request<hyper::body::Incoming>,
531    token_map: Arc<Vec<(String, String)>>,
532    connection_policies: Vec<ConnectionPolicy>,
533) -> Result<Response<BoxBodyType>, hyper::Error> {
534    let (parts, body) = req.into_parts();
535    let uri = parts.uri.clone();
536    let uri_str = uri.to_string();
537    let modified_uri_bytes = replace_tokens_in_bytes(uri_str.as_bytes(), &token_map);
538    let modified_uri = match String::from_utf8(modified_uri_bytes) {
539        Ok(s) => s.parse().unwrap_or(uri),
540        Err(_) => uri.clone(),
541    };
542    let host = match modified_uri.host() {
543        Some(h) => h.to_string(),
544        None => return Ok(bad_request("Request URI has no host")),
545    };
546    if !connection_allowed(host.as_str(), Some(&connection_policies)) {
547        return Ok(bad_request(
548            "Connection to this host is not allowed by policy",
549        ));
550    }
551    let port = modified_uri.port_u16().unwrap_or(80);
552
553    let body_bytes = body.collect().await?.to_bytes();
554    let modified_body = replace_tokens_in_bytes(&body_bytes, &token_map);
555
556    let mut new_headers = http::HeaderMap::new();
557    for (name, value) in parts.headers.iter() {
558        if name == http::header::CONNECTION
559            || name.as_str().eq_ignore_ascii_case("proxy-connection")
560            || name == http::header::TRANSFER_ENCODING
561        {
562            continue;
563        }
564        let value_str = match value.to_str() {
565            Ok(s) => s,
566            Err(_) => continue,
567        };
568        let new_value = replace_tokens_in_header_value(value_str, &token_map);
569        if let Some(v) = new_value {
570            if let Ok(hv) = v.parse() {
571                new_headers.insert(name.clone(), hv);
572            }
573        }
574    }
575    new_headers.insert(
576        http::header::CONTENT_LENGTH,
577        http::HeaderValue::from_str(&modified_body.len().to_string()).unwrap(),
578    );
579
580    let body = http_body_util::Full::new(bytes::Bytes::from(modified_body));
581    let new_req = match Request::builder()
582        .method(parts.method)
583        .uri(&modified_uri)
584        .body(body)
585    {
586        Ok(r) => r,
587        Err(_) => return Ok(bad_gateway("Invalid request")),
588    };
589    let (mut new_parts, new_body) = new_req.into_parts();
590    new_parts.headers = new_headers;
591    let new_req = Request::from_parts(new_parts, new_body);
592
593    let stream = match TcpStream::connect((host.as_str(), port)).await {
594        Ok(s) => s,
595        Err(e) => {
596            eprintln!("proxy connect error: {}", e);
597            return Ok(bad_gateway("Upstream connection failed"));
598        }
599    };
600    let io = TokioIo::new(stream);
601    let (mut sender, conn) = match hyper::client::conn::http1::Builder::new()
602        .handshake(io)
603        .await
604    {
605        Ok(x) => x,
606        Err(e) => {
607            eprintln!("proxy handshake error: {}", e);
608            return Ok(bad_gateway("Upstream handshake failed"));
609        }
610    };
611    tokio::spawn(async move {
612        let _ = conn.await;
613    });
614
615    let resp = match sender.send_request(new_req).await {
616        Ok(r) => r,
617        Err(e) => {
618            eprintln!("proxy send error: {}", e);
619            return Ok(bad_gateway("Upstream request failed"));
620        }
621    };
622    let (resp_parts, resp_body) = resp.into_parts();
623    let resp_body = resp_body.boxed();
624    let resp = Response::from_parts(resp_parts, resp_body);
625    Ok(resp)
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn secret_mapping_creation() {
634        let m = SecretMapping {
635            var: "API_KEY".to_string(),
636            value: "secret123".to_string(),
637        };
638        assert_eq!(m.var, "API_KEY");
639        assert_eq!(m.value, "secret123");
640    }
641
642    #[test]
643    fn sandbox_new_default_config() {
644        let _sandbox = Sandbox::new(SandboxConfig::default());
645    }
646
647    #[test]
648    fn sandbox_new_one_mapping() {
649        let m = SecretMapping {
650            var: "X".to_string(),
651            value: "y".to_string(),
652        };
653        let config = SandboxConfig {
654            secrets: vec![m],
655            ..SandboxConfig::default()
656        };
657        let _sandbox = Sandbox::new(config);
658    }
659
660    #[test]
661    fn sandbox_new_multiple_mappings() {
662        let mappings = vec![
663            SecretMapping {
664                var: "A".to_string(),
665                value: "a".to_string(),
666            },
667            SecretMapping {
668                var: "B".to_string(),
669                value: "b".to_string(),
670            },
671        ];
672        let config = SandboxConfig {
673            secrets: mappings,
674            ..SandboxConfig::default()
675        };
676        let _sandbox = Sandbox::new(config);
677    }
678
679    #[tokio::test]
680    async fn sandbox_clone() {
681        let m = SecretMapping {
682            var: "K".to_string(),
683            value: "v".to_string(),
684        };
685        let config = SandboxConfig {
686            secrets: vec![m],
687            force_traffic_through_proxy: false,
688            ..SandboxConfig::default()
689        };
690        let sandbox = Sandbox::new(config);
691        let cloned = sandbox.clone();
692        let status = cloned.run("true").await;
693        assert!(status.is_ok());
694        assert!(status.unwrap().success());
695    }
696
697    #[tokio::test]
698    async fn run_requires_at_least_one_mapping() {
699        let config = SandboxConfig {
700            force_traffic_through_proxy: false,
701            ..SandboxConfig::default()
702        };
703        let sandbox = Sandbox::new(config);
704        let result = sandbox.run("true").await;
705        assert!(result.is_err());
706        let err = result.unwrap_err();
707        assert!(
708            err.to_string()
709                .contains("at least one secret mapping or string mapping"),
710            "expected message about mapping, got: {}",
711            err
712        );
713    }
714
715    #[tokio::test]
716    async fn run_with_string_mapping_only() {
717        let config = SandboxConfig {
718            strings: vec![StringMapping {
719                token: "__TOKEN__".to_string(),
720                value: "replaced".to_string(),
721            }],
722            force_traffic_through_proxy: false,
723            ..SandboxConfig::default()
724        };
725        let sandbox = Sandbox::new(config);
726        let result = sandbox.run("true").await;
727        assert!(result.is_ok());
728        assert!(result.unwrap().success());
729    }
730
731    #[tokio::test]
732    async fn run_returns_exit_status_success() {
733        let config = SandboxConfig {
734            secrets: vec![SecretMapping {
735                var: "X".to_string(),
736                value: "x".to_string(),
737            }],
738            force_traffic_through_proxy: false,
739            ..SandboxConfig::default()
740        };
741        let sandbox = Sandbox::new(config);
742        let result = sandbox.run("true").await;
743        assert!(result.is_ok(), "{:?}", result.err());
744        assert!(result.unwrap().success());
745    }
746
747    #[tokio::test]
748    async fn run_returns_exit_status_failure() {
749        let config = SandboxConfig {
750            secrets: vec![SecretMapping {
751                var: "X".to_string(),
752                value: "x".to_string(),
753            }],
754            force_traffic_through_proxy: false,
755            ..SandboxConfig::default()
756        };
757        let sandbox = Sandbox::new(config);
758        let result = sandbox.run("false").await;
759        assert!(result.is_ok());
760        assert!(!result.unwrap().success());
761    }
762
763    #[tokio::test]
764    async fn run_forward_exit_code() {
765        let config = SandboxConfig {
766            secrets: vec![SecretMapping {
767                var: "X".to_string(),
768                value: "x".to_string(),
769            }],
770            force_traffic_through_proxy: false,
771            ..SandboxConfig::default()
772        };
773        let sandbox = Sandbox::new(config);
774        let result = sandbox.run("sh -c 'exit 42'").await;
775        assert!(result.is_ok());
776        assert_eq!(result.unwrap().code(), Some(42));
777    }
778
779    #[tokio::test]
780    async fn run_command_sees_masked_env() {
781        let config = SandboxConfig {
782            secrets: vec![SecretMapping {
783                var: "API_KEY".to_string(),
784                value: "real-secret".to_string(),
785            }],
786            force_traffic_through_proxy: false,
787            ..SandboxConfig::default()
788        };
789        let sandbox = Sandbox::new(config);
790        let result = sandbox.run("sh -c 'v=$API_KEY; if [ \"$v\" = \"real-secret\" ]; then exit 1; fi; case \"$v\" in api-key-*) exit 0;; *) exit 2;; esac'").await;
791        assert!(result.is_ok(), "run failed");
792        assert_eq!(
793            result.unwrap().code(),
794            Some(0),
795            "expected masked token pattern api-key-*"
796        );
797    }
798
799    #[tokio::test]
800    async fn run_sets_http_proxy_env() {
801        let config = SandboxConfig {
802            secrets: vec![SecretMapping {
803                var: "X".to_string(),
804                value: "x".to_string(),
805            }],
806            force_traffic_through_proxy: false,
807            ..SandboxConfig::default()
808        };
809        let sandbox = Sandbox::new(config);
810        let result = sandbox
811            .run("sh -c 'case \"$HTTP_PROXY\" in http://127.0.0.1*) exit 0;; *) exit 1;; esac'")
812            .await;
813        assert!(result.is_ok());
814        assert_eq!(result.unwrap().code(), Some(0));
815        let result = sandbox
816            .run("sh -c 'case \"$HTTPS_PROXY\" in http://127.0.0.1*) exit 0;; *) exit 1;; esac'")
817            .await;
818        assert!(result.is_ok());
819        assert_eq!(result.unwrap().code(), Some(0));
820    }
821
822    #[test]
823    fn replace_bytes_basic() {
824        let buf = b"hello world";
825        let out = replace_bytes(buf, b"o", b"X");
826        assert_eq!(out.as_slice(), b"hellX wXrld");
827    }
828
829    #[test]
830    fn replace_tokens_longest_first() {
831        let order = vec![
832            ("api-key-long".to_string(), "real-long".to_string()),
833            ("api-key".to_string(), "real-short".to_string()),
834        ];
835        let buf = b"prefix api-key-long suffix";
836        let out = replace_tokens_in_bytes(buf, &order);
837        assert_eq!(out.as_slice(), b"prefix real-long suffix");
838    }
839
840    #[test]
841    fn is_private_authority_blocks_localhost() {
842        assert!(is_private_authority("localhost:443"));
843        assert!(is_private_authority("127.0.0.1:8080"));
844        assert!(is_private_authority("10.0.0.1:80"));
845        assert!(!is_private_authority("example.com:443"));
846    }
847
848    #[test]
849    fn validate_secret_rejects_crlf() {
850        assert!(validate_secret("ok").is_ok());
851        assert!(validate_secret("no\rcr").is_err());
852        assert!(validate_secret("no\nlf").is_err());
853        assert!(validate_secret("no\0nul").is_err());
854    }
855
856    #[test]
857    fn string_mapping_creation() {
858        let m = StringMapping {
859            token: "__API_KEY__".to_string(),
860            value: "secret123".to_string(),
861        };
862        assert_eq!(m.token, "__API_KEY__");
863        assert_eq!(m.value, "secret123");
864    }
865
866    #[test]
867    fn replace_tokens_in_bytes_string_mapping() {
868        let order = vec![("__TOKEN__".to_string(), "real-value".to_string())];
869        let buf = b"Bearer __TOKEN__";
870        let out = replace_tokens_in_bytes(buf, &order);
871        assert_eq!(out.as_slice(), b"Bearer real-value");
872    }
873
874    #[test]
875    fn connection_allowed_no_policies() {
876        assert!(connection_allowed("example.com", None));
877        assert!(connection_allowed("evil.com", Some(&[])));
878    }
879
880    #[test]
881    fn connection_allowed_first_match_wins() {
882        let policies = vec![
883            ConnectionPolicy::deny(HostPattern::exact("blocked.com")),
884            ConnectionPolicy::allow(HostPattern::exact("blocked.com")),
885        ];
886        assert!(!connection_allowed("blocked.com", Some(&policies)));
887    }
888
889    #[test]
890    fn connection_allowed_regex() {
891        let policies = vec![
892            ConnectionPolicy::deny(HostPattern::regex(r"^internal\.").unwrap()),
893            ConnectionPolicy::allow(HostPattern::exact("api.example.com")),
894        ];
895        assert!(!connection_allowed("internal.service", Some(&policies)));
896        assert!(connection_allowed("api.example.com", Some(&policies)));
897        assert!(connection_allowed("other.com", Some(&policies))); // no match = allow
898    }
899}