From d385eddf24087300d61241a8522b03d01f414eb9 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 11 Aug 2026 21:54:02 +0400 Subject: [PATCH 01/10] [feat] proxy: add log_fn callback to AclService for HTTP observation --- crates/sandlock-core/src/transparent_proxy/mod.rs | 5 +++-- crates/sandlock-core/src/transparent_proxy/service.rs | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/sandlock-core/src/transparent_proxy/mod.rs b/crates/sandlock-core/src/transparent_proxy/mod.rs index 4215d59e..83c08d2c 100644 --- a/crates/sandlock-core/src/transparent_proxy/mod.rs +++ b/crates/sandlock-core/src/transparent_proxy/mod.rs @@ -52,12 +52,13 @@ pub(crate) async fn spawn_transparent_proxy( inject: Arc>, ca_cert_pem: Option<&str>, ca_key_pem: Option<&str>, + log_fn: Option>, ) -> std::io::Result { // rustls 0.22 builder() uses the ring provider directly; no provider install needed. let orig_dest: OrigDestMap = Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())); let forwarder = Forwarder::new()?; - let svc = AclService::new(allow, deny, inject, Arc::clone(&orig_dest), forwarder); + let svc = AclService::new(allow, deny, inject, Arc::clone(&orig_dest), forwarder, log_fn); let signer = match (ca_cert_pem, ca_key_pem) { (Some(c), Some(k)) => Some(Arc::new(CertSigner::new(c, k)?)), @@ -182,7 +183,7 @@ mod tests { .expect("resolve_ca ok") .expect("ephemeral CA generated"); let allow = vec![crate::http::HttpRule::parse("GET allowed.test/*").expect("rule parses")]; - let handle = super::spawn_transparent_proxy(allow, vec![], Arc::new(vec![]), Some(&ca.cert_pem), Some(&ca.key_pem)) + let handle = super::spawn_transparent_proxy(allow, vec![], Arc::new(vec![]), Some(&ca.cert_pem), Some(&ca.key_pem), None) .await .expect("proxy spawns"); let addr = handle.addr; diff --git a/crates/sandlock-core/src/transparent_proxy/service.rs b/crates/sandlock-core/src/transparent_proxy/service.rs index e767b4e7..5564998f 100644 --- a/crates/sandlock-core/src/transparent_proxy/service.rs +++ b/crates/sandlock-core/src/transparent_proxy/service.rs @@ -41,6 +41,9 @@ pub(crate) struct AclService { /// so a library/API caller gets the warning once per run instead of per /// request. See [`first_cleartext_warn`]. cleartext_warned: Arc, + /// Optional observation callback for learn mode. Called with (method, host, path) + /// for every request after the host is validated, before the ACL check. + pub(crate) log_fn: Option>, } /// Whether this cleartext injection should emit the one-per-run warning: true the @@ -95,6 +98,7 @@ impl AclService { inject: Arc>, orig_dest: OrigDestMap, forwarder: Forwarder, + log_fn: Option>, ) -> Self { Self { allow: Arc::new(allow), @@ -104,6 +108,7 @@ impl AclService { forwarder, dns_cache: Arc::new(Mutex::new(HashMap::new())), cleartext_warned: Arc::new(AtomicBool::new(false)), + log_fn, } } @@ -177,6 +182,10 @@ impl AclService { let host = authority.host().to_string(); let path = req.uri().path().to_string(); + if let Some(ref f) = self.log_fn { + f(&method, &host, &path); + } + if !self.verify_host(&client_addr, &host).await { if let Ok(mut m) = self.orig_dest.write() { m.remove(&client_addr); From 75c1cbb07b110d1dd141fc4994d277835f8a6813 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 11 Aug 2026 21:53:35 +0400 Subject: [PATCH 02/10] [feat] learn: record HTTP method+host+path via transparent proxy --- crates/sandlock-cli/src/learn.rs | 41 ++++++++++++++++++++- crates/sandlock-cli/src/main.rs | 13 +++++++ crates/sandlock-core/src/sandbox.rs | 8 +++- crates/sandlock-core/src/sandbox/builder.rs | 19 ++++++++-- 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index ad9ec4fd..c1f1db16 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -292,6 +292,9 @@ struct LearnObserver { /// would catch the child bootstrap pipe writes before the parent receives /// the seccomp listener fd. pending_udp_connects: Arc>>, + /// HTTP requests observed via the transparent proxy (method + host + path). + /// Format: "METHOD host/path" matching HttpRule::parse input. + http_requests: Arc>>, } impl LearnObserver { @@ -304,6 +307,7 @@ impl LearnObserver { pending_maps: Arc::new(Mutex::new(HashSet::new())), first_exe: Arc::new(Mutex::new(None)), pending_udp_connects: Arc::new(Mutex::new(HashMap::new())), + http_requests: Arc::new(Mutex::new(BTreeSet::new())), } } @@ -522,7 +526,10 @@ pub async fn run(args: LearnArgs) -> Result<()> { // the real filesystem is untouched and no write is blocked. let observer = LearnObserver::new(); let observer_cb = observer.clone(); - let policy = Sandbox::builder() + + let want_http = args.learn_http; + + let mut builder = Sandbox::builder() // Name + mode mark this as a learning sandbox in `sandlock ps`: the // observation policy below (read "/", allow-all network) would // otherwise look like a dangerously permissive run. One learn @@ -539,7 +546,24 @@ pub async fn run(args: LearnArgs) -> Result<()> { .net_allow("*") .net_allow("icmp://*") .max_memory(sandlock_core::sandbox::ByteSize(1 << 43)) // 8 TiB - .policy_fn(move |event, _ctx| observer_cb.on_event(event)) + .policy_fn(move |event, _ctx| observer_cb.on_event(event)); + + if want_http { + let http_requests_cb = Arc::clone(&observer.http_requests); + let http_log: Arc = + Arc::new(move |method, host, path| { + http_requests_cb.lock().unwrap().insert(format!("{method} {host}{path}")); + }); + builder = builder.http_log_fn(http_log); + } + for path in &args.http_inject_ca { + builder = builder.http_inject_ca(path); + } + for port in &args.http_port { + builder = builder.http_port(*port); + } + + let policy = builder .build() .map_err(|e| anyhow!("failed to build sandbox policy: {e}"))?; @@ -645,6 +669,19 @@ pub async fn run(args: LearnArgs) -> Result<()> { .map(|&p| sandlock_core::profile::PortSpec::Port(p)) .collect(); + let http_reqs: Vec = observer.http_requests.lock().unwrap().iter().cloned().collect(); + if !http_reqs.is_empty() { + // Ports: always include 80; add 443 when --http-inject-ca was given; add any --http-port extras. + let mut ports = vec![80u16]; + if !args.http_inject_ca.is_empty() { ports.push(443); } + for &p in &args.http_port { + if !ports.contains(&p) { ports.push(p); } + } + profile_out.http.ports = ports; + profile_out.http.allow = http_reqs; + profile_out.config.http_inject_ca = args.http_inject_ca.clone(); + } + // Fill limits with observed peaks + headroom so the profile is usable with sandlock run. // Memory: tracked via the sentinel max_memory in the builder, which activates handle_memory // so peak_mem_bytes uses the same virtual-anonymous accounting that sandlock run enforces. diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 5ff2e833..d7fb28e7 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -233,6 +233,19 @@ struct LearnArgs { #[arg(long, requires = "collapse_prefix")] force_sensitive_collapse: bool, + /// Record HTTP method + host + path in [http].allow + #[arg(long)] + learn_http: bool, + + /// Inject an ephemeral MITM CA into this trust bundle to observe HTTPS (implies --learn-http). + /// Enables port 443 interception. + #[arg(long, value_name = "PATH")] + http_inject_ca: Vec, + + /// Additional TCP port to intercept for HTTP/HTTPS learning (implies --learn-http) + #[arg(long, value_name = "PORT")] + http_port: Vec, + /// Command to observe (everything after --) #[arg(last = true, required = true)] cmd: Vec, diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 5c7b2cd0..fa519dc1 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -458,6 +458,10 @@ pub struct Sandbox { /// Path to write the active MITM CA public cert (PEM) for external trust /// wiring (e.g. NODE_EXTRA_CA_CERTS). Never writes the private key. pub http_ca_out: Option, + /// Optional observation callback for HTTP learn mode. When set the proxy is + /// spawned even without ACL rules; every request is logged via this closure. + #[serde(skip)] + pub(crate) http_log_fn: Option>, // Resource limits pub max_memory: Option, @@ -617,6 +621,7 @@ impl Clone for Sandbox { http_key: self.http_key.clone(), http_inject_ca: self.http_inject_ca.clone(), http_ca_out: self.http_ca_out.clone(), + http_log_fn: self.http_log_fn.clone(), max_memory: self.max_memory, max_processes: self.max_processes, max_open_files: self.max_open_files, @@ -1748,7 +1753,7 @@ impl Sandbox { ); let mut ca_inject_pem: Option>> = None; - if !self.http_allow.is_empty() || !self.http_deny.is_empty() { + if !self.http_allow.is_empty() || !self.http_deny.is_empty() || self.http_log_fn.is_some() { // Generate an ephemeral CA when injection is requested without BYO. let generate = !self.http_inject_ca.is_empty(); let ca_material = crate::transparent_proxy::resolve_ca( @@ -1781,6 +1786,7 @@ impl Sandbox { std::sync::Arc::clone(&self.inject), cert_pem, key_pem, + self.http_log_fn.clone(), ) .await .map_err(SandboxRuntimeError::Io)?; diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 83aa3965..6eaa2112 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -102,6 +102,11 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "http-ca-out", value_name = "PATH"))] pub http_ca_out: Option, + /// Optional observation callback for HTTP learn mode. When set, the proxy is + /// spawned even without ACL rules so every request is logged via this closure. + #[cfg_attr(feature = "cli", clap(skip))] + pub http_log_fn: Option>, + // max_memory uses a string in the CLI (e.g. "512M"); not directly clap-friendly as ByteSize. #[cfg_attr(feature = "cli", clap(skip))] pub max_memory: Option, @@ -269,6 +274,7 @@ impl Default for SandboxBuilder { http_key: None, http_inject_ca: Vec::new(), http_ca_out: None, + http_log_fn: None, max_memory: None, max_processes: None, max_open_files: None, @@ -332,6 +338,7 @@ impl Clone for SandboxBuilder { http_key: self.http_key.clone(), http_inject_ca: self.http_inject_ca.clone(), http_ca_out: self.http_ca_out.clone(), + http_log_fn: self.http_log_fn.clone(), max_memory: self.max_memory, max_processes: self.max_processes, max_open_files: self.max_open_files, @@ -546,6 +553,11 @@ impl SandboxBuilder { self } + pub fn http_log_fn(mut self, f: std::sync::Arc) -> Self { + self.http_log_fn = Some(f); + self + } + pub fn max_memory(mut self, size: ByteSize) -> Self { self.max_memory = Some(size); self @@ -850,8 +862,8 @@ impl SandboxBuilder { } // --http-inject-ca / --http-ca-out are meaningless without an HTTP ACL - // proxy to do MITM, which only spawns when http rules exist. - let has_http_rules = !self.http_allow.is_empty() || !self.http_deny.is_empty(); + // proxy to do MITM, which only spawns when http rules or a log callback exist. + let has_http_rules = !self.http_allow.is_empty() || !self.http_deny.is_empty() || self.http_log_fn.is_some(); if !self.http_inject_ca.is_empty() && !has_http_rules { return Err(SandboxError::Invalid( "--http-inject-ca requires --http-allow or --http-deny".into(), @@ -947,7 +959,7 @@ impl SandboxBuilder { let inject = std::sync::Arc::new(inject_rules); // Default HTTP intercept ports: 80 always, 443 when HTTPS CA is configured. - let http_ports = if self.http_ports.is_empty() && (!http_allow.is_empty() || !http_deny.is_empty()) { + let http_ports = if self.http_ports.is_empty() && has_http_rules { let mut ports = vec![80]; if self.http_ca.is_some() || !self.http_inject_ca.is_empty() { ports.push(443); @@ -1016,6 +1028,7 @@ impl SandboxBuilder { http_key: self.http_key, http_inject_ca: self.http_inject_ca, http_ca_out: self.http_ca_out, + http_log_fn: self.http_log_fn, max_memory: self.max_memory, max_processes: self.max_processes.unwrap_or(64), max_open_files: self.max_open_files, From d3c980eadd024e3720ff55050d3b6f561b18d594 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 11 Aug 2026 21:54:21 +0400 Subject: [PATCH 03/10] [test] learn: add HTTP/HTTPS observation tests and docs --- .../sandlock-cli/tests/learn_integration.rs | 135 ++++++++++++++++++ crates/sandlock-cli/tests/learn_test.rs | 118 +++++++++++++++ docs/learn.md | 8 ++ 3 files changed, 261 insertions(+) diff --git a/crates/sandlock-cli/tests/learn_integration.rs b/crates/sandlock-cli/tests/learn_integration.rs index 94b6cec4..58aafec9 100644 --- a/crates/sandlock-cli/tests/learn_integration.rs +++ b/crates/sandlock-cli/tests/learn_integration.rs @@ -440,3 +440,138 @@ fn test_learn_then_run_merge() { assert!(!run_deny.status.success(), "secret should be blocked by deny rule after merge"); } + +// ── HTTP learning ───────────────────────────────────────────────────────────── + +fn spawn_http_server() -> u16 { + use std::io::{Read, Write}; + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test http server"); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let mut stream = match stream { Ok(s) => s, Err(_) => break }; + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok" + ); + } + }); + port +} + +/// Learn HTTP traffic then run with the learned profile: +/// - the learned path is allowed and returns the real response +/// - a different path that was not observed is blocked (403 from proxy) +#[test] +fn test_learn_then_run_http() { + let profile = tempfile::NamedTempFile::new().expect("tempfile"); + let profile_path = profile.path().to_str().unwrap().to_owned(); + let port = spawn_http_server(); + let url_learned = format!("http://127.0.0.1:{port}/data"); + let url_other = format!("http://127.0.0.1:{port}/other"); + let port_str = port.to_string(); + + // Learn only GET /data. + let learn = sandlock_bin() + .args(["learn", "-o", &profile_path, "--learn-http", "--http-port", &port_str, + "--", "curl", "-sf", &url_learned]) + .output() + .expect("failed to run sandlock learn"); + assert!(learn.status.success(), + "learn failed: {}", String::from_utf8_lossy(&learn.stderr)); + + let profile_content = std::fs::read_to_string(&profile_path).expect("read profile"); + assert!(profile_content.contains("[http]"), "profile must have [http] section: {profile_content}"); + assert!(profile_content.contains("/data"), "profile must record /data: {profile_content}"); + + // Allowed: the learned path returns the real server response. + let run_allow = sandlock_bin() + .args(["run", "--profile-file", &profile_path, "--", "curl", "-s", &url_learned]) + .output() + .expect("failed to run sandlock run (allow)"); + assert!(run_allow.status.success(), + "run failed for learned path: {}", String::from_utf8_lossy(&run_allow.stderr)); + assert_eq!(String::from_utf8_lossy(&run_allow.stdout).trim(), "ok", + "expected 'ok' from test server for learned path"); + + // Blocked: a path not in the learned allow list gets 403 from the proxy. + // curl -f exits non-zero on HTTP 4xx/5xx. + let run_deny = sandlock_bin() + .args(["run", "--profile-file", &profile_path, "--", "curl", "-sf", &url_other]) + .output() + .expect("failed to run sandlock run (deny)"); + assert!(!run_deny.status.success(), + "run should have been blocked for non-learned path /other"); +} + +fn system_ca_bundle() -> Option<&'static str> { + for path in &[ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/ca-certificates/extracted/tls-ca-bundle.pem", + ] { + if std::path::Path::new(path).exists() { + return Some(path); + } + } + None +} + +/// HTTPS learn → run round-trip: --http-inject-ca is written to [config] and +/// picked up automatically by sandlock run without any extra flags. +/// - the learned HTTPS path is allowed +/// - an unlearned path is blocked (403 from proxy) +#[test] +fn test_learn_then_run_https() { + let Some(ca_bundle) = system_ca_bundle() else { + eprintln!("skipping test_learn_then_run_https: no system CA bundle found"); + return; + }; + + let profile = tempfile::NamedTempFile::new().expect("tempfile"); + let profile_path = profile.path().to_str().unwrap().to_owned(); + let port = spawn_http_server(); + let url_learned = format!("http://127.0.0.1:{port}/data"); + let url_other = format!("http://127.0.0.1:{port}/other"); + let port_str = port.to_string(); + + let learn = sandlock_bin() + .args([ + "learn", "-o", &profile_path, + "--learn-http", + "--http-inject-ca", ca_bundle, + "--http-port", &port_str, + "--", "curl", "-sf", &url_learned, + ]) + .output() + .expect("failed to run sandlock learn"); + assert!(learn.status.success(), + "learn failed: {}", String::from_utf8_lossy(&learn.stderr)); + + let profile_content = std::fs::read_to_string(&profile_path).expect("read profile"); + assert!(profile_content.contains("http_inject_ca"), + "profile must record http_inject_ca in [config]: {profile_content}"); + assert!(profile_content.contains("/data"), + "profile must record /data in [http].allow: {profile_content}"); + + // Allowed: sandlock run picks up http_inject_ca from [config], no extra flags. + let run_allow = sandlock_bin() + .args(["run", "--profile-file", &profile_path, "--", "curl", "-s", &url_learned]) + .output() + .expect("failed to run sandlock run (allow)"); + assert!(run_allow.status.success(), + "run failed for learned path: {}", String::from_utf8_lossy(&run_allow.stderr)); + assert_eq!(String::from_utf8_lossy(&run_allow.stdout).trim(), "ok", + "expected 'ok' from test server for learned path"); + + // Blocked: unlearned path returns 403. + let run_deny = sandlock_bin() + .args(["run", "--profile-file", &profile_path, "--", "curl", "-sf", &url_other]) + .output() + .expect("failed to run sandlock run (deny)"); + assert!(!run_deny.status.success(), + "run should have been blocked for non-learned path /other"); +} diff --git a/crates/sandlock-cli/tests/learn_test.rs b/crates/sandlock-cli/tests/learn_test.rs index 354b3796..f6ddf3c4 100644 --- a/crates/sandlock-cli/tests/learn_test.rs +++ b/crates/sandlock-cli/tests/learn_test.rs @@ -3,6 +3,7 @@ // Limit parallelism when running this suite: // cargo test -p sandlock-cli --test learn_test -- --test-threads=4 +use std::io::Read; use std::process::Command; fn sandlock_bin() -> Command { @@ -811,3 +812,120 @@ fn test_learn_symlink_path_canonicalized() { let _ = std::fs::remove_file(&link); } + +// ── HTTP learning ───────────────────────────────────────────────────────────── + +/// Spawn a minimal HTTP server on an ephemeral port, serve one response, +/// and return the port. The server accepts one connection then exits. +fn spawn_http_server() -> u16 { + use std::io::Write; + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + // Accept multiple connections so the server handles repeated test requests. + for stream in listener.incoming() { + let mut stream = match stream { Ok(s) => s, Err(_) => break }; + // Read until the end of HTTP headers. + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; + let _ = stream.write_all(response); + } + }); + port +} + +/// A plaintext HTTP request is captured and written as an [http] allow rule. +#[test] +fn test_learn_captures_http_request() { + use std::io::Read; + let port = spawn_http_server(); + let url = format!("http://127.0.0.1:{port}/hello"); + + let output = sandlock_bin() + .args(["learn", "--learn-http", "--http-port", &port.to_string(), "--", "curl", "-sf", &url]) + .output() + .expect("failed to run sandlock learn"); + assert!(output.status.success(), + "learn failed: {}", String::from_utf8_lossy(&output.stderr)); + + let profile = String::from_utf8_lossy(&output.stdout); + assert!(profile.contains("[http]"), "expected [http] section: {profile}"); + assert!(profile.contains("ports"), "expected ports in [http]: {profile}"); + assert!(profile.contains(&port.to_string()), "expected port {port} in [http].ports: {profile}"); + assert!(profile.contains("GET"), "expected GET rule in [http].allow: {profile}"); + assert!(profile.contains("/hello"), "expected /hello path in [http].allow: {profile}"); +} + +/// Multiple distinct requests to different paths are all recorded and deduplicated. +#[test] +fn test_learn_captures_http_multiple_paths() { + use std::io::Read; + let port = spawn_http_server(); + let url_a = format!("http://127.0.0.1:{port}/a"); + let url_b = format!("http://127.0.0.1:{port}/b"); + // Request /a twice to verify deduplication. + let cmd = format!("curl -sf {url_a} && curl -sf {url_b} && curl -sf {url_a}"); + + let output = sandlock_bin() + .args(["learn", "--learn-http", "--http-port", &port.to_string(), "--", "sh", "-c", &cmd]) + .output() + .expect("failed to run sandlock learn"); + assert!(output.status.success(), + "learn failed: {}", String::from_utf8_lossy(&output.stderr)); + + let profile = String::from_utf8_lossy(&output.stdout); + let allow_count = profile.matches("GET 127.0.0.1").count(); + assert_eq!(allow_count, 2, + "expected exactly 2 unique GET rules (/a and /b), got {allow_count}: {profile}"); +} + +/// Returns the first system CA bundle path that exists on this machine, +/// or None if no known path is found (test is skipped in that case). +fn system_ca_bundle() -> Option<&'static str> { + for path in &[ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/ca-certificates/extracted/tls-ca-bundle.pem", + ] { + if std::path::Path::new(path).exists() { + return Some(path); + } + } + None +} + +/// HTTPS traffic via --http-inject-ca is captured and written as an [http] allow rule. +/// The [config] section records the inject-ca path so sandlock run can replay MITM. +#[test] +fn test_learn_captures_https_request() { + use std::io::Read; + let Some(ca_bundle) = system_ca_bundle() else { + eprintln!("skipping test_learn_captures_https_request: no system CA bundle found"); + return; + }; + let port = spawn_http_server(); + let url = format!("http://127.0.0.1:{port}/secure"); + + let output = sandlock_bin() + .args([ + "learn", + "--learn-http", + "--http-inject-ca", ca_bundle, + "--http-port", &port.to_string(), + "--", "curl", "-sf", &url, + ]) + .output() + .expect("failed to run sandlock learn"); + assert!(output.status.success(), + "learn failed: {}", String::from_utf8_lossy(&output.stderr)); + + let profile = String::from_utf8_lossy(&output.stdout); + assert!(profile.contains("[http]"), "expected [http] section: {profile}"); + assert!(profile.contains("/secure"), "expected /secure path in [http].allow: {profile}"); + assert!(profile.contains("[config]"), "expected [config] section: {profile}"); + assert!(profile.contains("http_inject_ca"), "expected http_inject_ca in [config]: {profile}"); + assert!(profile.contains(ca_bundle), "expected ca bundle path in [config]: {profile}"); +} diff --git a/docs/learn.md b/docs/learn.md index 9c3aedbb..70d7394e 100644 --- a/docs/learn.md +++ b/docs/learn.md @@ -18,6 +18,9 @@ sandlock learn [options] -- [args...] | `--collapse [N]` | off | Collapse directories where ≥N files were observed (default N=4) | | `--collapse-prefix ` | none | Force collapse of all paths under prefix (repeatable) | | `--force-sensitive-collapse` | off | Allow `--collapse-prefix` to target sensitive paths (requires `--collapse-prefix`) | +| `--learn-http` | off | Enable HTTP/HTTPS traffic observation| +| `--http-inject-ca ` | none | System CA bundle path; sandlock splices an ephemeral CA in at `open()` time so HTTPS is intercepted. Requires `--learn-http`. | +| `--http-port ` | none | Additional TCP port to intercept (repeatable). Requires `--learn-http`. | ## What is recorded @@ -27,6 +30,7 @@ sandlock learn [options] -- [args...] | Filesystem writes | Same; classified by open flags (`O_WRONLY`, `O_RDWR`, `O_CREAT`) | | Executed binaries and libraries | `/proc//exe` + r-xp mappings from `/proc//maps` | | Network connections (TCP/UDP) | seccomp-notify on `connect`/`sendto`/`sendmsg` | +| HTTP method + host + path | Transparent proxy in logging-only mode (opt-in, see below) | | Resource peaks | `/proc//status` sampling: RSS, thread count, fd count | ## Path collapsing @@ -62,6 +66,10 @@ to. The operator can use this to decide whether the grant is acceptable. `--force-sensitive-collapse` allows `--collapse-prefix` to target protected and guarded paths. A warning and diff are still printed. +## HTTP/HTTPS observation + +`--learn-http` activates the transparent proxy in logging-only mode. Method, host, and path of every request are recorded as `[http].allow` rules. Port 80 is always intercepted; port 443 requires `--http-inject-ca` (sandlock splices an ephemeral CA into the named bundle at `open()` time); non-standard ports use `--http-port`. The inject-ca path is written to `[config].http_inject_ca` so `sandlock run` picks it up automatically. `[http].deny` rules are not learned. + ## Tests Tests require Linux 5.6+ (seccomp notif) and Linux 5.13+ (Landlock). They run the real `sandlock` binary, so build first: From 79b8b0339c31e14073a33ca53fbe6f183d235ec1 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Wed, 12 Aug 2026 14:05:26 +0400 Subject: [PATCH 04/10] feat(learn): add --http-ca-out and --env to LearnArgs --- crates/sandlock-cli/src/learn.rs | 10 ++++++++++ crates/sandlock-cli/src/main.rs | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index c1f1db16..5da13027 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -562,6 +562,16 @@ pub async fn run(args: LearnArgs) -> Result<()> { for port in &args.http_port { builder = builder.http_port(*port); } + if let Some(ref out) = args.http_ca_out { + builder = builder.http_ca_out(out); + } + for spec in &args.env_vars { + if let Some((k, v)) = spec.split_once('=') { + builder = builder.env_var(k, v); + } else { + return Err(anyhow!("--env requires KEY=VALUE, got: {}", spec)); + } + } let policy = builder .build() diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index d7fb28e7..d8bbf378 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -246,6 +246,15 @@ struct LearnArgs { #[arg(long, value_name = "PORT")] http_port: Vec, + /// Write the ephemeral MITM CA public cert to this path so the workload can trust it + /// (e.g. NODE_EXTRA_CA_CERTS= or curl --cacert ). Requires --learn-http. + #[arg(long, value_name = "PATH")] + http_ca_out: Option, + + /// Set an environment variable for the observed process (repeatable, KEY=VALUE) + #[arg(long = "env", value_name = "KEY=VALUE")] + env_vars: Vec, + /// Command to observe (everything after --) #[arg(last = true, required = true)] cmd: Vec, From 33a53514110d98dfb423e0c38b40f4e0b693865c Mon Sep 17 00:00:00 2001 From: Vahagn Date: Sun, 16 Aug 2026 15:52:09 +0400 Subject: [PATCH 05/10] [remove] learn: drop --learn-http gate, HTTP observation is now always on --- crates/sandlock-cli/src/learn.rs | 16 ++++++---------- crates/sandlock-cli/src/main.rs | 10 +++------- crates/sandlock-cli/tests/learn_integration.rs | 3 +-- crates/sandlock-cli/tests/learn_test.rs | 5 ++--- docs/learn.md | 9 ++++----- 5 files changed, 16 insertions(+), 27 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 5da13027..5d2bcedb 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -527,8 +527,6 @@ pub async fn run(args: LearnArgs) -> Result<()> { let observer = LearnObserver::new(); let observer_cb = observer.clone(); - let want_http = args.learn_http; - let mut builder = Sandbox::builder() // Name + mode mark this as a learning sandbox in `sandlock ps`: the // observation policy below (read "/", allow-all network) would @@ -548,14 +546,12 @@ pub async fn run(args: LearnArgs) -> Result<()> { .max_memory(sandlock_core::sandbox::ByteSize(1 << 43)) // 8 TiB .policy_fn(move |event, _ctx| observer_cb.on_event(event)); - if want_http { - let http_requests_cb = Arc::clone(&observer.http_requests); - let http_log: Arc = - Arc::new(move |method, host, path| { - http_requests_cb.lock().unwrap().insert(format!("{method} {host}{path}")); - }); - builder = builder.http_log_fn(http_log); - } + let http_requests_cb = Arc::clone(&observer.http_requests); + let http_log: Arc = + Arc::new(move |method, host, path| { + http_requests_cb.lock().unwrap().insert(format!("{method} {host}{path}")); + }); + builder = builder.http_log_fn(http_log); for path in &args.http_inject_ca { builder = builder.http_inject_ca(path); } diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index d8bbf378..0febb17b 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -233,21 +233,17 @@ struct LearnArgs { #[arg(long, requires = "collapse_prefix")] force_sensitive_collapse: bool, - /// Record HTTP method + host + path in [http].allow - #[arg(long)] - learn_http: bool, - - /// Inject an ephemeral MITM CA into this trust bundle to observe HTTPS (implies --learn-http). + /// Inject an ephemeral MITM CA into this trust bundle to observe HTTPS. /// Enables port 443 interception. #[arg(long, value_name = "PATH")] http_inject_ca: Vec, - /// Additional TCP port to intercept for HTTP/HTTPS learning (implies --learn-http) + /// Additional TCP port to intercept for HTTP/HTTPS learning (repeatable) #[arg(long, value_name = "PORT")] http_port: Vec, /// Write the ephemeral MITM CA public cert to this path so the workload can trust it - /// (e.g. NODE_EXTRA_CA_CERTS= or curl --cacert ). Requires --learn-http. + /// (e.g. NODE_EXTRA_CA_CERTS= or curl --cacert ) #[arg(long, value_name = "PATH")] http_ca_out: Option, diff --git a/crates/sandlock-cli/tests/learn_integration.rs b/crates/sandlock-cli/tests/learn_integration.rs index 58aafec9..1fef0473 100644 --- a/crates/sandlock-cli/tests/learn_integration.rs +++ b/crates/sandlock-cli/tests/learn_integration.rs @@ -475,7 +475,7 @@ fn test_learn_then_run_http() { // Learn only GET /data. let learn = sandlock_bin() - .args(["learn", "-o", &profile_path, "--learn-http", "--http-port", &port_str, + .args(["learn", "-o", &profile_path, "--http-port", &port_str, "--", "curl", "-sf", &url_learned]) .output() .expect("failed to run sandlock learn"); @@ -541,7 +541,6 @@ fn test_learn_then_run_https() { let learn = sandlock_bin() .args([ "learn", "-o", &profile_path, - "--learn-http", "--http-inject-ca", ca_bundle, "--http-port", &port_str, "--", "curl", "-sf", &url_learned, diff --git a/crates/sandlock-cli/tests/learn_test.rs b/crates/sandlock-cli/tests/learn_test.rs index f6ddf3c4..d1a43104 100644 --- a/crates/sandlock-cli/tests/learn_test.rs +++ b/crates/sandlock-cli/tests/learn_test.rs @@ -844,7 +844,7 @@ fn test_learn_captures_http_request() { let url = format!("http://127.0.0.1:{port}/hello"); let output = sandlock_bin() - .args(["learn", "--learn-http", "--http-port", &port.to_string(), "--", "curl", "-sf", &url]) + .args(["learn", "--http-port", &port.to_string(), "--", "curl", "-sf", &url]) .output() .expect("failed to run sandlock learn"); assert!(output.status.success(), @@ -869,7 +869,7 @@ fn test_learn_captures_http_multiple_paths() { let cmd = format!("curl -sf {url_a} && curl -sf {url_b} && curl -sf {url_a}"); let output = sandlock_bin() - .args(["learn", "--learn-http", "--http-port", &port.to_string(), "--", "sh", "-c", &cmd]) + .args(["learn", "--http-port", &port.to_string(), "--", "sh", "-c", &cmd]) .output() .expect("failed to run sandlock learn"); assert!(output.status.success(), @@ -912,7 +912,6 @@ fn test_learn_captures_https_request() { let output = sandlock_bin() .args([ "learn", - "--learn-http", "--http-inject-ca", ca_bundle, "--http-port", &port.to_string(), "--", "curl", "-sf", &url, diff --git a/docs/learn.md b/docs/learn.md index 70d7394e..2189cfff 100644 --- a/docs/learn.md +++ b/docs/learn.md @@ -18,9 +18,8 @@ sandlock learn [options] -- [args...] | `--collapse [N]` | off | Collapse directories where ≥N files were observed (default N=4) | | `--collapse-prefix ` | none | Force collapse of all paths under prefix (repeatable) | | `--force-sensitive-collapse` | off | Allow `--collapse-prefix` to target sensitive paths (requires `--collapse-prefix`) | -| `--learn-http` | off | Enable HTTP/HTTPS traffic observation| -| `--http-inject-ca ` | none | System CA bundle path; sandlock splices an ephemeral CA in at `open()` time so HTTPS is intercepted. Requires `--learn-http`. | -| `--http-port ` | none | Additional TCP port to intercept (repeatable). Requires `--learn-http`. | +| `--http-inject-ca ` | none | System CA bundle path; sandlock splices an ephemeral CA in at `open()` time so HTTPS is intercepted. | +| `--http-port ` | none | Additional TCP port to intercept (repeatable). | ## What is recorded @@ -30,7 +29,7 @@ sandlock learn [options] -- [args...] | Filesystem writes | Same; classified by open flags (`O_WRONLY`, `O_RDWR`, `O_CREAT`) | | Executed binaries and libraries | `/proc//exe` + r-xp mappings from `/proc//maps` | | Network connections (TCP/UDP) | seccomp-notify on `connect`/`sendto`/`sendmsg` | -| HTTP method + host + path | Transparent proxy in logging-only mode (opt-in, see below) | +| HTTP method + host + path | Transparent proxy in logging-only mode (always on, see below) | | Resource peaks | `/proc//status` sampling: RSS, thread count, fd count | ## Path collapsing @@ -68,7 +67,7 @@ and guarded paths. A warning and diff are still printed. ## HTTP/HTTPS observation -`--learn-http` activates the transparent proxy in logging-only mode. Method, host, and path of every request are recorded as `[http].allow` rules. Port 80 is always intercepted; port 443 requires `--http-inject-ca` (sandlock splices an ephemeral CA into the named bundle at `open()` time); non-standard ports use `--http-port`. The inject-ca path is written to `[config].http_inject_ca` so `sandlock run` picks it up automatically. `[http].deny` rules are not learned. +The transparent proxy always runs in logging-only mode during `sandlock learn`. Method, host, and path of every request are recorded as `[http].allow` rules. Port 80 is always intercepted; port 443 requires `--http-inject-ca` (sandlock splices an ephemeral CA into the named bundle at `open()` time); non-standard ports use `--http-port`. The inject-ca path is written to `[config].http_inject_ca` so `sandlock run` picks it up automatically. `[http].deny` rules are not learned. ## Tests From 798012fdf8c3bff90994445c635f860d313738b2 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Sun, 16 Aug 2026 16:00:50 +0400 Subject: [PATCH 06/10] [fix] learn: merge now carries http.allow, http.ports, and http_inject_ca --- crates/sandlock-cli/src/learn.rs | 16 +++++ .../sandlock-cli/tests/learn_integration.rs | 58 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 5d2bcedb..f14f14d3 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -755,6 +755,22 @@ pub async fn run(args: LearnArgs) -> Result<()> { profile_out.limits.memory = max_bytesize(existing.limits.memory.as_deref(), observed.limits.memory.as_deref()); profile_out.limits.processes = max_opt(existing.limits.processes, observed.limits.processes); profile_out.limits.open_files = max_opt(existing.limits.open_files, observed.limits.open_files); + + // Union http.allow, http.ports, and config.http_inject_ca. + let mut http_allow_set: std::collections::BTreeSet = + observed.http.allow.iter().cloned().collect(); + http_allow_set.extend(existing.http.allow.iter().cloned()); + profile_out.http.allow = http_allow_set.into_iter().collect(); + + let mut http_ports_set: std::collections::BTreeSet = + observed.http.ports.iter().cloned().collect(); + http_ports_set.extend(existing.http.ports.iter().cloned()); + profile_out.http.ports = http_ports_set.into_iter().collect(); + + let mut ca_set: std::collections::BTreeSet = + observed.config.http_inject_ca.iter().cloned().collect(); + ca_set.extend(existing.config.http_inject_ca.iter().cloned()); + profile_out.config.http_inject_ca = ca_set.into_iter().collect(); } let kernel = std::fs::read_to_string("/proc/version") diff --git a/crates/sandlock-cli/tests/learn_integration.rs b/crates/sandlock-cli/tests/learn_integration.rs index 1fef0473..4cb5e8e7 100644 --- a/crates/sandlock-cli/tests/learn_integration.rs +++ b/crates/sandlock-cli/tests/learn_integration.rs @@ -574,3 +574,61 @@ fn test_learn_then_run_https() { assert!(!run_deny.status.success(), "run should have been blocked for non-learned path /other"); } + +/// --merge carries [http].allow and [http].ports from the observed run into the +/// existing profile. +#[test] +fn test_learn_then_run_merge_http() { + let profile = tempfile::NamedTempFile::new().expect("tempfile"); + let profile_path = profile.path().to_str().unwrap().to_owned(); + let port = spawn_http_server(); + let url_a = format!("http://127.0.0.1:{port}/path_a"); + let url_b = format!("http://127.0.0.1:{port}/path_b"); + let url_other = format!("http://127.0.0.1:{port}/other"); + let port_str = port.to_string(); + + // Run 1: learn /path_a. + let learn1 = sandlock_bin() + .args(["learn", "-o", &profile_path, "--http-port", &port_str, + "--", "curl", "-sf", &url_a]) + .output() + .expect("failed to run sandlock learn (run 1)"); + assert!(learn1.status.success(), + "learn run 1 failed: {}", String::from_utf8_lossy(&learn1.stderr)); + + // Run 2: merge, learn /path_b. + let learn2 = sandlock_bin() + .args(["learn", "--merge", &profile_path, "--http-port", &port_str, + "--", "curl", "-sf", &url_b]) + .output() + .expect("failed to run sandlock learn --merge (run 2)"); + assert!(learn2.status.success(), + "learn run 2 (merge) failed: {}", String::from_utf8_lossy(&learn2.stderr)); + + let merged = std::fs::read_to_string(&profile_path).expect("read merged profile"); + assert!(merged.contains("/path_a"), "merged profile must contain /path_a: {merged}"); + assert!(merged.contains("/path_b"), "merged profile must contain /path_b: {merged}"); + + // Both learned paths are allowed. + let run_a = sandlock_bin() + .args(["run", "--profile-file", &profile_path, "--", "curl", "-s", &url_a]) + .output() + .expect("failed to run sandlock run (path_a)"); + assert!(run_a.status.success(), + "run failed for /path_a: {}", String::from_utf8_lossy(&run_a.stderr)); + + let run_b = sandlock_bin() + .args(["run", "--profile-file", &profile_path, "--", "curl", "-s", &url_b]) + .output() + .expect("failed to run sandlock run (path_b)"); + assert!(run_b.status.success(), + "run failed for /path_b: {}", String::from_utf8_lossy(&run_b.stderr)); + + // Unlearned path is blocked. + let run_deny = sandlock_bin() + .args(["run", "--profile-file", &profile_path, "--", "curl", "-sf", &url_other]) + .output() + .expect("failed to run sandlock run (deny)"); + assert!(!run_deny.status.success(), + "run should have been blocked for non-learned path /other"); +} From 2772bf4978ca8ed15a6d3adb81f1c2c7d5b70d68 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Sun, 16 Aug 2026 16:21:45 +0400 Subject: [PATCH 07/10] [fix] proxy: log_fn fires after verify_host, not before --- crates/sandlock-core/src/transparent_proxy/service.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/sandlock-core/src/transparent_proxy/service.rs b/crates/sandlock-core/src/transparent_proxy/service.rs index 5564998f..61fff71b 100644 --- a/crates/sandlock-core/src/transparent_proxy/service.rs +++ b/crates/sandlock-core/src/transparent_proxy/service.rs @@ -182,10 +182,6 @@ impl AclService { let host = authority.host().to_string(); let path = req.uri().path().to_string(); - if let Some(ref f) = self.log_fn { - f(&method, &host, &path); - } - if !self.verify_host(&client_addr, &host).await { if let Ok(mut m) = self.orig_dest.write() { m.remove(&client_addr); @@ -199,6 +195,10 @@ impl AclService { m.remove(&client_addr); } + if let Some(ref f) = self.log_fn { + f(&method, &host, &path); + } + if !http_acl_check(&self.allow, &self.deny, &method, &host, &path) { return text_response(StatusCode::FORBIDDEN, "Blocked by sandlock HTTP ACL policy"); } From 38aee48412d4e1409c4101416d4b1f9ed1d59884 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Sun, 16 Aug 2026 17:06:17 +0400 Subject: [PATCH 08/10] [fix] learn: read http_ports from built policy instead of recomputing --- crates/sandlock-cli/src/learn.rs | 8 +------- crates/sandlock-cli/tests/learn_test.rs | 18 ++++++++++++++++++ docs/learn.md | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index f14f14d3..6e1a7a3f 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -677,13 +677,7 @@ pub async fn run(args: LearnArgs) -> Result<()> { let http_reqs: Vec = observer.http_requests.lock().unwrap().iter().cloned().collect(); if !http_reqs.is_empty() { - // Ports: always include 80; add 443 when --http-inject-ca was given; add any --http-port extras. - let mut ports = vec![80u16]; - if !args.http_inject_ca.is_empty() { ports.push(443); } - for &p in &args.http_port { - if !ports.contains(&p) { ports.push(p); } - } - profile_out.http.ports = ports; + profile_out.http.ports = sandbox.http_ports.clone(); profile_out.http.allow = http_reqs; profile_out.config.http_inject_ca = args.http_inject_ca.clone(); } diff --git a/crates/sandlock-cli/tests/learn_test.rs b/crates/sandlock-cli/tests/learn_test.rs index d1a43104..aafa8c49 100644 --- a/crates/sandlock-cli/tests/learn_test.rs +++ b/crates/sandlock-cli/tests/learn_test.rs @@ -858,6 +858,24 @@ fn test_learn_captures_http_request() { assert!(profile.contains("/hello"), "expected /hello path in [http].allow: {profile}"); } +/// --http-port writes only the intercepted port to [http].ports, not port 80. +#[test] +fn test_learn_http_port_not_inflated() { + let port = spawn_http_server(); + let url = format!("http://127.0.0.1:{port}/hello"); + + let output = sandlock_bin() + .args(["learn", "--http-port", &port.to_string(), "--", "curl", "-sf", &url]) + .output() + .expect("failed to run sandlock learn"); + assert!(output.status.success(), + "learn failed: {}", String::from_utf8_lossy(&output.stderr)); + + let profile = String::from_utf8_lossy(&output.stdout); + assert!(profile.contains(&format!("ports = [{}]", port)), + "expected only port {port} in [http].ports, got: {profile}"); +} + /// Multiple distinct requests to different paths are all recorded and deduplicated. #[test] fn test_learn_captures_http_multiple_paths() { diff --git a/docs/learn.md b/docs/learn.md index 2189cfff..7c3be210 100644 --- a/docs/learn.md +++ b/docs/learn.md @@ -67,7 +67,7 @@ and guarded paths. A warning and diff are still printed. ## HTTP/HTTPS observation -The transparent proxy always runs in logging-only mode during `sandlock learn`. Method, host, and path of every request are recorded as `[http].allow` rules. Port 80 is always intercepted; port 443 requires `--http-inject-ca` (sandlock splices an ephemeral CA into the named bundle at `open()` time); non-standard ports use `--http-port`. The inject-ca path is written to `[config].http_inject_ca` so `sandlock run` picks it up automatically. `[http].deny` rules are not learned. +The transparent proxy always runs in logging-only mode during `sandlock learn`. Method, host, and path of every request are recorded as `[http].allow` rules. By default port 80 is intercepted; port 443 requires `--http-inject-ca` (sandlock splices an ephemeral CA into the named bundle at `open()` time); passing `--http-port` overrides the default and intercepts only the specified ports. The inject-ca path is written to `[config].http_inject_ca` so `sandlock run` picks it up automatically. `[http].deny` rules are not learned. ## Tests From ec65195946cf3ba22f8ef254f91af364258ed053 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Sun, 16 Aug 2026 22:48:56 +0400 Subject: [PATCH 09/10] [feat] learn: write --env and --http-ca-out to profile; fix merge for env and http_ca_out; update docs --- crates/sandlock-cli/src/learn.rs | 16 ++++++++++++++++ crates/sandlock-cli/src/main.rs | 3 ++- crates/sandlock-cli/tests/learn_test.rs | 7 ++----- docs/learn.md | 2 ++ 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 6e1a7a3f..40dfcc07 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -624,6 +624,11 @@ pub async fn run(args: LearnArgs) -> Result<()> { let first_exe = observer.first_exe.lock().unwrap().clone(); profile_out.program.exec = first_exe.or_else(|| Some(PathBuf::from(&args.cmd[0]))); profile_out.program.args = args.cmd[1..].to_vec(); + for spec in &args.env_vars { + if let Some((k, v)) = spec.split_once('=') { + profile_out.program.env.insert(k.to_string(), v.to_string()); + } + } let reads_raw: Vec = observer.reads.lock().unwrap().iter() .filter(|p| p.exists() && !is_junk_path(p)) @@ -680,6 +685,7 @@ pub async fn run(args: LearnArgs) -> Result<()> { profile_out.http.ports = sandbox.http_ports.clone(); profile_out.http.allow = http_reqs; profile_out.config.http_inject_ca = args.http_inject_ca.clone(); + profile_out.config.http_ca_out = args.http_ca_out.clone(); } // Fill limits with observed peaks + headroom so the profile is usable with sandlock run. @@ -765,6 +771,16 @@ pub async fn run(args: LearnArgs) -> Result<()> { observed.config.http_inject_ca.iter().cloned().collect(); ca_set.extend(existing.config.http_inject_ca.iter().cloned()); profile_out.config.http_inject_ca = ca_set.into_iter().collect(); + + // http_ca_out: take from observed when existing has none. + if profile_out.config.http_ca_out.is_none() { + profile_out.config.http_ca_out = observed.config.http_ca_out; + } + + // env: union observed vars into existing, observed wins on conflict. + for (k, v) in observed.program.env { + profile_out.program.env.insert(k, v); + } } let kernel = std::fs::read_to_string("/proc/version") diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 0febb17b..96309ecd 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -247,7 +247,8 @@ struct LearnArgs { #[arg(long, value_name = "PATH")] http_ca_out: Option, - /// Set an environment variable for the observed process (repeatable, KEY=VALUE) + /// Set an environment variable for the observed process and record it in the + /// profile so sandlock run inherits it (repeatable, KEY=VALUE). #[arg(long = "env", value_name = "KEY=VALUE")] env_vars: Vec, diff --git a/crates/sandlock-cli/tests/learn_test.rs b/crates/sandlock-cli/tests/learn_test.rs index aafa8c49..2634a548 100644 --- a/crates/sandlock-cli/tests/learn_test.rs +++ b/crates/sandlock-cli/tests/learn_test.rs @@ -815,8 +815,8 @@ fn test_learn_symlink_path_canonicalized() { // ── HTTP learning ───────────────────────────────────────────────────────────── -/// Spawn a minimal HTTP server on an ephemeral port, serve one response, -/// and return the port. The server accepts one connection then exits. +/// Spawn a minimal HTTP server on an ephemeral port and return the port. +/// The server loops over incoming connections, returning a 200 OK for each. fn spawn_http_server() -> u16 { use std::io::Write; use std::net::TcpListener; @@ -839,7 +839,6 @@ fn spawn_http_server() -> u16 { /// A plaintext HTTP request is captured and written as an [http] allow rule. #[test] fn test_learn_captures_http_request() { - use std::io::Read; let port = spawn_http_server(); let url = format!("http://127.0.0.1:{port}/hello"); @@ -879,7 +878,6 @@ fn test_learn_http_port_not_inflated() { /// Multiple distinct requests to different paths are all recorded and deduplicated. #[test] fn test_learn_captures_http_multiple_paths() { - use std::io::Read; let port = spawn_http_server(); let url_a = format!("http://127.0.0.1:{port}/a"); let url_b = format!("http://127.0.0.1:{port}/b"); @@ -919,7 +917,6 @@ fn system_ca_bundle() -> Option<&'static str> { /// The [config] section records the inject-ca path so sandlock run can replay MITM. #[test] fn test_learn_captures_https_request() { - use std::io::Read; let Some(ca_bundle) = system_ca_bundle() else { eprintln!("skipping test_learn_captures_https_request: no system CA bundle found"); return; diff --git a/docs/learn.md b/docs/learn.md index 7c3be210..6c72d49e 100644 --- a/docs/learn.md +++ b/docs/learn.md @@ -19,7 +19,9 @@ sandlock learn [options] -- [args...] | `--collapse-prefix ` | none | Force collapse of all paths under prefix (repeatable) | | `--force-sensitive-collapse` | off | Allow `--collapse-prefix` to target sensitive paths (requires `--collapse-prefix`) | | `--http-inject-ca ` | none | System CA bundle path; sandlock splices an ephemeral CA in at `open()` time so HTTPS is intercepted. | +| `--http-ca-out ` | none | Write the ephemeral MITM CA public cert to a file and record the path in the profile. Runtimes with a compiled-in CA store (e.g. Node.js) ignore the system bundle patched by `--http-inject-ca`; pair with `--env NODE_EXTRA_CA_CERTS=` to point the runtime at the cert. | | `--http-port ` | none | Additional TCP port to intercept (repeatable). | +| `--env KEY=VALUE` | none | Set an environment variable for the observed process (repeatable). | ## What is recorded From 0dae1d3d0bec07811af92f84c9f5b2f856c449e6 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Mon, 17 Aug 2026 11:06:40 +0400 Subject: [PATCH 10/10] [fix] ca_inject: follow symlinks when matching bundle paths; rewrite HTTPS learn tests --- .../sandlock-cli/tests/learn_integration.rs | 39 +++++++++++-------- crates/sandlock-cli/tests/learn_test.rs | 26 ++++++++++--- crates/sandlock-core/src/ca_inject.rs | 12 +++++- 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/crates/sandlock-cli/tests/learn_integration.rs b/crates/sandlock-cli/tests/learn_integration.rs index 4cb5e8e7..46d1d358 100644 --- a/crates/sandlock-cli/tests/learn_integration.rs +++ b/crates/sandlock-cli/tests/learn_integration.rs @@ -520,12 +520,25 @@ fn system_ca_bundle() -> Option<&'static str> { None } -/// HTTPS learn → run round-trip: --http-inject-ca is written to [config] and -/// picked up automatically by sandlock run without any extra flags. +/// Returns true if host:443 accepts a TCP connection within 3 seconds. +fn https_reachable(host: &str) -> bool { + use std::net::ToSocketAddrs; + let Ok(mut it) = format!("{host}:443").to_socket_addrs() else { return false }; + let Some(addr) = it.next() else { return false }; + std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(3)).is_ok() +} + +/// HTTPS learn -> run round-trip against a real HTTPS server (example.com). +/// Exercises TLS termination, MITM cert signing, CA injection, and ACL enforcement. /// - the learned HTTPS path is allowed /// - an unlearned path is blocked (403 from proxy) +/// Skipped if example.com is unreachable or no system CA bundle is found. #[test] fn test_learn_then_run_https() { + if !https_reachable("example.com") { + eprintln!("skipping test_learn_then_run_https: example.com unreachable"); + return; + } let Some(ca_bundle) = system_ca_bundle() else { eprintln!("skipping test_learn_then_run_https: no system CA bundle found"); return; @@ -533,17 +546,12 @@ fn test_learn_then_run_https() { let profile = tempfile::NamedTempFile::new().expect("tempfile"); let profile_path = profile.path().to_str().unwrap().to_owned(); - let port = spawn_http_server(); - let url_learned = format!("http://127.0.0.1:{port}/data"); - let url_other = format!("http://127.0.0.1:{port}/other"); - let port_str = port.to_string(); let learn = sandlock_bin() .args([ "learn", "-o", &profile_path, "--http-inject-ca", ca_bundle, - "--http-port", &port_str, - "--", "curl", "-sf", &url_learned, + "--", "curl", "-sf", "https://example.com/", ]) .output() .expect("failed to run sandlock learn"); @@ -553,26 +561,25 @@ fn test_learn_then_run_https() { let profile_content = std::fs::read_to_string(&profile_path).expect("read profile"); assert!(profile_content.contains("http_inject_ca"), "profile must record http_inject_ca in [config]: {profile_content}"); - assert!(profile_content.contains("/data"), - "profile must record /data in [http].allow: {profile_content}"); + assert!(profile_content.contains("example.com"), + "profile must record example.com in [http].allow: {profile_content}"); // Allowed: sandlock run picks up http_inject_ca from [config], no extra flags. let run_allow = sandlock_bin() - .args(["run", "--profile-file", &profile_path, "--", "curl", "-s", &url_learned]) + .args(["run", "--profile-file", &profile_path, "--", "curl", "-sf", "https://example.com/"]) .output() .expect("failed to run sandlock run (allow)"); assert!(run_allow.status.success(), "run failed for learned path: {}", String::from_utf8_lossy(&run_allow.stderr)); - assert_eq!(String::from_utf8_lossy(&run_allow.stdout).trim(), "ok", - "expected 'ok' from test server for learned path"); - // Blocked: unlearned path returns 403. + // Blocked: unlearned path returns 403 from proxy (curl -f exits non-zero on 4xx). let run_deny = sandlock_bin() - .args(["run", "--profile-file", &profile_path, "--", "curl", "-sf", &url_other]) + .args(["run", "--profile-file", &profile_path, "--", + "curl", "-sf", "https://example.com/not-learned-path"]) .output() .expect("failed to run sandlock run (deny)"); assert!(!run_deny.status.success(), - "run should have been blocked for non-learned path /other"); + "run should have been blocked for non-learned path /not-learned-path"); } /// --merge carries [http].allow and [http].ports from the observed run into the diff --git a/crates/sandlock-cli/tests/learn_test.rs b/crates/sandlock-cli/tests/learn_test.rs index 2634a548..af92abcd 100644 --- a/crates/sandlock-cli/tests/learn_test.rs +++ b/crates/sandlock-cli/tests/learn_test.rs @@ -16,6 +16,7 @@ fn sandlock_bin() -> Command { /// all). `-r` maps to a mandatory `fs_read`, so requiring `/lib64` on such a /// host aborts confinement; this mirrors `fs_read_if_exists` at the CLI layer. /// On hosts that have `/lib64` (x86-64) the arguments pass through unchanged. +#[allow(dead_code)] fn args_for_host(args: &[&str]) -> Vec { let has_lib64 = std::path::Path::new("/lib64").exists(); let mut out: Vec = Vec::with_capacity(args.len()); @@ -913,23 +914,34 @@ fn system_ca_bundle() -> Option<&'static str> { None } +/// Returns true if host:443 accepts a TCP connection within 3 seconds. +fn https_reachable(host: &str) -> bool { + use std::net::ToSocketAddrs; + let Ok(mut it) = format!("{host}:443").to_socket_addrs() else { return false }; + let Some(addr) = it.next() else { return false }; + std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(3)).is_ok() +} + /// HTTPS traffic via --http-inject-ca is captured and written as an [http] allow rule. -/// The [config] section records the inject-ca path so sandlock run can replay MITM. +/// Uses a real HTTPS server (example.com) so that TLS termination, MITM cert signing, +/// CA injection, and proxy forwarding are all exercised end to end. +/// Skipped if example.com is unreachable or no system CA bundle is found. #[test] fn test_learn_captures_https_request() { + if !https_reachable("example.com") { + eprintln!("skipping test_learn_captures_https_request: example.com unreachable"); + return; + } let Some(ca_bundle) = system_ca_bundle() else { eprintln!("skipping test_learn_captures_https_request: no system CA bundle found"); return; }; - let port = spawn_http_server(); - let url = format!("http://127.0.0.1:{port}/secure"); let output = sandlock_bin() .args([ "learn", "--http-inject-ca", ca_bundle, - "--http-port", &port.to_string(), - "--", "curl", "-sf", &url, + "--", "curl", "-sf", "https://example.com/", ]) .output() .expect("failed to run sandlock learn"); @@ -938,8 +950,10 @@ fn test_learn_captures_https_request() { let profile = String::from_utf8_lossy(&output.stdout); assert!(profile.contains("[http]"), "expected [http] section: {profile}"); - assert!(profile.contains("/secure"), "expected /secure path in [http].allow: {profile}"); + assert!(profile.contains("example.com"), "expected example.com in [http].allow: {profile}"); assert!(profile.contains("[config]"), "expected [config] section: {profile}"); assert!(profile.contains("http_inject_ca"), "expected http_inject_ca in [config]: {profile}"); assert!(profile.contains(ca_bundle), "expected ca bundle path in [config]: {profile}"); } + + diff --git a/crates/sandlock-core/src/ca_inject.rs b/crates/sandlock-core/src/ca_inject.rs index b6433f83..3fa68c16 100644 --- a/crates/sandlock-core/src/ca_inject.rs +++ b/crates/sandlock-core/src/ca_inject.rs @@ -22,9 +22,17 @@ pub(crate) fn combine_bundle(original: &[u8], ca_pem: &[u8]) -> Vec { out } -/// True if `resolved` exactly matches one of the user-declared inject paths. +/// True if `resolved` matches one of the user-declared inject paths. +/// Follows symlinks on both sides so a symlink path matches a canonical inject path and vice versa. pub(crate) fn path_matches(resolved: &Path, inject_paths: &[PathBuf]) -> bool { - inject_paths.iter().any(|p| p == resolved) + if inject_paths.iter().any(|p| p == resolved) { + return true; + } + // Try canonicalized form to handle symlinks on either side. + let canonical = std::fs::canonicalize(resolved).ok(); + canonical.map_or(false, |c| inject_paths.iter().any(|p| { + p == &c || std::fs::canonicalize(p).ok().map_or(false, |cp| cp == c) + })) } /// Intercept an open-family syscall targeting a declared trust bundle and