Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions crates/sandlock-cli/src/learn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mutex<HashMap<(u32, i64), String>>>,
/// HTTP requests observed via the transparent proxy (method + host + path).
/// Format: "METHOD host/path" matching HttpRule::parse input.
http_requests: Arc<Mutex<BTreeSet<String>>>,
}

impl LearnObserver {
Expand All @@ -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())),
}
}

Expand Down Expand Up @@ -522,7 +526,8 @@ 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 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
Expand All @@ -539,7 +544,32 @@ 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));

let http_requests_cb = Arc::clone(&observer.http_requests);
let http_log: Arc<dyn Fn(&str, &str, &str) + Send + Sync> =
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);
}
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()
.map_err(|e| anyhow!("failed to build sandbox policy: {e}"))?;

Expand Down Expand Up @@ -594,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<PathBuf> = observer.reads.lock().unwrap().iter()
.filter(|p| p.exists() && !is_junk_path(p))
Expand Down Expand Up @@ -645,6 +680,14 @@ pub async fn run(args: LearnArgs) -> Result<()> {
.map(|&p| sandlock_core::profile::PortSpec::Port(p))
.collect();

let http_reqs: Vec<String> = observer.http_requests.lock().unwrap().iter().cloned().collect();
if !http_reqs.is_empty() {
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.
// 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.
Expand Down Expand Up @@ -712,6 +755,32 @@ 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<String> =
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<u16> =
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<std::path::PathBuf> =
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")
Expand Down
19 changes: 19 additions & 0 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,25 @@ struct LearnArgs {
#[arg(long, requires = "collapse_prefix")]
force_sensitive_collapse: bool,

/// 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<PathBuf>,

/// Additional TCP port to intercept for HTTP/HTTPS learning (repeatable)
#[arg(long, value_name = "PORT")]
http_port: Vec<u16>,

/// Write the ephemeral MITM CA public cert to this path so the workload can trust it
/// (e.g. NODE_EXTRA_CA_CERTS=<path> or curl --cacert <path>)
#[arg(long, value_name = "PATH")]
http_ca_out: Option<PathBuf>,

/// 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<String>,

/// Command to observe (everything after --)
#[arg(last = true, required = true)]
cmd: Vec<String>,
Expand Down
199 changes: 199 additions & 0 deletions crates/sandlock-cli/tests/learn_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,3 +440,202 @@ 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, "--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
}

/// 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;
};

let profile = tempfile::NamedTempFile::new().expect("tempfile");
let profile_path = profile.path().to_str().unwrap().to_owned();

let learn = sandlock_bin()
.args([
"learn", "-o", &profile_path,
"--http-inject-ca", ca_bundle,
"--", "curl", "-sf", "https://example.com/",
])
.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("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", "-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));

// 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", "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 /not-learned-path");
}

/// --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");
}
Loading
Loading