diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index 9772cb1..a9fce1f 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -21,6 +21,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" jiff = "0.2" libc = "0.2" +tempfile = "3" [dev-dependencies] -tempfile = "3" diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs new file mode 100644 index 0000000..9b78a7c --- /dev/null +++ b/crates/sandlock-cli/src/learn.rs @@ -0,0 +1,260 @@ +//! Implementation of `sandlock learn -o `. +//! +//! Runs a workload under observation and emits a sandlock profile TOML +//! usable by `sandlock run -p`. + +use std::collections::{BTreeSet, HashSet}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{anyhow, Result}; +use sandlock_core::policy_fn::{SyscallEvent, Verdict}; +use sandlock_core::profile::{FilesystemSection, ProfileInput}; +use sandlock_core::Sandbox; + +use crate::LearnArgs; + +// openat flags (from fcntl.h) +const O_WRONLY: u64 = 0o1; +const O_RDWR: u64 = 0o2; +const O_CREAT: u64 = 0o100; + +fn is_write_open(flags: u64) -> bool { + flags & (O_WRONLY | O_RDWR | O_CREAT) != 0 +} + +/// Read the dynamic linker path from `/proc//maps`. The kernel loads it +/// during execve (bypassing seccomp), so this is the way to discover it +/// after the execve completes and `/proc//maps` reflects the new binary. +fn read_linker_from_maps(pid: u32) -> Option { + use std::io::BufRead; + let file = std::fs::File::open(format!("/proc/{pid}/maps")).ok()?; + for line in std::io::BufReader::new(file).lines() { + let line = line.ok()?; + // Format: "addr-addr perms offset dev inode pathname" + let pathname = line.splitn(6, ' ').nth(5).map(str::trim).unwrap_or(""); + if !pathname.is_empty() && !pathname.starts_with('[') { + let p = std::path::Path::new(pathname); + if p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("ld-")) + .unwrap_or(false) + { + return Some(p.to_path_buf()); + } + } + } + None +} + +/// Accumulated observations from the policy_fn callback during learn. +#[derive(Clone)] +struct LearnObserver { + reads: Arc>>, + writes: Arc>>, + connects: Arc>>, + /// PIDs that just completed an execve — on the NEXT event from that PID, + /// /proc//maps will reflect the new binary's dynamic linker. + pending_maps: Arc>>, +} + +impl LearnObserver { + fn new() -> Self { + Self { + reads: Arc::new(Mutex::new(BTreeSet::new())), + writes: Arc::new(Mutex::new(BTreeSet::new())), + connects: Arc::new(Mutex::new(BTreeSet::new())), + pending_maps: Arc::new(Mutex::new(HashSet::new())), + } + } + + /// The policy_fn callback: classifies each intercepted syscall into + /// reads, writes, or connects for profile generation. + fn on_event(&self, event: SyscallEvent) -> Verdict { + // After an execve, the NEXT event from that PID fires once the + // new binary is running — /proc//maps now shows the dynamic + // linker loaded by the kernel (which bypassed seccomp). + if self.pending_maps.lock().unwrap().remove(&event.pid) { + if let Some(linker) = read_linker_from_maps(event.pid) { + self.reads.lock().unwrap().insert(linker); + } + } + + match event.syscall.as_str() { + "openat" | "open" => { + if let Some(path) = event.path { + if let Some(fl) = event.flags { + if is_write_open(fl) { + self.writes.lock().unwrap().insert(path); + } else { + self.reads.lock().unwrap().insert(path); + } + } + } + } + "execve" | "execveat" => { + if let Some(path) = event.path { + self.reads.lock().unwrap().insert(path); + } + // Mark PID: read maps on next event after execve completes. + self.pending_maps.lock().unwrap().insert(event.pid); + } + "connect" | "sendto" => { + if let (Some(ip), Some(port)) = (event.host, event.port) { + self.connects.lock().unwrap().insert(format!("tcp://{ip}:{port}")); + } + } + _ => {} + } + Verdict::Allow + } +} + + +pub async fn run(args: LearnArgs) -> Result<()> { + if args.cmd.is_empty() { + anyhow::bail!("no command given — use: sandlock learn [flags] -- [args...]"); + } + + let cmd_str = args.cmd.join(" "); + let cmd_refs: Vec<&str> = args.cmd.iter().map(String::as_str).collect(); + + // Fully permissive Landlock so nothing is blocked during observation. + // workdir (COW overlay) lets writes go anywhere without touching the real filesystem. + let cow_dir = tempfile::Builder::new() + .prefix("sandlock-learn-") + .tempdir_in("/var/tmp") + .map_err(|e| anyhow!("failed to create COW tempdir: {e}"))?; + + let observer = LearnObserver::new(); + let observer_cb = observer.clone(); + let policy = Sandbox::builder() + .fs_read("/") + .workdir(cow_dir.path()) + .policy_fn(move |event, _ctx| observer_cb.on_event(event)) + .build() + .map_err(|e| anyhow!("failed to build sandbox policy: {e}"))?; + + eprintln!("sandlock learn: observing {cmd_str} ..."); + + // Use the three-step lifecycle (create/start/wait) so we can get the child + // PID from sandbox.pid() and sample /proc/ for resource peaks. + let mut sandbox = policy.with_name("sandlock-learn"); + sandbox.create_interactive(&cmd_refs).await + .map_err(|e| anyhow!("sandbox error: {e}"))?; + let child_pid = sandbox.pid().expect("child pid after create") as u32; + sandbox.start() + .map_err(|e| anyhow!("sandbox error: {e}"))?; + + // Resource peak sampler: polls /proc/ every 100ms until the process exits. + let max_threads = Arc::new(AtomicU64::new(0)); + let max_fds = Arc::new(AtomicU64::new(0)); + let peak_rss_kb_atomic = Arc::new(AtomicU64::new(0)); + let (max_threads_s, max_fds_s, peak_rss_s) = ( + Arc::clone(&max_threads), Arc::clone(&max_fds), Arc::clone(&peak_rss_kb_atomic), + ); + let sampler = tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + match std::fs::read_to_string(format!("/proc/{child_pid}/status")) { + Err(_) => break, // process gone + Ok(s) => { + for line in s.lines() { + if let Some(v) = line.strip_prefix("Threads:") { + if let Ok(n) = v.trim().parse::() { + max_threads_s.fetch_max(n, Ordering::Relaxed); + } + } + if let Some(v) = line.strip_prefix("VmHWM:") { + if let Ok(n) = v.trim().trim_end_matches("kB").trim().parse::() { + peak_rss_s.fetch_max(n, Ordering::Relaxed); + } + } + } + } + } + if let Ok(entries) = std::fs::read_dir(format!("/proc/{child_pid}/fd")) { + max_fds_s.fetch_max(entries.count() as u64, Ordering::Relaxed); + } + } + }); + + let result = sandbox.wait().await + .map_err(|e| anyhow!("sandbox error: {e}"))?; + + sampler.abort(); + + eprintln!("sandlock learn: done (exit={:?})", result.code()); + + let peak_rss_kb = peak_rss_kb_atomic.load(Ordering::Relaxed); + let threads = max_threads.load(Ordering::Relaxed); + let fds = max_fds.load(Ordering::Relaxed); + + // Build the profile. + let mut profile_out = ProfileInput::default(); + + // Record the observed command so `sandlock run -p profile.toml` works + // without repeating the command on the CLI. + profile_out.program.exec = Some(PathBuf::from(&args.cmd[0])); + profile_out.program.args = args.cmd[1..].to_vec(); + + let cow_path = cow_dir.path().to_path_buf(); + profile_out.filesystem = FilesystemSection { + // Filter reads by existence to drop failed PATH-probe openats. + // Executed binaries are merged into read. + read: observer.reads.lock().unwrap().iter() + .filter(|p| p.exists() && !p.starts_with(&cow_path)) + .cloned() + .collect(), + // For writes: if the file exists, record the specific path (existing file modified). + // If it doesn't exist on the real FS (COW intercepted a create), record the parent + // directory instead, Landlock requires the path to exist, and the program needs + // write access to the directory to create new files inside it. + write: observer.writes.lock().unwrap().iter() + .filter(|p| !p.starts_with(&cow_path)) + .filter_map(|p| { + if p.exists() { + Some(p.clone()) + } else { + p.parent().filter(|d| d.exists()).map(|d| d.to_path_buf()) + } + }) + .collect(), + ..Default::default() + }; + profile_out.network.allow = observer.connects.lock().unwrap().iter().cloned().collect(); + + // Fill limits with observed peaks + headroom so the profile is usable with sandlock run. + if peak_rss_kb > 0 { + let mib = (peak_rss_kb + 1023) / 1024; // ceil to MiB + let headroom = (mib * 5 / 4).max(16); // +25%, min 16M + profile_out.limits.memory = Some(format!("{headroom}M")); + } + if threads > 0 { + profile_out.limits.processes = Some((threads * 2).max(4) as u32); + } + if fds > 0 { + profile_out.limits.open_files = Some((fds * 2).max(32) as u32); + } + + let header = format!( + "# generated by sandlock learn\n\ + # command: {}\n\n", + cmd_str.replace('\n', " ") + ); + let body = profile_out.to_toml() + .map_err(|e| anyhow!("failed to serialize profile: {e}"))?; + let toml_out = format!("{header}{body}"); + + match args.output { + Some(ref path) => { + std::fs::write(path, &toml_out) + .map_err(|e| anyhow!("failed to write {}: {e}", path.display()))?; + eprintln!("sandlock learn: profile written to {}", path.display()); + } + None => print!("{toml_out}"), + } + + Ok(()) +} diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index ad973bf..eec81fc 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use std::time::SystemTime; mod network_registry; +mod learn; #[derive(Parser)] #[command(name = "sandlock", about = "Lightweight process sandbox", version)] @@ -33,6 +34,8 @@ enum Command { #[command(subcommand)] action: ProfileAction, }, + /// Observe a workload and emit a sandlock profile + Learn(LearnArgs), } /// Arguments for the `run` subcommand. @@ -200,6 +203,18 @@ enum ProfileAction { Delete { name: String }, } +/// Arguments for the `learn` subcommand. +#[derive(clap::Args)] +struct LearnArgs { + /// Write observed profile to this file (default: print to stdout) + #[arg(short = 'o', long, value_name = "PATH")] + output: Option, + + /// Command to observe (everything after --) + #[arg(last = true, required = true)] + cmd: Vec, +} + #[derive(serde::Serialize)] struct SandboxStatus { exit_code: i32, @@ -303,6 +318,10 @@ async fn main() -> Result<()> { println!(" Platform: {}", std::env::consts::ARCH); } + Command::Learn(args) => { + learn::run(args).await?; + } + Command::Profile { action } => { match action { ProfileAction::List => { diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index 3a5facf..26f8d4d 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -281,7 +281,208 @@ fn test_cow_commit_runs_on_cli_exit() { assert_eq!(contents.trim(), "committed"); } -/// Regression: `--user N:N` maps the sandbox to UID `N` via an unprivileged +/// `sandlock learn` must capture filesystem reads in the generated profile. +/// Runs `cat /etc/hostname` and verifies `/etc/hostname` appears under `read`. +#[test] +fn test_learn_captures_fs_read() { + let output = sandlock_bin() + .args(["learn", "--", "cat", "/etc/hostname"]) + .output() + .expect("failed to run sandlock learn"); + assert!( + output.status.success(), + "sandlock learn failed: stderr={}", + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("/etc/hostname"), + "expected /etc/hostname in learn output, got:\n{stdout}", + ); +} + +/// End-to-end: `sandlock learn` generates a profile, `sandlock run` uses it. +/// Verifies the full round-trip works for a simple read-only workload. +#[test] +fn test_learn_then_run() { + 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, "--", "cat", "/etc/hostname"]) + .output() + .expect("failed to run sandlock learn"); + assert!( + learn.status.success(), + "sandlock learn failed: stderr={}", + String::from_utf8_lossy(&learn.stderr), + ); + + let run = sandlock_bin() + .args(["run", "--profile-file", &profile_path, "--", "cat", "/etc/hostname"]) + .output() + .expect("failed to run sandlock run"); + assert!( + run.status.success(), + "sandlock run with learned profile failed: stderr={}", + String::from_utf8_lossy(&run.stderr), + ); + assert!( + !String::from_utf8_lossy(&run.stdout).trim().is_empty(), + "expected output from cat /etc/hostname", + ); +} + +/// `sandlock learn` must classify file opens with write flags under `write`. +/// Runs a shell that writes a temp file and verifies it appears under `write`. +#[test] +fn test_learn_captures_fs_write() { + let tmp = tempfile::NamedTempFile::new().expect("tempfile"); + let path = tmp.path().to_str().unwrap().to_owned(); + let cmd = format!("echo x > {path}"); + let output = sandlock_bin() + .args(["learn", "--", "sh", "-c", &cmd]) + .output() + .expect("failed to run sandlock learn"); + assert!( + output.status.success(), + "sandlock learn failed: stderr={}", + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&path), + "expected {path} in learn write output, got:\n{stdout}", + ); + // Confirm it appears in write = [...], not read + let write_line = stdout.lines().find(|l| l.starts_with("write = [")).unwrap_or(""); + assert!( + write_line.contains(&path), + "expected {path} under write = [...], got: {write_line}", + ); +} + +/// New file creates must be collapsed to the parent directory in the profile. +/// The specific file path is useless to Landlock (it doesn't exist yet); +/// the parent dir is what `sandlock run` needs to create new files. +/// COW must also confirm the real filesystem is not touched during learn. +#[test] +fn test_learn_new_file_collapses_to_parent() { + let path = "/var/tmp/sandlock-learn-write-test.txt"; + let output = sandlock_bin() + .args(["learn", "--", "sh", "-c", &format!("echo x > {path}")]) + .output() + .expect("failed to run sandlock learn"); + assert!( + output.status.success(), + "sandlock learn failed: stderr={}", + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // New-file creates are collapsed to the parent directory (file didn't exist on real FS). + let parent = std::path::Path::new(path).parent().unwrap().to_str().unwrap(); + let write_line = stdout.lines().find(|l| l.starts_with("write = [")).unwrap_or(""); + assert!( + write_line.contains(parent), + "expected parent dir {parent} under write = [...], got: {write_line}", + ); + // COW must have intercepted the write, real file must not exist. + assert!( + !std::path::Path::new(path).exists(), + "real filesystem was modified, COW isolation failed", + ); +} + +/// End-to-end write round-trip: learn captures write path, run actually writes the file. +/// During learn, COW intercepts the write (file not created on real FS). +/// During run, the profile grants write access to parent dir, so the file is created for real. +#[test] +fn test_learn_then_run_write() { + let profile = tempfile::NamedTempFile::new().expect("tempfile"); + let profile_path = profile.path().to_str().unwrap().to_owned(); + let write_path = "/var/tmp/sandlock-learn-run-write-test.txt"; + let _ = std::fs::remove_file(write_path); // clean state + + // No pre-creation needed: learn collapses new-file creates to the parent directory, + // so sandlock run gets write access to the directory and can create the file. + let learn = sandlock_bin() + .args(["learn", "-o", &profile_path, "--", "sh", "-c", &format!("echo hello > {write_path}")]) + .output() + .expect("failed to run sandlock learn"); + assert!(learn.status.success() || learn.status.code() == Some(2), + "learn failed unexpectedly: {}", String::from_utf8_lossy(&learn.stderr)); + assert!(!std::path::Path::new(write_path).exists(), "COW isolation failed during learn"); + + let run = sandlock_bin() + .args(["run", "--profile-file", &profile_path, "--", "sh", "-c", &format!("echo hello > {write_path}")]) + .output() + .expect("failed to run sandlock run"); + assert!(run.status.success(), "run failed: {}", String::from_utf8_lossy(&run.stderr)); + assert_eq!(std::fs::read_to_string(write_path).unwrap_or_default().trim(), "hello", "file not written during run"); + let _ = std::fs::remove_file(write_path); +} + + +/// `sandlock learn` must record observed TCP connections under `[network] allow`. +/// Binds a real listener so the connect succeeds cleanly. +#[test] +fn test_learn_captures_net_connect() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + // Accept one connection so the child doesn't hang waiting for handshake. + let _t = std::thread::spawn(move || { let _ = listener.accept(); }); + + let script = format!( + "import socket; s=socket.socket(); s.connect(('127.0.0.1',{port})); s.close()" + ); + let output = sandlock_bin() + .args(["learn", "--", "python3", "-c", &script]) + .output() + .expect("failed to run sandlock learn"); + assert!( + output.status.success(), + "sandlock learn failed: stderr={}", + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let expected = format!("127.0.0.1:{port}"); + assert!( + stdout.contains(&expected), + "expected {expected} in network output, got:\n{stdout}", + ); + let net_line = stdout.lines().find(|l| l.starts_with("allow = [")).unwrap_or(""); + assert!( + net_line.contains(&expected), + "expected {expected} under [network] allow = [...], got: {net_line}", + ); +} + +/// End-to-end network round-trip: learn captures a TCP connection, run allows it. +/// A single listener accepts two connections, one from learn, one from run. +#[test] +fn test_learn_then_run_network() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { let _ = listener.accept(); let _ = listener.accept(); }); + + let profile = tempfile::NamedTempFile::new().expect("tempfile"); + let profile_path = profile.path().to_str().unwrap().to_owned(); + let script = format!("import socket; s=socket.socket(); s.connect(('127.0.0.1',{port})); s.close()"); + + let learn = sandlock_bin() + .args(["learn", "-o", &profile_path, "--", "python3", "-c", &script]) + .output() + .expect("failed to run sandlock learn"); + assert!(learn.status.success(), "learn failed: {}", String::from_utf8_lossy(&learn.stderr)); + + let run = sandlock_bin() + .args(["run", "--profile-file", &profile_path, "--", "python3", "-c", &script]) + .output() + .expect("failed to run sandlock run"); + assert!(run.status.success(), "run failed: {}", String::from_utf8_lossy(&run.stderr)); +} + +/// `--user N:N` maps the sandbox to UID `N` via an unprivileged /// user namespace, even when the host UID is non-zero. This is the only /// remaining `CLONE_NEWUSER` site after the overlayfs backend removal; /// the test guards against accidentally tearing it out. @@ -311,6 +512,35 @@ fn test_uid_mapping_fakes_root() { ); } +/// Verify that `sandlock learn` populates `[limits]` with memory, processes, +/// and open_files when the workload runs long enough for the sampler to capture +/// resource peaks. +#[test] +fn test_learn_captures_resource_limits() { + let output = sandlock_bin() + .args(["learn", "--", "sleep", "0.2"]) + .output() + .expect("failed to run sandlock learn"); + assert!( + output.status.success(), + "sandlock learn failed: stderr={}", + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("memory = \""), + "expected memory limit in learn output, got:\n{stdout}", + ); + assert!( + stdout.contains("processes = "), + "expected processes limit in learn output, got:\n{stdout}", + ); + assert!( + stdout.contains("open_files = "), + "expected open_files limit in learn output, got:\n{stdout}", + ); +} + #[test] fn test_uid_mapping_arbitrary_uid() { // Arbitrary --user value should also map cleanly (not just 0). diff --git a/crates/sandlock-core/src/policy_fn.rs b/crates/sandlock-core/src/policy_fn.rs index 2b96f0b..eb7d6ab 100644 --- a/crates/sandlock-core/src/policy_fn.rs +++ b/crates/sandlock-core/src/policy_fn.rs @@ -105,6 +105,14 @@ pub struct SyscallEvent { pub argv: Option>, /// Whether the supervisor denied this syscall. pub denied: bool, + /// Resolved absolute path for file syscalls (openat, execve/execveat). + /// Read from the kernel's fd table via `/proc//fd/` after the + /// supervisor's on-behalf open — not from child user memory — so it is + /// TOCTOU-safe. `None` for non-file syscalls or when resolution fails. + pub path: Option, + /// Open flags for openat (the `flags` argument, e.g. `O_RDONLY`, + /// `O_WRONLY`, `O_CREAT`). `None` for non-openat syscalls. + pub flags: Option, } impl SyscallEvent { @@ -483,6 +491,8 @@ mod tests { size: None, argv: Some(vec!["python3".into(), "-c".into(), "print(1)".into()]), denied: false, + path: None, + flags: None, }; assert!(event.argv_contains("python3")); assert!(event.argv_contains("-c")); @@ -502,6 +512,8 @@ mod tests { size: None, argv: None, denied: false, + path: None, + flags: None, }; assert!(!event.argv_contains("anything")); } diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 065c6dd..1640abe 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -1,6 +1,6 @@ use crate::sandbox::{ByteSize, Sandbox}; use crate::error::SandlockError; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::collections::HashMap; use std::time::SystemTime; @@ -14,67 +14,104 @@ pub struct ProgramSpec { } /// Top-level profile input. Each section maps to one schema section. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct ProfileInput { + #[serde(skip_serializing_if = "is_default")] pub config: ConfigSection, + #[serde(skip_serializing_if = "is_default")] pub determinism: DeterminismSection, + #[serde(skip_serializing_if = "is_default")] pub program: ProgramSection, + #[serde(skip_serializing_if = "is_default")] pub filesystem: FilesystemSection, + #[serde(skip_serializing_if = "is_default")] pub network: NetworkSection, + #[serde(skip_serializing_if = "is_default")] pub http: HttpSection, + #[serde(skip_serializing_if = "is_default")] pub syscalls: SyscallsSection, + #[serde(skip_serializing_if = "is_default")] pub limits: LimitsSection, } +fn is_false(b: &bool) -> bool { !b } +fn is_default(v: &T) -> bool { *v == T::default() } + // Field names follow the schema vocabulary and match `Sandbox`'s field names 1:1. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct ConfigSection { + #[serde(skip_serializing_if = "Option::is_none")] pub http_ca: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub http_key: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] pub http_inject_ca: Vec, + #[serde(skip_serializing_if = "Option::is_none")] pub http_ca_out: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub fs_storage: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub workdir: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct DeterminismSection { + #[serde(skip_serializing_if = "Option::is_none")] pub random_seed: Option, /// RFC3339 timestamp string. Maps to `Sandbox::time_start`. + #[serde(skip_serializing_if = "Option::is_none")] pub time_start: Option, + #[serde(skip_serializing_if = "is_false")] pub deterministic_dirs: bool, + #[serde(skip_serializing_if = "is_false")] pub no_randomize_memory: bool, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct ProgramSection { + #[serde(skip_serializing_if = "Option::is_none")] pub exec: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] pub args: Vec, + #[serde(skip_serializing_if = "HashMap::is_empty")] pub env: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub uid: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub gid: Option, + #[serde(skip_serializing_if = "is_false")] pub clean_env: bool, + #[serde(skip_serializing_if = "is_false")] pub no_coredump: bool, + #[serde(skip_serializing_if = "is_false")] pub no_huge_pages: bool, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct FilesystemSection { + #[serde(skip_serializing_if = "Vec::is_empty")] pub read: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub write: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub deny: Vec, + #[serde(skip_serializing_if = "Option::is_none")] pub chroot: Option, /// Each entry has the form `"VIRTUAL:HOST"`, matching `--fs-mount` syntax. + #[serde(skip_serializing_if = "Vec::is_empty")] pub mount: Vec, /// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_exit`. + #[serde(skip_serializing_if = "Option::is_none")] pub on_exit: Option, /// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_error`. + #[serde(skip_serializing_if = "Option::is_none")] pub on_error: Option, } @@ -82,61 +119,86 @@ pub struct FilesystemSection { /// quoted string holding a comma list and/or `lo-hi` range (`"9000-9005"`). /// The untagged form lets a TOML array mix the two, e.g. /// `allow_bind = [8080, "9000-9005"]`. -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(untagged)] pub enum PortSpec { Port(u16), Spec(String), } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct NetworkSection { + #[serde(skip_serializing_if = "Vec::is_empty")] pub allow_bind: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub deny_bind: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub allow: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub deny: Vec, + #[serde(skip_serializing_if = "is_false")] pub port_remap: bool, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct HttpSection { + #[serde(skip_serializing_if = "Vec::is_empty")] pub ports: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub allow: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub deny: Vec, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct SyscallsSection { + #[serde(skip_serializing_if = "Vec::is_empty")] pub extra_allow: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub extra_deny: Vec, } // Field names drop the `max_` prefix that `Sandbox` uses (`memory`, not // `max_memory`) — the section name `[limits]` makes the prefix redundant. // `parse_input` maps each of these to the corresponding `Sandbox::max_*` field. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct LimitsSection { /// `ByteSize` string, e.g. `"512M"` (suffixes K/M/G only; IEC `MiB`/`GiB` /// not yet supported). Maps to `Sandbox::max_memory`. + #[serde(skip_serializing_if = "Option::is_none")] pub memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub processes: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub open_files: Option, /// CPU cap as a percentage (0–100). Maps to `Sandbox::max_cpu`. + #[serde(skip_serializing_if = "Option::is_none")] pub cpu: Option, /// `ByteSize` string, e.g. `"256M"` (suffixes K/M/G only; IEC `MiB`/`GiB` /// not yet supported). Maps to `Sandbox::max_disk`. + #[serde(skip_serializing_if = "Option::is_none")] pub disk: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub gpu_devices: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub cpu_cores: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub num_cpus: Option, } /// Convert a parsed `ProfileInput` into a `(Sandbox, ProgramSpec)` pair. /// +impl ProfileInput { + /// Serialize the profile to a TOML string. + pub fn to_toml(&self) -> Result { + toml::to_string(self) + } +} + /// Forwards each schema section's fields to the corresponding `SandboxBuilder` /// method calls. The two private helpers (`parse_branch_action`, /// `parse_mount_spec`) handle string-to-typed-value conversions for fields diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 0cdd3b7..c0966d0 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -197,6 +197,7 @@ pub struct SandboxBuilder { // COW fork work function: runs in each COW clone. #[cfg_attr(feature = "cli", clap(skip))] pub(crate) work_fn: Option>, + } impl std::fmt::Debug for SandboxBuilder { diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index aeac354..ec508d3 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -1227,6 +1227,14 @@ fn send_response(fd: RawFd, id: u64, action: NotifAction) -> io::Result<()> { } } +// ============================================================ +// Maps reading after exec +// ============================================================ + +/// Read the dynamic linker path from /proc//maps after execve completes. +/// The linker is loaded by the kernel in kernel space during execve. After exec, it appears as a file-backed mapping whose name +/// contains "/ld-" (the standard naming convention). + // ============================================================ // vDSO re-patching after exec // ============================================================ @@ -1551,6 +1559,8 @@ async fn emit_policy_event( let mut port = None; let mut size = None; let mut argv = None; + let mut path = None; + let mut flags = None; if !denied && (nr == libc::SYS_execve || nr == libc::SYS_execveat) { // execve(pathname, argv, envp): args[1] = argv ptr @@ -1561,6 +1571,9 @@ async fn emit_policy_event( notif.data.args[1] }; argv = read_argv_for_event(notif, argv_ptr, notif_fd); + // Binary path: resolved via procfs (TOCTOU-safe, not from child memory). + path = resolve_path_for_notif(notif, notif_fd) + .map(std::path::PathBuf::from); } if nr == libc::SYS_connect || nr == libc::SYS_sendto || nr == libc::SYS_bind { @@ -1577,6 +1590,20 @@ async fn emit_policy_event( size = Some(notif.data.args[1]); } + // openat(dirfd, pathname, flags, mode): resolved path + flags. + // Path is read via /proc//fd after the on-behalf open — TOCTOU-safe. + // openat2 uses struct open_how* for flags so flags is left None there. + if nr == libc::SYS_openat || Some(nr) == arch::sys_open() { + path = resolve_path_for_notif(notif, notif_fd) + .map(std::path::PathBuf::from); + // openat: args[2] = flags; open: args[1] = flags + flags = Some(if nr == libc::SYS_openat { + notif.data.args[2] + } else { + notif.data.args[1] + }); + } + let event = crate::policy_fn::SyscallEvent { syscall: name.to_string(), category, @@ -1587,6 +1614,8 @@ async fn emit_policy_event( size, argv, denied, + path, + flags, }; // Hold syscalls where the callback's verdict matters. diff --git a/crates/sandlock-core/src/seccomp_plan.rs b/crates/sandlock-core/src/seccomp_plan.rs index 8ffaaa2..6ea5194 100644 --- a/crates/sandlock-core/src/seccomp_plan.rs +++ b/crates/sandlock-core/src/seccomp_plan.rs @@ -354,8 +354,11 @@ pub(crate) fn notif_syscalls_resolved(resolved: &ResolvedSandbox) -> Vec { } // Dynamic policy callback: intercept key syscalls for event emission. + // Also includes the legacy open(2) syscall (absent on some arches) so + // path events fire on kernels that still dispatch it. if features.policy_fn { nrs.extend(POLICY_EVENT_SYSCALLS); + nrs.push_optional(arch::sys_open()); } // Port remapping