From bb1fbbc2afe8e79ae2fbe0671fcfdd3c9f8f6f0b Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 23 Jun 2026 07:17:15 +0400 Subject: [PATCH 01/28] profile: derive Serialize on all profile section types --- crates/sandlock-core/src/profile.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 065c6dd2..93945410 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,7 +14,7 @@ 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 { pub config: ConfigSection, @@ -28,7 +28,7 @@ pub struct ProfileInput { } // 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 { pub http_ca: Option, @@ -39,7 +39,7 @@ pub struct ConfigSection { pub workdir: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct DeterminismSection { pub random_seed: Option, @@ -49,7 +49,7 @@ pub struct DeterminismSection { 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 { pub exec: Option, @@ -63,7 +63,7 @@ pub struct ProgramSection { 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 { pub read: Vec, @@ -82,14 +82,14 @@ 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 { pub allow_bind: Vec, @@ -99,7 +99,7 @@ pub struct NetworkSection { 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 { pub ports: Vec, @@ -107,7 +107,7 @@ pub struct HttpSection { pub deny: Vec, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct SyscallsSection { pub extra_allow: Vec, @@ -117,7 +117,7 @@ pub struct SyscallsSection { // 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` From 724842407a3f9c45835e74d8d0ce8559f4937557 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 23 Jun 2026 07:18:19 +0400 Subject: [PATCH 02/28] seccomp: add audit hooks for file access and network connect --- crates/sandlock-core/src/sandbox.rs | 13 ++++++++ crates/sandlock-core/src/sandbox/builder.rs | 29 +++++++++++++++++ crates/sandlock-core/src/seccomp/notif.rs | 35 +++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index ca99f9f5..b13a1c46 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -435,6 +435,14 @@ pub struct Sandbox { #[serde(skip)] work_fn: Option>, + // Audit callback for file-open syscalls; fires before internal handlers. + #[serde(skip)] + on_file_access: Option>, + + // Audit callback for network connect/sendto syscalls; fires before internal handlers. + #[serde(skip)] + on_net_connect: Option>, + // Heap-allocated runtime state; `None` when not started. #[serde(skip)] runtime: Option>, @@ -519,6 +527,9 @@ impl Clone for Sandbox { init_fn: None, // work_fn is Arc-wrapped — clone bumps the reference count. work_fn: self.work_fn.clone(), + // on_file_access is Arc-wrapped — clone bumps the reference count. + on_file_access: self.on_file_access.clone(), + on_net_connect: self.on_net_connect.clone(), // Runtime is NOT cloned — the clone starts with no runtime. runtime: None, } @@ -1710,6 +1721,8 @@ impl Sandbox { virtual_etc_hosts, ca_inject_paths: self.http_inject_ca.clone(), ca_inject_pem: ca_inject_pem.clone(), + audit_file_access: self.on_file_access.clone(), + audit_net_connect: self.on_net_connect.clone(), }; use rand::SeedableRng; diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 0cdd3b70..0bee0c0a 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -197,6 +197,14 @@ pub struct SandboxBuilder { // COW fork work function: runs in each COW clone. #[cfg_attr(feature = "cli", clap(skip))] pub(crate) work_fn: Option>, + + // Audit callback for file-open syscalls. + #[cfg_attr(feature = "cli", clap(skip))] + pub(crate) on_file_access: Option>, + + // Audit callback for network connect/sendto syscalls. + #[cfg_attr(feature = "cli", clap(skip))] + pub(crate) on_net_connect: Option>, } impl std::fmt::Debug for SandboxBuilder { @@ -268,6 +276,9 @@ impl Clone for SandboxBuilder { init_fn: None, // work_fn is Arc-wrapped; clone bumps the reference count. work_fn: self.work_fn.clone(), + // on_file_access is Arc-wrapped; clone bumps the reference count. + on_file_access: self.on_file_access.clone(), + on_net_connect: self.on_net_connect.clone(), } } } @@ -610,6 +621,22 @@ impl SandboxBuilder { self } + /// Register an audit callback that fires for every file-open syscall + /// (`openat`, `open`, `execve`, etc.) before any internal handler runs. + /// Receives the resolved absolute path and the open flags (`O_*`); flags + /// are `0` for execve and other non-open syscalls. + pub fn on_file_access(mut self, f: impl Fn(&std::path::Path, u64) + Send + Sync + 'static) -> Self { + self.on_file_access = Some(Arc::new(f)); + self + } + + /// Register an audit callback that fires for every `connect`/`sendto` syscall + /// before any internal handler runs. Receives the destination IP and port. + pub fn on_net_connect(mut self, f: impl Fn(std::net::IpAddr, u16) + Send + Sync + 'static) -> Self { + self.on_net_connect = Some(Arc::new(f)); + self + } + /// Build a `Sandbox`, parsing all string fields and running per-field /// validation, but **without** the cross-section checks that /// `Sandbox::validate` performs. Use this in tests that deliberately @@ -781,6 +808,8 @@ impl SandboxBuilder { name: self.name, init_fn: self.init_fn, work_fn: self.work_fn, + on_file_access: self.on_file_access, + on_net_connect: self.on_net_connect, runtime: None, }) } diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index aeac354a..4aa75180 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -777,6 +777,13 @@ pub struct NotifPolicy { /// Active MITM CA public cert (PEM bytes) to inject. `Some` only when /// HTTPS MITM is active (BYO or generated). pub ca_inject_pem: Option>>, + /// Optional audit hook called for every file-open syscall before dispatch. + /// Receives the resolved absolute path and the open flags (`O_*`); flags + /// are `0` for execve/execveat and other non-open syscalls. + pub audit_file_access: Option>, + /// Optional audit hook called for every `connect`/`sendto` syscall before + /// dispatch. Receives the destination IP and port. + pub audit_net_connect: Option>, } impl NotifPolicy { @@ -1690,6 +1697,34 @@ async fn handle_notification( maybe_patch_vdso(notif.pid as i32, &mut pfs, policy); } + // Audit hook — fires before internal handlers so it sees every file open + // regardless of how the dispatch chain handles it. + if let Some(ref hook) = policy.audit_file_access { + let nr = notif.data.nr as i64; + if let Some(path) = resolve_path_for_notif(¬if, fd) { + let flags = if nr == libc::SYS_openat { + notif.data.args[2] + } else if Some(nr) == arch::sys_open() { + notif.data.args[1] + } else { + 0 + }; + hook(std::path::Path::new(&path), flags); + } + } + + // Network connect audit hook — fires before internal handlers. + if let Some(ref hook) = policy.audit_net_connect { + let nr = notif.data.nr as i64; + if nr == libc::SYS_connect || nr == libc::SYS_sendto { + let addr_ptr = notif.data.args[1]; + let addr_len = notif.data.args[2] as usize; + if let (Some(ip), Some(port)) = read_sockaddr_for_event(¬if, addr_ptr, addr_len, fd) { + hook(ip, port); + } + } + } + // Check dynamic path denials before dispatch let mut action = { let nr = notif.data.nr as i64; From 4cfc4602a8a86e0b0cf1ef83b1ab524d7295b14b Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 23 Jun 2026 07:18:29 +0400 Subject: [PATCH 03/28] cli: add sandlock learn subcommand --- Cargo.lock | 1 + crates/sandlock-cli/Cargo.toml | 1 + crates/sandlock-cli/src/learn.rs | 207 ++++++++++++++++++++++++++ crates/sandlock-cli/src/main.rs | 19 +++ crates/sandlock-cli/tests/cli_test.rs | 85 ++++++++++- 5 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 crates/sandlock-cli/src/learn.rs diff --git a/Cargo.lock b/Cargo.lock index aff6b548..05267cdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1379,6 +1379,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "toml", ] [[package]] diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index 9772cb12..3e92048b 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -21,6 +21,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" jiff = "0.2" libc = "0.2" +toml = "0.8" [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 00000000..51adb6e3 --- /dev/null +++ b/crates/sandlock-cli/src/learn.rs @@ -0,0 +1,207 @@ +//! Implementation of `sandlock learn`. +//! +//! Runs a workload under fully-permissive Landlock (read-everything) and +//! intercepts every file-open syscall via an audit hook registered directly +//! in the sandlock-core supervisor. Emits a sandlock profile TOML readable +//! by `sandlock run --profile-file`. +//! +//! Note: no path collapsing is applied — every individual file is listed. +//! See issue #72 for the planned collapsing design. + +use std::collections::BTreeSet; +use std::net::IpAddr; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use anyhow::{anyhow, Result}; +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 +} + +fn resolve_cmd(cmd: &str) -> PathBuf { + let p = if cmd.contains('/') { + PathBuf::from(cmd) + } else { + std::env::var("PATH") + .unwrap_or_default() + .split(':') + .map(|dir| PathBuf::from(dir).join(cmd)) + .find(|p| p.exists()) + .unwrap_or_else(|| PathBuf::from(cmd)) + }; + std::fs::canonicalize(&p).unwrap_or(p) +} + +/// Read the ELF PT_INTERP segment of a binary and return the interpreter path. +/// Returns `None` for statically linked binaries or non-ELF files. +fn elf_interpreter(binary: &std::path::Path) -> Option { + let data = std::fs::read(binary).ok()?; + // ELF magic: 0x7f 'E' 'L' 'F' + if data.get(..4) != Some(b"\x7fELF") { + return None; + } + // ELF64 only: class byte at offset 4 must be 2. + if data.get(4).copied() != Some(2) { + return None; + } + // Endianness byte at offset 5: 1 = little, 2 = big. + let le = data.get(5).copied()? == 1; + let read_u16 = |off: usize| -> Option { + let b = data.get(off..off + 2)?; + Some(if le { u16::from_le_bytes(b.try_into().ok()?) } else { u16::from_be_bytes(b.try_into().ok()?) }) + }; + let read_u64 = |off: usize| -> Option { + let b = data.get(off..off + 8)?; + Some(if le { u64::from_le_bytes(b.try_into().ok()?) } else { u64::from_be_bytes(b.try_into().ok()?) }) + }; + // ELF64 header: phoff at 0x20, phentsize at 0x36, phnum at 0x38. + let phoff = read_u64(0x20)? as usize; + let phentsize = read_u16(0x36)? as usize; + let phnum = read_u16(0x38)? as usize; + // PT_INTERP = 3 + for i in 0..phnum { + let ph = phoff + i * phentsize; + let p_type = data.get(ph..ph + 4)?; + let p_type = if le { u32::from_le_bytes(p_type.try_into().ok()?) } else { u32::from_be_bytes(p_type.try_into().ok()?) }; + if p_type == 3 { + // p_offset at ph+8, p_filesz at ph+32 in ELF64 + let offset = read_u64(ph + 8)? as usize; + let filesz = read_u64(ph + 32)? as usize; + let interp = data.get(offset..offset + filesz)?; + // Strip trailing null byte + let interp = interp.split(|&b| b == 0).next()?; + return Some(PathBuf::from(std::str::from_utf8(interp).ok()?)); + } + } + None +} + +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. + // Any denial here would make the trace incomplete (workload crashes before + // reaching other files it needs). See issue #72 open question #1. + let reads: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); + let writes: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); + let connects: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); + + let (reads_c, writes_c, connects_c) = (Arc::clone(&reads), Arc::clone(&writes), Arc::clone(&connects)); + let policy = Sandbox::builder() + .fs_read("/") + .fs_write("/tmp") + .on_file_access(move |path, flags| { + if is_write_open(flags) { + writes_c.lock().unwrap().insert(path.to_path_buf()); + } else { + reads_c.lock().unwrap().insert(path.to_path_buf()); + } + }) + .on_net_connect(move |ip, port| { + connects_c.lock().unwrap().insert(format!("tcp://{ip}:{port}")); + }) + .build() + .map_err(|e| anyhow!("failed to build sandbox policy: {e}"))?; + + eprintln!("sandlock learn: observing {cmd_str} ..."); + + let result = policy + .with_name("sandlock-learn") + .run(&cmd_refs) + .await + .map_err(|e| anyhow!("sandbox error: {e}"))?; + + eprintln!("sandlock learn: done (exit={:?})", result.code()); + + // The executed binary and its dynamic linker are loaded by the kernel during + // execve — they never appear in the audit hook trace. Add them explicitly. + let binary = resolve_cmd(&args.cmd[0]); + if let Some(interp) = elf_interpreter(&binary) { + reads.lock().unwrap().insert(interp); + } + reads.lock().unwrap().insert(binary); + + // Build the profile using the proper struct — same schema `sandlock run -p` reads. + let mut profile_out = ProfileInput::default(); + profile_out.filesystem = FilesystemSection { + read: reads.lock().unwrap().iter().filter(|p| p.exists()).cloned().collect(), + write: writes.lock().unwrap().iter().filter(|p| p.exists()).cloned().collect(), + ..Default::default() + }; + profile_out.network.allow = connects.lock().unwrap().iter().cloned().collect(); + + let header = format!( + "# generated by sandlock learn\n\ + # command: {cmd_str}\n\ + # note: raw observation — no path collapsing applied\n\ + # every file is listed individually (see issue #72 for collapsing design)\n\n" + ); + let body = toml::to_string(&profile_out) + .map_err(|e| anyhow!("failed to serialize profile: {e}"))?; + let body = strip_empty_sections(&body); + 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(()) +} + +/// Strip TOML sections that contain only default/empty values. +/// `toml::to_string` emits every field including defaults — this keeps +/// the profile minimal and readable. +fn strip_empty_sections(toml: &str) -> String { + let mut out = String::new(); + let mut section: Vec<&str> = Vec::new(); + let mut in_section = false; + + for line in toml.lines() { + if line.starts_with('[') { + if in_section && section_has_content(§ion) { + for l in §ion { out.push_str(l); out.push('\n'); } + } + section = vec![line]; + in_section = true; + } else if in_section { + section.push(line); + } else { + out.push_str(line); out.push('\n'); + } + } + if in_section && section_has_content(§ion) { + for l in §ion { out.push_str(l); out.push('\n'); } + } + out +} + +fn section_has_content(lines: &[&str]) -> bool { + lines.iter().skip(1).any(|l| { + let v = l.trim(); + !v.is_empty() + && !v.ends_with("= []") + && !v.ends_with("= {}") + && !v.ends_with("= false") + && v != "[]" + }) +} diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index ad973bf3..3e529ef7 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 minimal 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 3a5facf9..ab99ca54 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -281,7 +281,90 @@ 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}", + ); +} + +/// `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}", + ); +} + +/// `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}", + ); +} + +/// `--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. From 62514ce2db9da6c5ca711b78f50ff256b72996be Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 23 Jun 2026 07:24:44 +0400 Subject: [PATCH 04/28] profile: add to_toml() method, remove toml dep from sandlock-cli --- Cargo.lock | 1 - crates/sandlock-cli/Cargo.toml | 1 - crates/sandlock-cli/src/learn.rs | 2 +- crates/sandlock-core/src/profile.rs | 7 +++++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 05267cdf..aff6b548 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1379,7 +1379,6 @@ dependencies = [ "serde_json", "tempfile", "tokio", - "toml", ] [[package]] diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index 3e92048b..9772cb12 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -21,7 +21,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" jiff = "0.2" libc = "0.2" -toml = "0.8" [dev-dependencies] tempfile = "3" diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 51adb6e3..be3f82d1 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -151,7 +151,7 @@ pub async fn run(args: LearnArgs) -> Result<()> { # note: raw observation — no path collapsing applied\n\ # every file is listed individually (see issue #72 for collapsing design)\n\n" ); - let body = toml::to_string(&profile_out) + let body = profile_out.to_toml() .map_err(|e| anyhow!("failed to serialize profile: {e}"))?; let body = strip_empty_sections(&body); let toml_out = format!("{header}{body}"); diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 93945410..dae31d58 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -137,6 +137,13 @@ pub struct LimitsSection { /// 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 From eab8c1b7ff106d7146cee746a49d06aeb278145f Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 23 Jun 2026 10:22:19 +0400 Subject: [PATCH 05/28] core: extend file hook to execve, fix sendto args, add sendmsg --- crates/sandlock-core/src/resolved.rs | 2 + crates/sandlock-core/src/sandbox.rs | 2 +- crates/sandlock-core/src/seccomp/notif.rs | 49 +++++++++++++++++------ crates/sandlock-core/src/seccomp_plan.rs | 8 ++++ 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/sandlock-core/src/resolved.rs b/crates/sandlock-core/src/resolved.rs index 78d0c08b..48d8e8c6 100644 --- a/crates/sandlock-core/src/resolved.rs +++ b/crates/sandlock-core/src/resolved.rs @@ -42,6 +42,7 @@ pub(crate) struct SandboxFeatures { pub(crate) chroot: bool, pub(crate) fs_denies: bool, pub(crate) policy_fn: bool, + pub(crate) audit_file_access: bool, pub(crate) port_remap: bool, pub(crate) http_acl: bool, pub(crate) argv_safety_required: bool, @@ -80,6 +81,7 @@ impl SandboxFeatures { chroot: sandbox.chroot.is_some(), fs_denies: !sandbox.fs_denied.is_empty(), policy_fn: sandbox.policy_fn.is_some(), + audit_file_access: sandbox.on_file_access.is_some(), port_remap: sandbox.port_remap, http_acl, argv_safety_required: sandbox.policy_fn.is_some() || exec_handler, diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index b13a1c46..018f16f8 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -437,7 +437,7 @@ pub struct Sandbox { // Audit callback for file-open syscalls; fires before internal handlers. #[serde(skip)] - on_file_access: Option>, + pub(crate) on_file_access: Option>, // Audit callback for network connect/sendto syscalls; fires before internal handlers. #[serde(skip)] diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index 4aa75180..e9bec4d3 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -1701,24 +1701,49 @@ async fn handle_notification( // regardless of how the dispatch chain handles it. if let Some(ref hook) = policy.audit_file_access { let nr = notif.data.nr as i64; - if let Some(path) = resolve_path_for_notif(¬if, fd) { - let flags = if nr == libc::SYS_openat { - notif.data.args[2] - } else if Some(nr) == arch::sys_open() { - notif.data.args[1] - } else { - 0 - }; - hook(std::path::Path::new(&path), flags); + let is_open = nr == libc::SYS_openat + || Some(nr) == arch::sys_open() + || nr == libc::SYS_execve + || nr == libc::SYS_execveat; + if is_open { + if let Some(path) = resolve_path_for_notif(¬if, fd) { + let flags = if nr == libc::SYS_openat { + notif.data.args[2] + } else if Some(nr) == arch::sys_open() { + notif.data.args[1] + } else { + 0 // execve/execveat — no open flags + }; + hook(std::path::Path::new(&path), flags); + } } } // Network connect audit hook — fires before internal handlers. if let Some(ref hook) = policy.audit_net_connect { let nr = notif.data.nr as i64; - if nr == libc::SYS_connect || nr == libc::SYS_sendto { - let addr_ptr = notif.data.args[1]; - let addr_len = notif.data.args[2] as usize; + // Extract (addr_ptr, addr_len) from the syscall args — each syscall lays them out differently. + let sockaddr = if nr == libc::SYS_connect { + // connect(sockfd, addr, addrlen) + Some((notif.data.args[1], notif.data.args[2] as usize)) + } else if nr == libc::SYS_sendto { + // sendto(sockfd, buf, len, flags, addr, addrlen) + Some((notif.data.args[4], notif.data.args[5] as usize)) + } else if nr == libc::SYS_sendmsg { + // sendmsg(sockfd, msghdr*, flags) — addr is msg_name inside the msghdr struct + let msghdr_ptr = notif.data.args[1]; + read_child_mem(fd, notif.id, notif.pid, msghdr_ptr, 16) + .ok() + .filter(|b| b.len() >= 16) + .and_then(|b| { + let msg_name = u64::from_ne_bytes(b[0..8].try_into().ok()?); + let msg_namelen = u32::from_ne_bytes(b[8..12].try_into().ok()?) as usize; + if msg_name != 0 && msg_namelen >= 4 { Some((msg_name, msg_namelen)) } else { None } + }) + } else { + None + }; + if let Some((addr_ptr, addr_len)) = sockaddr { if let (Some(ip), Some(port)) = read_sockaddr_for_event(¬if, addr_ptr, addr_len, fd) { hook(ip, port); } diff --git a/crates/sandlock-core/src/seccomp_plan.rs b/crates/sandlock-core/src/seccomp_plan.rs index 8ffaaa21..0c57be65 100644 --- a/crates/sandlock-core/src/seccomp_plan.rs +++ b/crates/sandlock-core/src/seccomp_plan.rs @@ -358,6 +358,14 @@ pub(crate) fn notif_syscalls_resolved(resolved: &ResolvedSandbox) -> Vec { nrs.extend(POLICY_EVENT_SYSCALLS); } + // Audit file-access hook: needs execve/execveat in notif so the hook fires + // for the executed binary (openat is already covered by procfs/hosts notif). + if features.audit_file_access { + nrs.push(libc::SYS_execve); + nrs.push(libc::SYS_execveat); + nrs.push_optional(arch::sys_open()); + } + // Port remapping if features.port_remap { nrs.extend(PORT_REMAP_SYSCALLS); From 1ee9176f73126a8f3e60f4bb190b80df897e5e7f Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 23 Jun 2026 10:22:32 +0400 Subject: [PATCH 06/28] learn: COW observation, parent-dir write collapsing, execve binary capture --- crates/sandlock-cli/Cargo.toml | 2 +- crates/sandlock-cli/src/learn.rs | 122 ++++++++++++------------------- 2 files changed, 48 insertions(+), 76 deletions(-) diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index 9772cb12..a9fce1ff 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 index be3f82d1..be068334 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -1,15 +1,9 @@ -//! Implementation of `sandlock learn`. +//! Implementation of `sandlock learn -o `. //! -//! Runs a workload under fully-permissive Landlock (read-everything) and -//! intercepts every file-open syscall via an audit hook registered directly -//! in the sandlock-core supervisor. Emits a sandlock profile TOML readable -//! by `sandlock run --profile-file`. -//! -//! Note: no path collapsing is applied — every individual file is listed. -//! See issue #72 for the planned collapsing design. +//! Runs a workload under observation and emits a sandlock profile TOML +//! usable by `sandlock run -p`. use std::collections::BTreeSet; -use std::net::IpAddr; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -28,22 +22,8 @@ fn is_write_open(flags: u64) -> bool { flags & (O_WRONLY | O_RDWR | O_CREAT) != 0 } -fn resolve_cmd(cmd: &str) -> PathBuf { - let p = if cmd.contains('/') { - PathBuf::from(cmd) - } else { - std::env::var("PATH") - .unwrap_or_default() - .split(':') - .map(|dir| PathBuf::from(dir).join(cmd)) - .find(|p| p.exists()) - .unwrap_or_else(|| PathBuf::from(cmd)) - }; - std::fs::canonicalize(&p).unwrap_or(p) -} - /// Read the ELF PT_INTERP segment of a binary and return the interpreter path. -/// Returns `None` for statically linked binaries or non-ELF files. +/// Returns `None` for statically linked binaries, non-ELF files, or ELF32 binaries. fn elf_interpreter(binary: &std::path::Path) -> Option { let data = std::fs::read(binary).ok()?; // ELF magic: 0x7f 'E' 'L' 'F' @@ -95,8 +75,12 @@ pub async fn run(args: LearnArgs) -> Result<()> { let cmd_refs: Vec<&str> = args.cmd.iter().map(String::as_str).collect(); // Fully permissive Landlock so nothing is blocked during observation. - // Any denial here would make the trace incomplete (workload crashes before - // reaching other files it needs). See issue #72 open question #1. + // 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 reads: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); let writes: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); let connects: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); @@ -104,7 +88,7 @@ pub async fn run(args: LearnArgs) -> Result<()> { let (reads_c, writes_c, connects_c) = (Arc::clone(&reads), Arc::clone(&writes), Arc::clone(&connects)); let policy = Sandbox::builder() .fs_read("/") - .fs_write("/tmp") + .workdir(cow_dir.path()) .on_file_access(move |path, flags| { if is_write_open(flags) { writes_c.lock().unwrap().insert(path.to_path_buf()); @@ -128,32 +112,57 @@ pub async fn run(args: LearnArgs) -> Result<()> { eprintln!("sandlock learn: done (exit={:?})", result.code()); - // The executed binary and its dynamic linker are loaded by the kernel during - // execve — they never appear in the audit hook trace. Add them explicitly. - let binary = resolve_cmd(&args.cmd[0]); - if let Some(interp) = elf_interpreter(&binary) { - reads.lock().unwrap().insert(interp); + // The dynamic linker is loaded entirely in kernel space + // during execve — no userspace syscall fires. Find the binary in the captured + // reads (by basename match) and parse its ELF PT_INTERP to add the linker. + let cmd_basename = std::path::Path::new(&args.cmd[0]).file_name(); + let candidates: Vec = reads.lock().unwrap().iter() + .filter(|p| p.file_name() == cmd_basename) + .cloned() + .collect(); + for bin in candidates.iter().filter(|p| p.exists()) { + if let Some(interp) = elf_interpreter(bin) { + reads.lock().unwrap().insert(interp); + break; + } } - reads.lock().unwrap().insert(binary); - // Build the profile using the proper struct — same schema `sandlock run -p` reads. + // Build the profile. let mut profile_out = ProfileInput::default(); + let cow_path = cow_dir.path().to_path_buf(); profile_out.filesystem = FilesystemSection { - read: reads.lock().unwrap().iter().filter(|p| p.exists()).cloned().collect(), - write: writes.lock().unwrap().iter().filter(|p| p.exists()).cloned().collect(), + // Filter reads by existence to drop failed PATH-probe openats. + read: 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: 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 = connects.lock().unwrap().iter().cloned().collect(); + let cmd_comment = cmd_str.replace('\n', " "); let header = format!( "# generated by sandlock learn\n\ - # command: {cmd_str}\n\ + # command: {cmd_comment}\n\ # note: raw observation — no path collapsing applied\n\ # every file is listed individually (see issue #72 for collapsing design)\n\n" ); let body = profile_out.to_toml() .map_err(|e| anyhow!("failed to serialize profile: {e}"))?; - let body = strip_empty_sections(&body); let toml_out = format!("{header}{body}"); match args.output { @@ -168,40 +177,3 @@ pub async fn run(args: LearnArgs) -> Result<()> { Ok(()) } -/// Strip TOML sections that contain only default/empty values. -/// `toml::to_string` emits every field including defaults — this keeps -/// the profile minimal and readable. -fn strip_empty_sections(toml: &str) -> String { - let mut out = String::new(); - let mut section: Vec<&str> = Vec::new(); - let mut in_section = false; - - for line in toml.lines() { - if line.starts_with('[') { - if in_section && section_has_content(§ion) { - for l in §ion { out.push_str(l); out.push('\n'); } - } - section = vec![line]; - in_section = true; - } else if in_section { - section.push(line); - } else { - out.push_str(line); out.push('\n'); - } - } - if in_section && section_has_content(§ion) { - for l in §ion { out.push_str(l); out.push('\n'); } - } - out -} - -fn section_has_content(lines: &[&str]) -> bool { - lines.iter().skip(1).any(|l| { - let v = l.trim(); - !v.is_empty() - && !v.ends_with("= []") - && !v.ends_with("= {}") - && !v.ends_with("= false") - && v != "[]" - }) -} From 8564a378487bbc2797a9d1573a693c0a17e323b5 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 23 Jun 2026 10:23:00 +0400 Subject: [PATCH 07/28] tests: add learn round-trip tests for fs read, write, and network --- crates/sandlock-cli/tests/cli_test.rs | 118 ++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index ab99ca54..27ac7678 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -301,6 +301,38 @@ fn test_learn_captures_fs_read() { ); } +/// 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] @@ -330,6 +362,67 @@ fn test_learn_captures_fs_write() { ); } +/// 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] @@ -364,6 +457,31 @@ fn test_learn_captures_net_connect() { ); } +/// 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; From cb3aebc1998b984eafc1aef943f3ebfef49731c2 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 23 Jun 2026 10:58:40 +0400 Subject: [PATCH 08/28] core: fix on_net_connect doc, shorten comment --- crates/sandlock-core/src/sandbox/builder.rs | 2 +- crates/sandlock-core/src/seccomp_plan.rs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 0bee0c0a..23022152 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -630,7 +630,7 @@ impl SandboxBuilder { self } - /// Register an audit callback that fires for every `connect`/`sendto` syscall + /// Register an audit callback that fires for every `connect`/`sendto`/`sendmsg` syscall /// before any internal handler runs. Receives the destination IP and port. pub fn on_net_connect(mut self, f: impl Fn(std::net::IpAddr, u16) + Send + Sync + 'static) -> Self { self.on_net_connect = Some(Arc::new(f)); diff --git a/crates/sandlock-core/src/seccomp_plan.rs b/crates/sandlock-core/src/seccomp_plan.rs index 0c57be65..7976146f 100644 --- a/crates/sandlock-core/src/seccomp_plan.rs +++ b/crates/sandlock-core/src/seccomp_plan.rs @@ -358,8 +358,7 @@ pub(crate) fn notif_syscalls_resolved(resolved: &ResolvedSandbox) -> Vec { nrs.extend(POLICY_EVENT_SYSCALLS); } - // Audit file-access hook: needs execve/execveat in notif so the hook fires - // for the executed binary (openat is already covered by procfs/hosts notif). + // Audit file-access hook: needs execve/execveat in notif so the hook fires for the executed binary. if features.audit_file_access { nrs.push(libc::SYS_execve); nrs.push(libc::SYS_execveat); From 7321099cc221c3c11c5d6d77d837880eeb1e029d Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 23 Jun 2026 10:58:52 +0400 Subject: [PATCH 09/28] cli: fix learn header, remove minimal from subcommand description --- crates/sandlock-cli/src/learn.rs | 10 ++++------ crates/sandlock-cli/src/main.rs | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index be068334..0bdcf9d8 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -113,7 +113,7 @@ pub async fn run(args: LearnArgs) -> Result<()> { eprintln!("sandlock learn: done (exit={:?})", result.code()); // The dynamic linker is loaded entirely in kernel space - // during execve — no userspace syscall fires. Find the binary in the captured + // during execve, no userspace syscall fires. Find the binary in the captured // reads (by basename match) and parse its ELF PT_INTERP to add the linker. let cmd_basename = std::path::Path::new(&args.cmd[0]).file_name(); let candidates: Vec = reads.lock().unwrap().iter() @@ -138,7 +138,7 @@ pub async fn run(args: LearnArgs) -> Result<()> { .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 + // directory instead, Landlock requires the path to exist, and the program needs // write access to the directory to create new files inside it. write: writes.lock().unwrap().iter() .filter(|p| !p.starts_with(&cow_path)) @@ -154,12 +154,10 @@ pub async fn run(args: LearnArgs) -> Result<()> { }; profile_out.network.allow = connects.lock().unwrap().iter().cloned().collect(); - let cmd_comment = cmd_str.replace('\n', " "); let header = format!( "# generated by sandlock learn\n\ - # command: {cmd_comment}\n\ - # note: raw observation — no path collapsing applied\n\ - # every file is listed individually (see issue #72 for collapsing design)\n\n" + # command: {}\n\n", + cmd_str.replace('\n', " ") ); let body = profile_out.to_toml() .map_err(|e| anyhow!("failed to serialize profile: {e}"))?; diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 3e529ef7..eec81fc6 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -34,7 +34,7 @@ enum Command { #[command(subcommand)] action: ProfileAction, }, - /// Observe a workload and emit a minimal sandlock profile + /// Observe a workload and emit a sandlock profile Learn(LearnArgs), } From 16db5d548885a08f92b6e5ccfc8b0906e659b56a Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 23 Jun 2026 11:21:13 +0400 Subject: [PATCH 10/28] core: fix missing audit fields in NotifPolicy test fixtures --- crates/sandlock-core/src/resource.rs | 2 ++ crates/sandlock-core/src/seccomp/dispatch.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/sandlock-core/src/resource.rs b/crates/sandlock-core/src/resource.rs index 7bdaeb95..e61bcdde 100644 --- a/crates/sandlock-core/src/resource.rs +++ b/crates/sandlock-core/src/resource.rs @@ -731,6 +731,8 @@ mod tests { virtual_etc_hosts: String::new(), ca_inject_paths: Vec::new(), ca_inject_pem: None, + audit_file_access: None, + audit_net_connect: None, } } diff --git a/crates/sandlock-core/src/seccomp/dispatch.rs b/crates/sandlock-core/src/seccomp/dispatch.rs index 9181b01f..0259e520 100644 --- a/crates/sandlock-core/src/seccomp/dispatch.rs +++ b/crates/sandlock-core/src/seccomp/dispatch.rs @@ -1141,6 +1141,8 @@ mod handler_tests { virtual_etc_hosts: String::new(), ca_inject_paths: Vec::new(), ca_inject_pem: None, + audit_file_access: None, + audit_net_connect: None, }), child_pidfd: None, notif_fd: -1, From e6f62684aa364902e5a4d5faef00c633d194b950 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Fri, 26 Jun 2026 08:21:59 +0400 Subject: [PATCH 11/28] core: add on_execve audit hook for execve/execveat --- crates/sandlock-core/src/resolved.rs | 2 ++ crates/sandlock-core/src/resource.rs | 1 + crates/sandlock-core/src/sandbox.rs | 6 ++++ crates/sandlock-core/src/sandbox/builder.rs | 21 ++++++++++--- crates/sandlock-core/src/seccomp/dispatch.rs | 1 + crates/sandlock-core/src/seccomp/notif.rs | 32 ++++++++++++-------- crates/sandlock-core/src/seccomp_plan.rs | 5 +-- 7 files changed, 49 insertions(+), 19 deletions(-) diff --git a/crates/sandlock-core/src/resolved.rs b/crates/sandlock-core/src/resolved.rs index 48d8e8c6..2f2f3062 100644 --- a/crates/sandlock-core/src/resolved.rs +++ b/crates/sandlock-core/src/resolved.rs @@ -43,6 +43,7 @@ pub(crate) struct SandboxFeatures { pub(crate) fs_denies: bool, pub(crate) policy_fn: bool, pub(crate) audit_file_access: bool, + pub(crate) audit_execve: bool, pub(crate) port_remap: bool, pub(crate) http_acl: bool, pub(crate) argv_safety_required: bool, @@ -82,6 +83,7 @@ impl SandboxFeatures { fs_denies: !sandbox.fs_denied.is_empty(), policy_fn: sandbox.policy_fn.is_some(), audit_file_access: sandbox.on_file_access.is_some(), + audit_execve: sandbox.on_execve.is_some(), port_remap: sandbox.port_remap, http_acl, argv_safety_required: sandbox.policy_fn.is_some() || exec_handler, diff --git a/crates/sandlock-core/src/resource.rs b/crates/sandlock-core/src/resource.rs index e61bcdde..2f836d68 100644 --- a/crates/sandlock-core/src/resource.rs +++ b/crates/sandlock-core/src/resource.rs @@ -732,6 +732,7 @@ mod tests { ca_inject_paths: Vec::new(), ca_inject_pem: None, audit_file_access: None, + audit_execve: None, audit_net_connect: None, } } diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 018f16f8..babb8a1f 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -439,6 +439,10 @@ pub struct Sandbox { #[serde(skip)] pub(crate) on_file_access: Option>, + // Audit callback for execve/execveat syscalls; fires before internal handlers. + #[serde(skip)] + pub(crate) on_execve: Option>, + // Audit callback for network connect/sendto syscalls; fires before internal handlers. #[serde(skip)] on_net_connect: Option>, @@ -529,6 +533,7 @@ impl Clone for Sandbox { work_fn: self.work_fn.clone(), // on_file_access is Arc-wrapped — clone bumps the reference count. on_file_access: self.on_file_access.clone(), + on_execve: self.on_execve.clone(), on_net_connect: self.on_net_connect.clone(), // Runtime is NOT cloned — the clone starts with no runtime. runtime: None, @@ -1722,6 +1727,7 @@ impl Sandbox { ca_inject_paths: self.http_inject_ca.clone(), ca_inject_pem: ca_inject_pem.clone(), audit_file_access: self.on_file_access.clone(), + audit_execve: self.on_execve.clone(), audit_net_connect: self.on_net_connect.clone(), }; diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 23022152..a7f922bf 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -202,6 +202,10 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", clap(skip))] pub(crate) on_file_access: Option>, + // Audit callback for execve/execveat syscalls. + #[cfg_attr(feature = "cli", clap(skip))] + pub(crate) on_execve: Option>, + // Audit callback for network connect/sendto syscalls. #[cfg_attr(feature = "cli", clap(skip))] pub(crate) on_net_connect: Option>, @@ -278,6 +282,7 @@ impl Clone for SandboxBuilder { work_fn: self.work_fn.clone(), // on_file_access is Arc-wrapped; clone bumps the reference count. on_file_access: self.on_file_access.clone(), + on_execve: self.on_execve.clone(), on_net_connect: self.on_net_connect.clone(), } } @@ -621,15 +626,22 @@ impl SandboxBuilder { self } - /// Register an audit callback that fires for every file-open syscall - /// (`openat`, `open`, `execve`, etc.) before any internal handler runs. - /// Receives the resolved absolute path and the open flags (`O_*`); flags - /// are `0` for execve and other non-open syscalls. + /// Register an audit callback that fires for every `openat`/`open` syscall + /// before any internal handler runs. Receives the resolved absolute path and + /// the open flags (`O_*`). pub fn on_file_access(mut self, f: impl Fn(&std::path::Path, u64) + Send + Sync + 'static) -> Self { self.on_file_access = Some(Arc::new(f)); self } + /// Register an audit callback that fires for every `execve`/`execveat` syscall + /// before any internal handler runs. Receives the resolved absolute path of the + /// binary being executed. + pub fn on_execve(mut self, f: impl Fn(&std::path::Path) + Send + Sync + 'static) -> Self { + self.on_execve = Some(Arc::new(f)); + self + } + /// Register an audit callback that fires for every `connect`/`sendto`/`sendmsg` syscall /// before any internal handler runs. Receives the destination IP and port. pub fn on_net_connect(mut self, f: impl Fn(std::net::IpAddr, u16) + Send + Sync + 'static) -> Self { @@ -809,6 +821,7 @@ impl SandboxBuilder { init_fn: self.init_fn, work_fn: self.work_fn, on_file_access: self.on_file_access, + on_execve: self.on_execve, on_net_connect: self.on_net_connect, runtime: None, }) diff --git a/crates/sandlock-core/src/seccomp/dispatch.rs b/crates/sandlock-core/src/seccomp/dispatch.rs index 0259e520..d793cf33 100644 --- a/crates/sandlock-core/src/seccomp/dispatch.rs +++ b/crates/sandlock-core/src/seccomp/dispatch.rs @@ -1142,6 +1142,7 @@ mod handler_tests { ca_inject_paths: Vec::new(), ca_inject_pem: None, audit_file_access: None, + audit_execve: None, audit_net_connect: None, }), child_pidfd: None, diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index e9bec4d3..251a9743 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -777,11 +777,13 @@ pub struct NotifPolicy { /// Active MITM CA public cert (PEM bytes) to inject. `Some` only when /// HTTPS MITM is active (BYO or generated). pub ca_inject_pem: Option>>, - /// Optional audit hook called for every file-open syscall before dispatch. - /// Receives the resolved absolute path and the open flags (`O_*`); flags - /// are `0` for execve/execveat and other non-open syscalls. + /// Optional audit hook called for every `openat`/`open` syscall before dispatch. + /// Receives the resolved absolute path and the open flags (`O_*`). pub audit_file_access: Option>, - /// Optional audit hook called for every `connect`/`sendto` syscall before + /// Optional audit hook called for every `execve`/`execveat` syscall before dispatch. + /// Receives the resolved absolute path of the binary being executed. + pub audit_execve: Option>, + /// Optional audit hook called for every `connect`/`sendto`/`sendmsg` syscall before /// dispatch. Receives the destination IP and port. pub audit_net_connect: Option>, } @@ -1697,28 +1699,32 @@ async fn handle_notification( maybe_patch_vdso(notif.pid as i32, &mut pfs, policy); } - // Audit hook — fires before internal handlers so it sees every file open - // regardless of how the dispatch chain handles it. + // File-open audit hook — fires for openat/open before internal handlers. if let Some(ref hook) = policy.audit_file_access { let nr = notif.data.nr as i64; - let is_open = nr == libc::SYS_openat - || Some(nr) == arch::sys_open() - || nr == libc::SYS_execve - || nr == libc::SYS_execveat; + let is_open = nr == libc::SYS_openat || Some(nr) == arch::sys_open(); if is_open { if let Some(path) = resolve_path_for_notif(¬if, fd) { let flags = if nr == libc::SYS_openat { notif.data.args[2] - } else if Some(nr) == arch::sys_open() { - notif.data.args[1] } else { - 0 // execve/execveat — no open flags + notif.data.args[1] }; hook(std::path::Path::new(&path), flags); } } } + // Execve audit hook — fires for execve/execveat before internal handlers. + if let Some(ref hook) = policy.audit_execve { + let nr = notif.data.nr as i64; + if nr == libc::SYS_execve || nr == libc::SYS_execveat { + if let Some(path) = resolve_path_for_notif(¬if, fd) { + hook(std::path::Path::new(&path)); + } + } + } + // Network connect audit hook — fires before internal handlers. if let Some(ref hook) = policy.audit_net_connect { let nr = notif.data.nr as i64; diff --git a/crates/sandlock-core/src/seccomp_plan.rs b/crates/sandlock-core/src/seccomp_plan.rs index 7976146f..a761901c 100644 --- a/crates/sandlock-core/src/seccomp_plan.rs +++ b/crates/sandlock-core/src/seccomp_plan.rs @@ -358,11 +358,12 @@ pub(crate) fn notif_syscalls_resolved(resolved: &ResolvedSandbox) -> Vec { nrs.extend(POLICY_EVENT_SYSCALLS); } - // Audit file-access hook: needs execve/execveat in notif so the hook fires for the executed binary. if features.audit_file_access { + nrs.push_optional(arch::sys_open()); + } + if features.audit_execve || features.audit_file_access { nrs.push(libc::SYS_execve); nrs.push(libc::SYS_execveat); - nrs.push_optional(arch::sys_open()); } // Port remapping From a342bbf9cd420aab5d1c9eea8a3abb8de78d2c01 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Fri, 26 Jun 2026 08:22:16 +0400 Subject: [PATCH 12/28] learn: use on_execve hook for binary capture --- crates/sandlock-cli/src/learn.rs | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 0bdcf9d8..2ef738f2 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -83,9 +83,13 @@ pub async fn run(args: LearnArgs) -> Result<()> { let reads: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); let writes: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); + let execs: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); let connects: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); - let (reads_c, writes_c, connects_c) = (Arc::clone(&reads), Arc::clone(&writes), Arc::clone(&connects)); + let (reads_c, writes_c, execs_c, connects_c) = ( + Arc::clone(&reads), Arc::clone(&writes), + Arc::clone(&execs), Arc::clone(&connects), + ); let policy = Sandbox::builder() .fs_read("/") .workdir(cow_dir.path()) @@ -96,6 +100,9 @@ pub async fn run(args: LearnArgs) -> Result<()> { reads_c.lock().unwrap().insert(path.to_path_buf()); } }) + .on_execve(move |path| { + execs_c.lock().unwrap().insert(path.to_path_buf()); + }) .on_net_connect(move |ip, port| { connects_c.lock().unwrap().insert(format!("tcp://{ip}:{port}")); }) @@ -112,18 +119,12 @@ pub async fn run(args: LearnArgs) -> Result<()> { eprintln!("sandlock learn: done (exit={:?})", result.code()); - // The dynamic linker is loaded entirely in kernel space - // during execve, no userspace syscall fires. Find the binary in the captured - // reads (by basename match) and parse its ELF PT_INTERP to add the linker. - let cmd_basename = std::path::Path::new(&args.cmd[0]).file_name(); - let candidates: Vec = reads.lock().unwrap().iter() - .filter(|p| p.file_name() == cmd_basename) - .cloned() - .collect(); - for bin in candidates.iter().filter(|p| p.exists()) { + // The dynamic linker is loaded entirely in kernel space during execve, + // no userspace syscall fires for it. Use ELF PT_INTERP as a workaround + // until a /proc//maps-based approach is implemented. + for bin in execs.lock().unwrap().iter() { if let Some(interp) = elf_interpreter(bin) { reads.lock().unwrap().insert(interp); - break; } } @@ -132,9 +133,13 @@ pub async fn run(args: LearnArgs) -> Result<()> { 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: reads.lock().unwrap().iter() + .chain(execs.lock().unwrap().iter()) .filter(|p| p.exists() && !p.starts_with(&cow_path)) .cloned() + .collect::>() + .into_iter() .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 From 319deb1e003fbab7fbb56a19d166144f0c05152f Mon Sep 17 00:00:00 2001 From: Vahagn Date: Fri, 26 Jun 2026 11:18:04 +0400 Subject: [PATCH 13/28] core: read dynamic linker from /proc/pid/maps after execve --- crates/sandlock-core/src/seccomp/notif.rs | 50 +++++++++++++++++++++++ crates/sandlock-core/src/seccomp/state.rs | 3 ++ 2 files changed, 53 insertions(+) diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index 251a9743..a972ea18 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -1236,6 +1236,34 @@ 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). +fn read_linker_from_maps(pid: i32) -> Option { + use std::io::BufRead; + let file = std::fs::File::open(format!("/proc/{}/maps", pid)).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 +} + // ============================================================ // vDSO re-patching after exec // ============================================================ @@ -1699,6 +1727,23 @@ async fn handle_notification( maybe_patch_vdso(notif.pid as i32, &mut pfs, policy); } + // If the previous notification from this pid was an execve, exec has now + // completed and /proc//maps reflects the new image. Read the dynamic + // linker path from maps and fire the audit_execve hook for it — the linker + // is loaded by the kernel in kernel space so no openat ever fires for it. + if let Some(ref hook) = policy.audit_execve { + if let Some((_, state)) = ctx.processes.entry_for(notif.pid as i32) { + let mut st = state.lock().await; + if st.pending_exec_maps_read { + st.pending_exec_maps_read = false; + drop(st); + if let Some(linker) = read_linker_from_maps(notif.pid as i32) { + hook(&linker); + } + } + } + } + // File-open audit hook — fires for openat/open before internal handlers. if let Some(ref hook) = policy.audit_file_access { let nr = notif.data.nr as i64; @@ -1716,11 +1761,16 @@ async fn handle_notification( } // Execve audit hook — fires for execve/execveat before internal handlers. + // Also sets pending_exec_maps_read so the dynamic linker is captured on + // the next notification, after exec completes and maps reflects the new image. if let Some(ref hook) = policy.audit_execve { let nr = notif.data.nr as i64; if nr == libc::SYS_execve || nr == libc::SYS_execveat { if let Some(path) = resolve_path_for_notif(¬if, fd) { hook(std::path::Path::new(&path)); + if let Some((_, state)) = ctx.processes.entry_for(notif.pid as i32) { + state.lock().await.pending_exec_maps_read = true; + } } } } diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index a409867f..bda9d008 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -114,6 +114,9 @@ pub struct PerProcessState { /// /proc directory dirent cache. Keyed by (child fd, target /// path); same drain-on-EOF semantics as cow_dir_cache. pub procfs_dir_cache: HashMap<(u32, String), Vec>>, + /// Set when the process called execve; cleared on the next notification + /// after exec completes, when /proc//maps reflects the new image. + pub pending_exec_maps_read: bool, } // ============================================================ From e14ce29b96e864ac2246b5cb19515690a5ebb716 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Fri, 26 Jun 2026 11:18:15 +0400 Subject: [PATCH 14/28] learn: remove ELF PT_INTERP parser, linker now captured via maps --- crates/sandlock-cli/src/learn.rs | 52 -------------------------------- 1 file changed, 52 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 2ef738f2..23c59bdd 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -22,49 +22,6 @@ fn is_write_open(flags: u64) -> bool { flags & (O_WRONLY | O_RDWR | O_CREAT) != 0 } -/// Read the ELF PT_INTERP segment of a binary and return the interpreter path. -/// Returns `None` for statically linked binaries, non-ELF files, or ELF32 binaries. -fn elf_interpreter(binary: &std::path::Path) -> Option { - let data = std::fs::read(binary).ok()?; - // ELF magic: 0x7f 'E' 'L' 'F' - if data.get(..4) != Some(b"\x7fELF") { - return None; - } - // ELF64 only: class byte at offset 4 must be 2. - if data.get(4).copied() != Some(2) { - return None; - } - // Endianness byte at offset 5: 1 = little, 2 = big. - let le = data.get(5).copied()? == 1; - let read_u16 = |off: usize| -> Option { - let b = data.get(off..off + 2)?; - Some(if le { u16::from_le_bytes(b.try_into().ok()?) } else { u16::from_be_bytes(b.try_into().ok()?) }) - }; - let read_u64 = |off: usize| -> Option { - let b = data.get(off..off + 8)?; - Some(if le { u64::from_le_bytes(b.try_into().ok()?) } else { u64::from_be_bytes(b.try_into().ok()?) }) - }; - // ELF64 header: phoff at 0x20, phentsize at 0x36, phnum at 0x38. - let phoff = read_u64(0x20)? as usize; - let phentsize = read_u16(0x36)? as usize; - let phnum = read_u16(0x38)? as usize; - // PT_INTERP = 3 - for i in 0..phnum { - let ph = phoff + i * phentsize; - let p_type = data.get(ph..ph + 4)?; - let p_type = if le { u32::from_le_bytes(p_type.try_into().ok()?) } else { u32::from_be_bytes(p_type.try_into().ok()?) }; - if p_type == 3 { - // p_offset at ph+8, p_filesz at ph+32 in ELF64 - let offset = read_u64(ph + 8)? as usize; - let filesz = read_u64(ph + 32)? as usize; - let interp = data.get(offset..offset + filesz)?; - // Strip trailing null byte - let interp = interp.split(|&b| b == 0).next()?; - return Some(PathBuf::from(std::str::from_utf8(interp).ok()?)); - } - } - None -} pub async fn run(args: LearnArgs) -> Result<()> { if args.cmd.is_empty() { @@ -119,15 +76,6 @@ pub async fn run(args: LearnArgs) -> Result<()> { eprintln!("sandlock learn: done (exit={:?})", result.code()); - // The dynamic linker is loaded entirely in kernel space during execve, - // no userspace syscall fires for it. Use ELF PT_INTERP as a workaround - // until a /proc//maps-based approach is implemented. - for bin in execs.lock().unwrap().iter() { - if let Some(interp) = elf_interpreter(bin) { - reads.lock().unwrap().insert(interp); - } - } - // Build the profile. let mut profile_out = ProfileInput::default(); let cow_path = cow_dir.path().to_path_buf(); From 9983f75e08f37ae3cfd28e1db52f6a820d618f0d Mon Sep 17 00:00:00 2001 From: Vahagn Date: Fri, 3 Jul 2026 10:15:13 +0400 Subject: [PATCH 15/28] profile: skip default fields/sections in TOML serialization --- crates/sandlock-core/src/profile.rs | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index dae31d58..1640abe4 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -17,64 +17,101 @@ pub struct ProgramSpec { #[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, 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, 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, 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, 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, } @@ -92,25 +129,35 @@ pub enum PortSpec { #[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, 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, 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, } @@ -122,16 +169,24 @@ pub struct SyscallsSection { 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, } From 050de2dd8a9e5f9b720fdafaa3a8ff330a833896 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Fri, 3 Jul 2026 10:15:27 +0400 Subject: [PATCH 16/28] learn: resource peaks via create/start/wait, populate [program].exec --- crates/sandlock-cli/src/learn.rs | 73 ++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 23c59bdd..45353c24 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -6,6 +6,7 @@ use std::collections::BTreeSet; use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::{anyhow, Result}; use sandlock_core::profile::{FilesystemSection, ProfileInput}; @@ -68,16 +69,67 @@ pub async fn run(args: LearnArgs) -> Result<()> { eprintln!("sandlock learn: observing {cmd_str} ..."); - let result = policy - .with_name("sandlock-learn") - .run(&cmd_refs) - .await + // 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. @@ -107,6 +159,19 @@ pub async fn run(args: LearnArgs) -> Result<()> { }; profile_out.network.allow = 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", From 7ded9fa4e3f093a26d914d7e303caf62cbeda45a Mon Sep 17 00:00:00 2001 From: Vahagn Date: Fri, 3 Jul 2026 10:15:41 +0400 Subject: [PATCH 17/28] tests: assert resource limits populated by sandlock learn --- crates/sandlock-cli/tests/cli_test.rs | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index 27ac7678..26f8d4d8 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -512,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). From 3456857259ff94e99ce9994e16d0edaab42015bd Mon Sep 17 00:00:00 2001 From: Vahagn Date: Sun, 5 Jul 2026 12:09:26 +0400 Subject: [PATCH 18/28] learn: replace on_file_access/on_execve/on_net_connect hooks with policy_fn, expose path/flags in SyscallEvent --- crates/sandlock-cli/src/learn.rs | 127 +++++++++++++----- crates/sandlock-core/src/policy_fn.rs | 12 ++ crates/sandlock-core/src/resolved.rs | 4 - crates/sandlock-core/src/resource.rs | 3 - crates/sandlock-core/src/sandbox.rs | 19 --- crates/sandlock-core/src/sandbox/builder.rs | 41 ------ crates/sandlock-core/src/seccomp/dispatch.rs | 3 - crates/sandlock-core/src/seccomp/notif.rs | 129 +++---------------- crates/sandlock-core/src/seccomp/state.rs | 3 - crates/sandlock-core/src/seccomp_plan.rs | 9 +- 10 files changed, 131 insertions(+), 219 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 45353c24..9b78a7cd 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -3,12 +3,13 @@ //! Runs a workload under observation and emits a sandlock profile TOML //! usable by `sandlock run -p`. -use std::collections::BTreeSet; +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; @@ -23,6 +24,93 @@ 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() { @@ -39,31 +127,12 @@ pub async fn run(args: LearnArgs) -> Result<()> { .tempdir_in("/var/tmp") .map_err(|e| anyhow!("failed to create COW tempdir: {e}"))?; - let reads: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); - let writes: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); - let execs: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); - let connects: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); - - let (reads_c, writes_c, execs_c, connects_c) = ( - Arc::clone(&reads), Arc::clone(&writes), - Arc::clone(&execs), Arc::clone(&connects), - ); + let observer = LearnObserver::new(); + let observer_cb = observer.clone(); let policy = Sandbox::builder() .fs_read("/") .workdir(cow_dir.path()) - .on_file_access(move |path, flags| { - if is_write_open(flags) { - writes_c.lock().unwrap().insert(path.to_path_buf()); - } else { - reads_c.lock().unwrap().insert(path.to_path_buf()); - } - }) - .on_execve(move |path| { - execs_c.lock().unwrap().insert(path.to_path_buf()); - }) - .on_net_connect(move |ip, port| { - connects_c.lock().unwrap().insert(format!("tcp://{ip}:{port}")); - }) + .policy_fn(move |event, _ctx| observer_cb.on_event(event)) .build() .map_err(|e| anyhow!("failed to build sandbox policy: {e}"))?; @@ -133,19 +202,16 @@ pub async fn run(args: LearnArgs) -> Result<()> { 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: reads.lock().unwrap().iter() - .chain(execs.lock().unwrap().iter()) + // Executed binaries are merged into read. + read: observer.reads.lock().unwrap().iter() .filter(|p| p.exists() && !p.starts_with(&cow_path)) .cloned() - .collect::>() - .into_iter() .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: writes.lock().unwrap().iter() + write: observer.writes.lock().unwrap().iter() .filter(|p| !p.starts_with(&cow_path)) .filter_map(|p| { if p.exists() { @@ -157,7 +223,7 @@ pub async fn run(args: LearnArgs) -> Result<()> { .collect(), ..Default::default() }; - profile_out.network.allow = connects.lock().unwrap().iter().cloned().collect(); + 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 { @@ -192,4 +258,3 @@ pub async fn run(args: LearnArgs) -> Result<()> { Ok(()) } - diff --git a/crates/sandlock-core/src/policy_fn.rs b/crates/sandlock-core/src/policy_fn.rs index 2b96f0bf..eb7d6ab3 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/resolved.rs b/crates/sandlock-core/src/resolved.rs index 2f2f3062..78d0c08b 100644 --- a/crates/sandlock-core/src/resolved.rs +++ b/crates/sandlock-core/src/resolved.rs @@ -42,8 +42,6 @@ pub(crate) struct SandboxFeatures { pub(crate) chroot: bool, pub(crate) fs_denies: bool, pub(crate) policy_fn: bool, - pub(crate) audit_file_access: bool, - pub(crate) audit_execve: bool, pub(crate) port_remap: bool, pub(crate) http_acl: bool, pub(crate) argv_safety_required: bool, @@ -82,8 +80,6 @@ impl SandboxFeatures { chroot: sandbox.chroot.is_some(), fs_denies: !sandbox.fs_denied.is_empty(), policy_fn: sandbox.policy_fn.is_some(), - audit_file_access: sandbox.on_file_access.is_some(), - audit_execve: sandbox.on_execve.is_some(), port_remap: sandbox.port_remap, http_acl, argv_safety_required: sandbox.policy_fn.is_some() || exec_handler, diff --git a/crates/sandlock-core/src/resource.rs b/crates/sandlock-core/src/resource.rs index 2f836d68..7bdaeb95 100644 --- a/crates/sandlock-core/src/resource.rs +++ b/crates/sandlock-core/src/resource.rs @@ -731,9 +731,6 @@ mod tests { virtual_etc_hosts: String::new(), ca_inject_paths: Vec::new(), ca_inject_pem: None, - audit_file_access: None, - audit_execve: None, - audit_net_connect: None, } } diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index babb8a1f..ca99f9f5 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -435,18 +435,6 @@ pub struct Sandbox { #[serde(skip)] work_fn: Option>, - // Audit callback for file-open syscalls; fires before internal handlers. - #[serde(skip)] - pub(crate) on_file_access: Option>, - - // Audit callback for execve/execveat syscalls; fires before internal handlers. - #[serde(skip)] - pub(crate) on_execve: Option>, - - // Audit callback for network connect/sendto syscalls; fires before internal handlers. - #[serde(skip)] - on_net_connect: Option>, - // Heap-allocated runtime state; `None` when not started. #[serde(skip)] runtime: Option>, @@ -531,10 +519,6 @@ impl Clone for Sandbox { init_fn: None, // work_fn is Arc-wrapped — clone bumps the reference count. work_fn: self.work_fn.clone(), - // on_file_access is Arc-wrapped — clone bumps the reference count. - on_file_access: self.on_file_access.clone(), - on_execve: self.on_execve.clone(), - on_net_connect: self.on_net_connect.clone(), // Runtime is NOT cloned — the clone starts with no runtime. runtime: None, } @@ -1726,9 +1710,6 @@ impl Sandbox { virtual_etc_hosts, ca_inject_paths: self.http_inject_ca.clone(), ca_inject_pem: ca_inject_pem.clone(), - audit_file_access: self.on_file_access.clone(), - audit_execve: self.on_execve.clone(), - audit_net_connect: self.on_net_connect.clone(), }; use rand::SeedableRng; diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index a7f922bf..c0966d02 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -198,17 +198,6 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", clap(skip))] pub(crate) work_fn: Option>, - // Audit callback for file-open syscalls. - #[cfg_attr(feature = "cli", clap(skip))] - pub(crate) on_file_access: Option>, - - // Audit callback for execve/execveat syscalls. - #[cfg_attr(feature = "cli", clap(skip))] - pub(crate) on_execve: Option>, - - // Audit callback for network connect/sendto syscalls. - #[cfg_attr(feature = "cli", clap(skip))] - pub(crate) on_net_connect: Option>, } impl std::fmt::Debug for SandboxBuilder { @@ -280,10 +269,6 @@ impl Clone for SandboxBuilder { init_fn: None, // work_fn is Arc-wrapped; clone bumps the reference count. work_fn: self.work_fn.clone(), - // on_file_access is Arc-wrapped; clone bumps the reference count. - on_file_access: self.on_file_access.clone(), - on_execve: self.on_execve.clone(), - on_net_connect: self.on_net_connect.clone(), } } } @@ -626,29 +611,6 @@ impl SandboxBuilder { self } - /// Register an audit callback that fires for every `openat`/`open` syscall - /// before any internal handler runs. Receives the resolved absolute path and - /// the open flags (`O_*`). - pub fn on_file_access(mut self, f: impl Fn(&std::path::Path, u64) + Send + Sync + 'static) -> Self { - self.on_file_access = Some(Arc::new(f)); - self - } - - /// Register an audit callback that fires for every `execve`/`execveat` syscall - /// before any internal handler runs. Receives the resolved absolute path of the - /// binary being executed. - pub fn on_execve(mut self, f: impl Fn(&std::path::Path) + Send + Sync + 'static) -> Self { - self.on_execve = Some(Arc::new(f)); - self - } - - /// Register an audit callback that fires for every `connect`/`sendto`/`sendmsg` syscall - /// before any internal handler runs. Receives the destination IP and port. - pub fn on_net_connect(mut self, f: impl Fn(std::net::IpAddr, u16) + Send + Sync + 'static) -> Self { - self.on_net_connect = Some(Arc::new(f)); - self - } - /// Build a `Sandbox`, parsing all string fields and running per-field /// validation, but **without** the cross-section checks that /// `Sandbox::validate` performs. Use this in tests that deliberately @@ -820,9 +782,6 @@ impl SandboxBuilder { name: self.name, init_fn: self.init_fn, work_fn: self.work_fn, - on_file_access: self.on_file_access, - on_execve: self.on_execve, - on_net_connect: self.on_net_connect, runtime: None, }) } diff --git a/crates/sandlock-core/src/seccomp/dispatch.rs b/crates/sandlock-core/src/seccomp/dispatch.rs index d793cf33..9181b01f 100644 --- a/crates/sandlock-core/src/seccomp/dispatch.rs +++ b/crates/sandlock-core/src/seccomp/dispatch.rs @@ -1141,9 +1141,6 @@ mod handler_tests { virtual_etc_hosts: String::new(), ca_inject_paths: Vec::new(), ca_inject_pem: None, - audit_file_access: None, - audit_execve: None, - audit_net_connect: None, }), child_pidfd: None, notif_fd: -1, diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index a972ea18..ec508d33 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -777,15 +777,6 @@ pub struct NotifPolicy { /// Active MITM CA public cert (PEM bytes) to inject. `Some` only when /// HTTPS MITM is active (BYO or generated). pub ca_inject_pem: Option>>, - /// Optional audit hook called for every `openat`/`open` syscall before dispatch. - /// Receives the resolved absolute path and the open flags (`O_*`). - pub audit_file_access: Option>, - /// Optional audit hook called for every `execve`/`execveat` syscall before dispatch. - /// Receives the resolved absolute path of the binary being executed. - pub audit_execve: Option>, - /// Optional audit hook called for every `connect`/`sendto`/`sendmsg` syscall before - /// dispatch. Receives the destination IP and port. - pub audit_net_connect: Option>, } impl NotifPolicy { @@ -1243,26 +1234,6 @@ fn send_response(fd: RawFd, id: u64, action: NotifAction) -> io::Result<()> { /// 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). -fn read_linker_from_maps(pid: i32) -> Option { - use std::io::BufRead; - let file = std::fs::File::open(format!("/proc/{}/maps", pid)).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 -} // ============================================================ // vDSO re-patching after exec @@ -1588,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 @@ -1598,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 { @@ -1614,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, @@ -1624,6 +1614,8 @@ async fn emit_policy_event( size, argv, denied, + path, + flags, }; // Hold syscalls where the callback's verdict matters. @@ -1727,85 +1719,6 @@ async fn handle_notification( maybe_patch_vdso(notif.pid as i32, &mut pfs, policy); } - // If the previous notification from this pid was an execve, exec has now - // completed and /proc//maps reflects the new image. Read the dynamic - // linker path from maps and fire the audit_execve hook for it — the linker - // is loaded by the kernel in kernel space so no openat ever fires for it. - if let Some(ref hook) = policy.audit_execve { - if let Some((_, state)) = ctx.processes.entry_for(notif.pid as i32) { - let mut st = state.lock().await; - if st.pending_exec_maps_read { - st.pending_exec_maps_read = false; - drop(st); - if let Some(linker) = read_linker_from_maps(notif.pid as i32) { - hook(&linker); - } - } - } - } - - // File-open audit hook — fires for openat/open before internal handlers. - if let Some(ref hook) = policy.audit_file_access { - let nr = notif.data.nr as i64; - let is_open = nr == libc::SYS_openat || Some(nr) == arch::sys_open(); - if is_open { - if let Some(path) = resolve_path_for_notif(¬if, fd) { - let flags = if nr == libc::SYS_openat { - notif.data.args[2] - } else { - notif.data.args[1] - }; - hook(std::path::Path::new(&path), flags); - } - } - } - - // Execve audit hook — fires for execve/execveat before internal handlers. - // Also sets pending_exec_maps_read so the dynamic linker is captured on - // the next notification, after exec completes and maps reflects the new image. - if let Some(ref hook) = policy.audit_execve { - let nr = notif.data.nr as i64; - if nr == libc::SYS_execve || nr == libc::SYS_execveat { - if let Some(path) = resolve_path_for_notif(¬if, fd) { - hook(std::path::Path::new(&path)); - if let Some((_, state)) = ctx.processes.entry_for(notif.pid as i32) { - state.lock().await.pending_exec_maps_read = true; - } - } - } - } - - // Network connect audit hook — fires before internal handlers. - if let Some(ref hook) = policy.audit_net_connect { - let nr = notif.data.nr as i64; - // Extract (addr_ptr, addr_len) from the syscall args — each syscall lays them out differently. - let sockaddr = if nr == libc::SYS_connect { - // connect(sockfd, addr, addrlen) - Some((notif.data.args[1], notif.data.args[2] as usize)) - } else if nr == libc::SYS_sendto { - // sendto(sockfd, buf, len, flags, addr, addrlen) - Some((notif.data.args[4], notif.data.args[5] as usize)) - } else if nr == libc::SYS_sendmsg { - // sendmsg(sockfd, msghdr*, flags) — addr is msg_name inside the msghdr struct - let msghdr_ptr = notif.data.args[1]; - read_child_mem(fd, notif.id, notif.pid, msghdr_ptr, 16) - .ok() - .filter(|b| b.len() >= 16) - .and_then(|b| { - let msg_name = u64::from_ne_bytes(b[0..8].try_into().ok()?); - let msg_namelen = u32::from_ne_bytes(b[8..12].try_into().ok()?) as usize; - if msg_name != 0 && msg_namelen >= 4 { Some((msg_name, msg_namelen)) } else { None } - }) - } else { - None - }; - if let Some((addr_ptr, addr_len)) = sockaddr { - if let (Some(ip), Some(port)) = read_sockaddr_for_event(¬if, addr_ptr, addr_len, fd) { - hook(ip, port); - } - } - } - // Check dynamic path denials before dispatch let mut action = { let nr = notif.data.nr as i64; diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index bda9d008..a409867f 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -114,9 +114,6 @@ pub struct PerProcessState { /// /proc directory dirent cache. Keyed by (child fd, target /// path); same drain-on-EOF semantics as cow_dir_cache. pub procfs_dir_cache: HashMap<(u32, String), Vec>>, - /// Set when the process called execve; cleared on the next notification - /// after exec completes, when /proc//maps reflects the new image. - pub pending_exec_maps_read: bool, } // ============================================================ diff --git a/crates/sandlock-core/src/seccomp_plan.rs b/crates/sandlock-core/src/seccomp_plan.rs index a761901c..6ea51946 100644 --- a/crates/sandlock-core/src/seccomp_plan.rs +++ b/crates/sandlock-core/src/seccomp_plan.rs @@ -354,17 +354,12 @@ 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); - } - - if features.audit_file_access { nrs.push_optional(arch::sys_open()); } - if features.audit_execve || features.audit_file_access { - nrs.push(libc::SYS_execve); - nrs.push(libc::SYS_execveat); - } // Port remapping if features.port_remap { From 7c0f372536dac3282f3199332dd13fa7609a2651 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Sun, 5 Jul 2026 12:49:43 +0400 Subject: [PATCH 19/28] core: remove stale maps-reading section comment from notif.rs --- crates/sandlock-core/src/seccomp/notif.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index ec508d33..3574b090 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -1227,14 +1227,6 @@ 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 // ============================================================ From e40fe62ba664c91141657c0d587b50621bebf15c Mon Sep 17 00:00:00 2001 From: Vahagn Date: Sun, 5 Jul 2026 12:49:52 +0400 Subject: [PATCH 20/28] learn: add kernel version and timestamp to profile header --- crates/sandlock-cli/src/learn.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 9b78a7cd..9ea1343d 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -238,9 +238,21 @@ pub async fn run(args: LearnArgs) -> Result<()> { profile_out.limits.open_files = Some((fds * 2).max(32) as u32); } + let kernel = std::fs::read_to_string("/proc/version") + .unwrap_or_default() + .split_whitespace() + .nth(2) + .unwrap_or("unknown") + .to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_default(); let header = format!( "# generated by sandlock learn\n\ - # command: {}\n\n", + # command: {}\n\ + # kernel: {kernel}\n\ + # timestamp: {timestamp}\n\n", cmd_str.replace('\n', " ") ); let body = profile_out.to_toml() From cb35f515cc9b6213a3a5e6a52007b32b23834ce2 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Sat, 11 Jul 2026 19:18:44 +0400 Subject: [PATCH 21/28] fix sendto sockaddr args, add sendmsg extraction, distinguish UDP/TCP in profiles --- crates/sandlock-cli/src/learn.rs | 12 ++++- crates/sandlock-cli/tests/cli_test.rs | 57 +++++++++++++++++++++++ crates/sandlock-core/src/seccomp/notif.rs | 30 ++++++++++-- 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 9ea1343d..3b02edbd 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -100,11 +100,18 @@ impl LearnObserver { // Mark PID: read maps on next event after execve completes. self.pending_maps.lock().unwrap().insert(event.pid); } - "connect" | "sendto" => { + // Simplified: connect is assumed TCP, sendto/sendmsg UDP. + // Ideally we'd check SO_PROTOCOL on the socket fd (need emit_policy_event to expose that info in SyscallEvent) + "connect" => { if let (Some(ip), Some(port)) = (event.host, event.port) { self.connects.lock().unwrap().insert(format!("tcp://{ip}:{port}")); } } + "sendto" | "sendmsg" | "sendmmsg" => { + if let (Some(ip), Some(port)) = (event.host, event.port) { + self.connects.lock().unwrap().insert(format!("udp://{ip}:{port}")); + } + } _ => {} } Verdict::Allow @@ -132,6 +139,9 @@ pub async fn run(args: LearnArgs) -> Result<()> { let policy = Sandbox::builder() .fs_read("/") .workdir(cow_dir.path()) + .net_allow("*") + .net_allow("udp://*") + .net_allow("icmp://*") .policy_fn(move |event, _ctx| observer_cb.on_event(event)) .build() .map_err(|e| anyhow!("failed to build sandbox policy: {e}"))?; diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index 26f8d4d8..1d68013b 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -457,6 +457,63 @@ fn test_learn_captures_net_connect() { ); } +/// `sandlock learn` must record UDP sendto destinations under `[network] allow` +/// with a `udp://` scheme. +#[test] +fn test_learn_captures_udp_sendto() { + let sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let port = sock.local_addr().unwrap().port(); + + let script = format!( + "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); \ + s.sendto(b'hi',('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!("udp://127.0.0.1:{port}"); + assert!( + stdout.contains(&expected), + "expected {expected} in network output, got:\n{stdout}", + ); +} + +/// `sandlock learn` must record UDP sendmsg destinations under `[network] allow` +/// with a `udp://` scheme. Uses Python's `socket.sendmsg()` which invokes the +/// sendmsg syscall (not sendto), verifying the msghdr.msg_name extraction path. +#[test] +fn test_learn_captures_udp_sendmsg() { + let sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let port = sock.local_addr().unwrap().port(); + + let script = format!( + "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); \ + s.sendmsg([b'hi'],[],0,('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!("udp://127.0.0.1:{port}"); + assert!( + stdout.contains(&expected), + "expected {expected} in network output, got:\n{stdout}", + ); +} + /// 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] diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index 3574b090..cef905d4 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -1568,15 +1568,35 @@ async fn emit_policy_event( .map(std::path::PathBuf::from); } - if nr == libc::SYS_connect || nr == libc::SYS_sendto || nr == libc::SYS_bind { - // connect(fd, addr, addrlen): args[1]=addr, args[2]=len - let addr_ptr = notif.data.args[1]; - let addr_len = notif.data.args[2] as usize; - let (h, p) = read_sockaddr_for_event(notif, addr_ptr, addr_len, notif_fd); + // connect(fd, addr, addrlen) and bind(fd, addr, addrlen): sockaddr in args[1]/args[2]. + if nr == libc::SYS_connect || nr == libc::SYS_bind { + let (h, p) = read_sockaddr_for_event(notif, notif.data.args[1], notif.data.args[2] as usize, notif_fd); host = h; port = p; } + // sendto(fd, buf, len, flags, addr, addrlen): sockaddr in args[4]/args[5]. + if nr == libc::SYS_sendto { + let (h, p) = read_sockaddr_for_event(notif, notif.data.args[4], notif.data.args[5] as usize, notif_fd); + host = h; + port = p; + } + + // sendmsg/sendmmsg: sockaddr is inside struct msghdr at args[1]. + // msghdr layout: msg_name ptr (u64 @ offset 0), msg_namelen u32 (@ offset 8). + // For sendmmsg the first mmsghdr entry's msghdr starts at offset 0, same layout. + if nr == libc::SYS_sendmsg || nr == libc::SYS_sendmmsg { + if let Ok(hdr) = read_child_mem(notif_fd, notif.id, notif.pid, notif.data.args[1], 12) { + if hdr.len() >= 12 { + let name_ptr = u64::from_ne_bytes(hdr[0..8].try_into().unwrap()); + let name_len = u32::from_ne_bytes(hdr[8..12].try_into().unwrap()) as usize; + let (h, p) = read_sockaddr_for_event(notif, name_ptr, name_len, notif_fd); + host = h; + port = p; + } + } + } + if nr == libc::SYS_mmap { // mmap(addr, length, ...): args[1] = length size = Some(notif.data.args[1]); From b648c2fc396726d4b7a139660fdc88621bd054c8 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Sat, 11 Jul 2026 19:42:55 +0400 Subject: [PATCH 22/28] correct SyscallEvent.path doc: path is read from child memory, not /proc fd table --- crates/sandlock-core/src/policy_fn.rs | 6 +++--- crates/sandlock-core/src/seccomp/notif.rs | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/sandlock-core/src/policy_fn.rs b/crates/sandlock-core/src/policy_fn.rs index eb7d6ab3..10fc66c4 100644 --- a/crates/sandlock-core/src/policy_fn.rs +++ b/crates/sandlock-core/src/policy_fn.rs @@ -106,9 +106,9 @@ pub struct SyscallEvent { /// 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. + /// Read from child user memory, not TOCTOU-safe for enforcement but + /// sufficient for learn-mode observation. + /// `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. diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index cef905d4..acdc3afb 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -1563,7 +1563,6 @@ 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); } @@ -1603,7 +1602,6 @@ async fn emit_policy_event( } // 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) From 5e8eb1f1c38a370e3e2b5fb9398d5755978af388 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Mon, 13 Jul 2026 18:28:48 +0400 Subject: [PATCH 23/28] tests: add openat2 test, fix read/write assertions to check profile sections not raw stdout --- crates/sandlock-cli/tests/cli_test.rs | 42 ++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index 1d68013b..92607fb2 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -295,9 +295,10 @@ fn test_learn_captures_fs_read() { String::from_utf8_lossy(&output.stderr), ); let stdout = String::from_utf8_lossy(&output.stdout); + let read_line = stdout.lines().find(|l| l.starts_with("read = [")).unwrap_or(""); assert!( - stdout.contains("/etc/hostname"), - "expected /etc/hostname in learn output, got:\n{stdout}", + read_line.contains("/etc/hostname"), + "expected /etc/hostname under read = [...], got: {read_line}", ); } @@ -350,11 +351,6 @@ fn test_learn_captures_fs_write() { 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), @@ -457,6 +453,38 @@ fn test_learn_captures_net_connect() { ); } +/// `sandlock learn` must capture reads done via the `openat2` syscall (not just +/// `openat`). +#[test] +fn test_learn_captures_openat2() { + // SYS_openat2 = 437 on x86_64; struct open_how { u64 flags; u64 mode; u64 resolve; } + // class can't follow ';' in Python one-liners; use embedded newlines. + let script = concat!( + "import ctypes, os\n", + "libc = ctypes.CDLL(None)\n", + "class How(ctypes.Structure):\n", + " _fields_ = [('f',ctypes.c_uint64),('m',ctypes.c_uint64),('r',ctypes.c_uint64)]\n", + "how = How(f=os.O_RDONLY)\n", + "fd = libc.syscall(437, -100, b'/etc/hostname', ctypes.byref(how), ctypes.sizeof(how))\n", + "os.read(fd, 4); os.close(fd)", + ); + 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 read_line = stdout.lines().find(|l| l.starts_with("read = [")).unwrap_or(""); + assert!( + read_line.contains("/etc/hostname"), + "expected /etc/hostname under read = [...] (via openat2), got: {read_line}", + ); +} + /// `sandlock learn` must record UDP sendto destinations under `[network] allow` /// with a `udp://` scheme. #[test] From ac06a921bd0b2b33d0ee6f877ded403505cb6203 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Mon, 13 Jul 2026 18:29:16 +0400 Subject: [PATCH 24/28] fix openat2 path/flags in policy events, guard is_write_open against pointer-sized values --- crates/sandlock-cli/src/learn.rs | 6 ++++++ crates/sandlock-core/src/seccomp/notif.rs | 22 +++++++++------------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 3b02edbd..c0b93435 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -21,6 +21,12 @@ const O_RDWR: u64 = 0o2; const O_CREAT: u64 = 0o100; fn is_write_open(flags: u64) -> bool { + // No valid open flag has bits 32+; a value that large is a pointer or + // garbage (e.g. from a mis-decoded syscall arg). Treat it as read-only + // so a misdecoded flag never puts a file in writes incorrectly. + if flags >> 32 != 0 { + return false; + } flags & (O_WRONLY | O_RDWR | O_CREAT) != 0 } diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index acdc3afb..003c6ae1 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -1253,7 +1253,7 @@ fn maybe_patch_vdso(pid: i32, procfs: &mut super::state::ProcfsState, policy: &N /// Map a syscall number to a human-readable name for the policy callback. fn syscall_name(nr: i64) -> &'static str { match nr { - n if n == libc::SYS_openat => "openat", + n if n == libc::SYS_openat || n == arch::SYS_OPENAT2 => "openat", n if n == libc::SYS_connect => "connect", n if n == libc::SYS_sendto => "sendto", n if n == libc::SYS_sendmsg => "sendmsg", @@ -1279,7 +1279,7 @@ fn syscall_name(nr: i64) -> &'static str { fn syscall_category(nr: i64) -> crate::policy_fn::SyscallCategory { use crate::policy_fn::SyscallCategory; match nr { - n if n == libc::SYS_openat || n == libc::SYS_unlinkat + n if n == libc::SYS_openat || n == arch::SYS_OPENAT2 || n == libc::SYS_unlinkat || n == libc::SYS_mkdirat || n == libc::SYS_renameat2 || n == libc::SYS_symlinkat || n == libc::SYS_linkat || n == libc::SYS_fchmodat || n == libc::SYS_fchownat @@ -1601,17 +1601,13 @@ async fn emit_policy_event( size = Some(notif.data.args[1]); } - // openat(dirfd, pathname, flags, mode): resolved path + flags. - // 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] - }); + // openat/openat2/open: resolved path + flags. + if nr == libc::SYS_openat || nr == arch::SYS_OPENAT2 || Some(nr) == arch::sys_open() { + if let Some(open_args) = decode_open_args(notif, notif_fd) { + path = resolve_path_for_notif(notif, notif_fd) + .map(std::path::PathBuf::from); + flags = Some(open_args.flags); + } } let event = crate::policy_fn::SyscallEvent { From 78319c0c893ffb870b1edfb7d0137985f49a0a7c Mon Sep 17 00:00:00 2001 From: Vahagn Date: Mon, 13 Jul 2026 20:09:16 +0400 Subject: [PATCH 25/28] learn: fix COW isolation -- workdir=/ intercepts all writes, abort on exit --- crates/sandlock-cli/src/learn.rs | 32 +++++++++++++-------------- crates/sandlock-cli/tests/cli_test.rs | 19 +++++++++------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index c0b93435..387721dd 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -11,6 +11,7 @@ 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::BranchAction; use sandlock_core::Sandbox; use crate::LearnArgs; @@ -133,18 +134,18 @@ pub async fn run(args: LearnArgs) -> Result<()> { 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}"))?; - + // COW workdir="/" covers every path: seccomp fires before Landlock, so the + // supervisor intercepts every write openat and redirects it to an upper layer + // the real filesystem is untouched and no write is blocked. let observer = LearnObserver::new(); let observer_cb = observer.clone(); let policy = Sandbox::builder() .fs_read("/") - .workdir(cow_dir.path()) + .workdir("/") + // Discard all COW changes after observation; learn is read-only from + // the real filesystem's perspective. + .on_exit(BranchAction::Abort) + .on_error(BranchAction::Abort) .net_allow("*") .net_allow("udp://*") .net_allow("icmp://*") @@ -201,7 +202,7 @@ pub async fn run(args: LearnArgs) -> Result<()> { sampler.abort(); - eprintln!("sandlock learn: done (exit={:?})", result.code()); + eprintln!("sandlock learn: done"); let peak_rss_kb = peak_rss_kb_atomic.load(Ordering::Relaxed); let threads = max_threads.load(Ordering::Relaxed); @@ -215,20 +216,19 @@ pub async fn run(args: LearnArgs) -> Result<()> { 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)) + .filter(|p| p.exists()) .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. + // For writes: if the file exists on the real FS, record the specific path + // (COW kept the original intact; the file was there before the run). + // If it doesn't exist (COW intercepted a create → new file in upper layer), + // record the parent directory instead; Landlock requires existing paths, + // and the program needs directory write access to create new files. write: observer.writes.lock().unwrap().iter() - .filter(|p| !p.starts_with(&cow_path)) .filter_map(|p| { if p.exists() { Some(p.clone()) diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index 92607fb2..bd4a8b12 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -335,12 +335,16 @@ fn test_learn_then_run() { } /// `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`. +/// Writes to two pre-existing temp files in different directories (no error +/// handling in the script; any blocked write would exit sh non-zero). #[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 tmp1 = tempfile::NamedTempFile::new().expect("tempfile"); + let tmp2 = tempfile::Builder::new().tempdir_in("/var/tmp").expect("tempdir"); + let tmp2_file = tmp2.path().join("sandlock-learn-write2.txt"); + let path1 = tmp1.path().to_str().unwrap().to_owned(); + let path2 = tmp2_file.to_str().unwrap().to_owned(); + let cmd = format!("echo x > {path1} && echo y > {path2}"); let output = sandlock_bin() .args(["learn", "--", "sh", "-c", &cmd]) .output() @@ -352,10 +356,9 @@ fn test_learn_captures_fs_write() { ); let stdout = String::from_utf8_lossy(&output.stdout); 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}", - ); + assert!(write_line.contains(&path1), "expected {path1} under write = [...], got: {write_line}"); + assert!(write_line.contains(&path2) || write_line.contains(tmp2.path().to_str().unwrap()), + "expected {path2} (or its parent) under write = [...], got: {write_line}"); } /// New file creates must be collapsed to the parent directory in the profile. From 4ef12605e739791cc9f834b6d78bca06752c51aa Mon Sep 17 00:00:00 2001 From: Vahagn Date: Mon, 13 Jul 2026 20:13:59 +0400 Subject: [PATCH 26/28] learn: refuse to write profile if workload exits abnormally --- crates/sandlock-cli/src/learn.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 387721dd..95585e31 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -202,7 +202,21 @@ pub async fn run(args: LearnArgs) -> Result<()> { sampler.abort(); - eprintln!("sandlock learn: done"); + match result.exit_status { + sandlock_core::ExitStatus::Code(0) => eprintln!("sandlock learn: done"), + sandlock_core::ExitStatus::Code(n) => { + eprintln!("sandlock learn: process exited with code {n}, not writing profile"); + std::process::exit(1); + } + sandlock_core::ExitStatus::Signal(sig) => { + eprintln!("sandlock learn: process killed by signal {sig}, not writing profile"); + std::process::exit(1); + } + sandlock_core::ExitStatus::Killed | sandlock_core::ExitStatus::Timeout => { + eprintln!("sandlock learn: process terminated abnormally, not writing profile"); + std::process::exit(1); + } + } let peak_rss_kb = peak_rss_kb_atomic.load(Ordering::Relaxed); let threads = max_threads.load(Ordering::Relaxed); From 354e53e964e6d35b4dbc24b8c68dfbc610c5e42d Mon Sep 17 00:00:00 2001 From: Vahagn Date: Mon, 13 Jul 2026 20:15:28 +0400 Subject: [PATCH 27/28] learn: add --timeout flag; kill and write partial profile on expiry --- crates/sandlock-cli/src/learn.rs | 73 ++++++++++++++++++++++++-------- crates/sandlock-cli/src/main.rs | 4 ++ 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 95585e31..e2c3f307 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -197,25 +197,64 @@ pub async fn run(args: LearnArgs) -> Result<()> { } }); - let result = sandbox.wait().await - .map_err(|e| anyhow!("sandbox error: {e}"))?; - - sampler.abort(); - - match result.exit_status { - sandlock_core::ExitStatus::Code(0) => eprintln!("sandlock learn: done"), - sandlock_core::ExitStatus::Code(n) => { - eprintln!("sandlock learn: process exited with code {n}, not writing profile"); - std::process::exit(1); - } - sandlock_core::ExitStatus::Signal(sig) => { - eprintln!("sandlock learn: process killed by signal {sig}, not writing profile"); - std::process::exit(1); + // Wait for the process, optionally with a timeout. + let timed_out = if let Some(secs) = args.timeout { + let deadline = std::time::Duration::from_secs(secs); + match tokio::time::timeout(deadline, sandbox.wait()).await { + Ok(r) => { + let result = r.map_err(|e| anyhow!("sandbox error: {e}"))?; + sampler.abort(); + match result.exit_status { + sandlock_core::ExitStatus::Code(0) => eprintln!("sandlock learn: done"), + sandlock_core::ExitStatus::Code(n) => { + eprintln!("sandlock learn: process exited with code {n}, not writing profile"); + std::process::exit(1); + } + sandlock_core::ExitStatus::Signal(sig) => { + eprintln!("sandlock learn: process killed by signal {sig}, not writing profile"); + std::process::exit(1); + } + sandlock_core::ExitStatus::Killed | sandlock_core::ExitStatus::Timeout => { + eprintln!("sandlock learn: process terminated abnormally, not writing profile"); + std::process::exit(1); + } + } + false + } + Err(_elapsed) => { + // Timeout: kill the child, drain the supervisor, write a partial profile. + eprintln!("sandlock learn: timeout after {secs}s, killing process"); + unsafe { libc::kill(child_pid as i32, libc::SIGKILL); } + // Drain without timeout so the supervisor releases its resources cleanly. + let _ = sandbox.wait().await; + sampler.abort(); + true + } } - sandlock_core::ExitStatus::Killed | sandlock_core::ExitStatus::Timeout => { - eprintln!("sandlock learn: process terminated abnormally, not writing profile"); - std::process::exit(1); + } else { + let result = sandbox.wait().await + .map_err(|e| anyhow!("sandbox error: {e}"))?; + sampler.abort(); + match result.exit_status { + sandlock_core::ExitStatus::Code(0) => eprintln!("sandlock learn: done"), + sandlock_core::ExitStatus::Code(n) => { + eprintln!("sandlock learn: process exited with code {n}, not writing profile"); + std::process::exit(1); + } + sandlock_core::ExitStatus::Signal(sig) => { + eprintln!("sandlock learn: process killed by signal {sig}, not writing profile"); + std::process::exit(1); + } + sandlock_core::ExitStatus::Killed | sandlock_core::ExitStatus::Timeout => { + eprintln!("sandlock learn: process terminated abnormally, not writing profile"); + std::process::exit(1); + } } + false + }; + + if timed_out { + eprintln!("sandlock learn: writing partial profile from observations before timeout"); } let peak_rss_kb = peak_rss_kb_atomic.load(Ordering::Relaxed); diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index eec81fc6..62d8d10a 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -210,6 +210,10 @@ struct LearnArgs { #[arg(short = 'o', long, value_name = "PATH")] output: Option, + /// Kill the observed process after this many seconds and write a partial profile + #[arg(long, value_name = "SECS")] + timeout: Option, + /// Command to observe (everything after --) #[arg(last = true, required = true)] cmd: Vec, From 5b0640bbb98049e17e0b8ffae843cf2977df4443 Mon Sep 17 00:00:00 2001 From: Vahagn Date: Tue, 14 Jul 2026 19:51:05 +0400 Subject: [PATCH 28/28] learn: capture mkdir/unlink/rename/symlink/link/truncate syscalls --- crates/sandlock-cli/src/learn.rs | 44 ++++++- crates/sandlock-cli/tests/cli_test.rs | 153 ++++++++++++++++++++++ crates/sandlock-core/src/policy_fn.rs | 8 +- crates/sandlock-core/src/seccomp/notif.rs | 67 +++++++++- 4 files changed, 262 insertions(+), 10 deletions(-) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index e2c3f307..db0fb29e 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -89,6 +89,13 @@ impl LearnObserver { } match event.syscall.as_str() { + "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); + } "openat" | "open" => { if let Some(path) = event.path { if let Some(fl) = event.flags { @@ -100,12 +107,39 @@ impl LearnObserver { } } } - "execve" | "execveat" => { - if let Some(path) = event.path { - self.reads.lock().unwrap().insert(path); + // mkdir/unlink/rmdir/symlink: Landlock MAKE_*/REMOVE_* are directory + // rights, so the parent dir is what sandlock run needs, not the target. + "mkdirat" | "unlinkat" | "symlinkat" => { + if let Some(p) = event.path { + if let Some(parent) = p.parent() { + self.writes.lock().unwrap().insert(parent.to_path_buf()); + } + } + } + // rename: needs RENAME_OLD on parent of old path + RENAME_NEW on parent of new path. + "renameat2" => { + for p in [event.path, event.path2].into_iter().flatten() { + if let Some(parent) = p.parent() { + self.writes.lock().unwrap().insert(parent.to_path_buf()); + } + } + } + // link: source needs read access (ln doesn't open() it); dst parent needs MAKE_HARDLINK. + "linkat" => { + if let Some(src) = event.path { + self.reads.lock().unwrap().insert(src); + } + if let Some(dst) = event.path2 { + if let Some(parent) = dst.parent() { + self.writes.lock().unwrap().insert(parent.to_path_buf()); + } + } + } + // truncate: LANDLOCK_ACCESS_FS_TRUNCATE applies to the file itself. + "truncate" => { + if let Some(p) = event.path { + self.writes.lock().unwrap().insert(p); } - // Mark PID: read maps on next event after execve completes. - self.pending_maps.lock().unwrap().insert(event.pid); } // Simplified: connect is assumed TCP, sendto/sendmsg UDP. // Ideally we'd check SO_PROTOCOL on the socket fd (need emit_policy_event to expose that info in SyscallEvent) diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index bd4a8b12..3704d17c 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -392,6 +392,159 @@ fn test_learn_new_file_collapses_to_parent() { ); } +/// mkdir records the parent directory in write (Landlock MAKE_DIR is a dir right). +/// COW must intercept the create so the real directory does not appear. +#[test] +fn test_learn_captures_mkdir() { + let dir = "/var/tmp/sandlock-learn-mkdir-test"; + let output = sandlock_bin() + .args(["learn", "--", "sh", "-c", &format!("mkdir {dir}")]) + .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 write_line = stdout.lines().find(|l| l.starts_with("write = [")).unwrap_or(""); + assert!(write_line.contains("/var/tmp"), + "expected /var/tmp in write = [...], got: {write_line}"); + assert!(!std::path::Path::new(dir).exists(), "COW isolation failed: dir was created on real FS"); +} + +/// unlink records the parent directory in write (Landlock REMOVE_FILE is a dir right). +/// COW must intercept the delete so the real file still exists after learn. +#[test] +fn test_learn_captures_unlink() { + let file = tempfile::NamedTempFile::new().expect("tempfile"); + let path = file.path().to_str().unwrap().to_owned(); + let parent = std::path::Path::new(&path).parent().unwrap().to_str().unwrap().to_owned(); + let output = sandlock_bin() + .args(["learn", "--", "sh", "-c", &format!("rm {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); + let write_line = stdout.lines().find(|l| l.starts_with("write = [")).unwrap_or(""); + assert!(write_line.contains(&parent), + "expected parent {parent} in write = [...], got: {write_line}"); + assert!(std::path::Path::new(&path).exists(), "COW isolation failed: file was deleted on real FS"); +} + +/// rename records parent dirs of both old and new path (RENAME_OLD + RENAME_NEW are dir rights). +/// Cross-directory rename so both /var/tmp and /tmp appear in write. +#[test] +fn test_learn_captures_rename() { + let src = tempfile::NamedTempFile::new_in("/var/tmp").expect("tempfile in /var/tmp"); + let src_path = src.path().to_str().unwrap().to_owned(); + let dst = "/tmp/sandlock-learn-rename-dst-test"; + let cmd = format!("mv {src_path} {dst}"); + 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); + let write_line = stdout.lines().find(|l| l.starts_with("write = [")).unwrap_or(""); + assert!(write_line.contains("/var/tmp"), "expected /var/tmp (src parent) in: {write_line}"); + assert!(write_line.contains("/tmp"), "expected /tmp (dst parent) in: {write_line}"); + // COW: src file still exists, dst was not created on real FS. + assert!(src.path().exists(), "COW isolation failed: src was deleted on real FS"); + assert!(!std::path::Path::new(dst).exists(), "COW isolation failed: dst was created on real FS"); +} + +/// symlink records the parent of the created linkpath (args[2] of symlinkat), +/// NOT the parent of the target string (args[0]). +/// This verifies we read the right argument for symlinkat. +/// Uses a relative target so COW can intercept the create. +#[test] +fn test_learn_captures_symlink() { + let link = "/var/tmp/sandlock-learn-symlink-test"; + // Relative target so COW can intercept. Key check: /tmp (target's dir) must NOT appear + // as write -- only /var/tmp (the linkpath's parent) should. + let cmd = format!("ln -s hostname {link}"); + 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); + let write_line = stdout.lines().find(|l| l.starts_with("write = [")).unwrap_or(""); + assert!(write_line.contains("/var/tmp"), "expected /var/tmp (link parent) in: {write_line}"); + assert!(!std::path::Path::new(link).exists(), "COW isolation failed: symlink created on real FS"); +} + +/// hardlink records only the destination parent (MAKE_HARDLINK is a dst-dir right; +/// the source only needs read access which fs_read already grants). +#[test] +fn test_learn_captures_hardlink() { + let src = tempfile::NamedTempFile::new_in("/var/tmp").expect("tempfile in /var/tmp"); + let src_path = src.path().to_str().unwrap().to_owned(); + let dst = "/tmp/sandlock-learn-hardlink-dst-test"; + let cmd = format!("ln {src_path} {dst}"); + 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); + let write_line = stdout.lines().find(|l| l.starts_with("write = [")).unwrap_or(""); + let read_line = stdout.lines().find(|l| l.starts_with("read = [")).unwrap_or(""); + // dst parent /tmp must appear in writes (MAKE_HARDLINK is a dst-dir right). + assert!(write_line.contains("/tmp"), "expected /tmp (dst parent) in: {write_line}"); + // src parent /var/tmp must NOT appear as a write (only read access is needed for src). + assert!(!write_line.contains("/var/tmp"), "src parent /var/tmp wrongly recorded as write in: {write_line}"); + // src file must appear in reads (ln never calls open() on it, so we add it explicitly). + assert!(read_line.contains(&src_path), "expected src {src_path} in reads: {read_line}"); + assert!(!std::path::Path::new(dst).exists(), "COW isolation failed: hardlink created on real FS"); +} + +/// All filesystem mutation syscalls in one run: mkdir, unlink, rename, symlink, hardlink. +/// Verifies they are all captured without any one operation blocking the others. +#[test] +fn test_learn_captures_all_fs_mutations() { + let existing = tempfile::NamedTempFile::new_in("/var/tmp").expect("tempfile"); + let existing_path = existing.path().to_str().unwrap().to_owned(); + let newdir = "/var/tmp/sandlock-learn-allops-dir"; + let symlink = "/var/tmp/sandlock-learn-allops-link"; + let cmd = format!( + "mkdir {newdir} && rmdir {newdir} && rm {existing_path} && ln -s hostname {symlink}", + ); + let output = sandlock_bin() + .args(["learn", "--", "sh", "-c", &cmd]) + .output() + .expect("failed to run sandlock learn"); + assert!(output.status.success(), + "one or more mutations blocked: stderr={}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8_lossy(&output.stdout); + let write_line = stdout.lines().find(|l| l.starts_with("write = [")).unwrap_or(""); + assert!(write_line.contains("/var/tmp"), "expected /var/tmp in: {write_line}"); + // COW: existing file must still be present, new dir and symlink must not exist. + assert!(existing.path().exists(), "COW isolation failed: file deleted on real FS"); + assert!(!std::path::Path::new(newdir).exists(), "COW isolation failed: dir created on real FS"); + assert!(!std::path::Path::new(symlink).exists(), "COW isolation failed: symlink created on real FS"); +} + +/// truncate records the file path itself (LANDLOCK_ACCESS_FS_TRUNCATE is a file right, +/// not a directory right, so we record the file, not the parent). +#[test] +fn test_learn_captures_truncate() { + let tmp = tempfile::NamedTempFile::new().expect("tempfile"); + let path = tmp.path().to_str().unwrap().to_owned(); + let output = sandlock_bin() + .args(["learn", "--", "sh", "-c", &format!("truncate -s 0 {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); + let write_line = stdout.lines().find(|l| l.starts_with("write = [")).unwrap_or(""); + assert!(write_line.contains(&path), + "expected file path {path} in write = [...], got: {write_line}"); +} + /// 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. diff --git a/crates/sandlock-core/src/policy_fn.rs b/crates/sandlock-core/src/policy_fn.rs index 10fc66c4..3949c296 100644 --- a/crates/sandlock-core/src/policy_fn.rs +++ b/crates/sandlock-core/src/policy_fn.rs @@ -105,11 +105,15 @@ pub struct SyscallEvent { pub argv: Option>, /// Whether the supervisor denied this syscall. pub denied: bool, - /// Resolved absolute path for file syscalls (openat, execve/execveat). + /// Resolved absolute path for file syscalls (openat, execve/execveat, + /// mkdirat, unlinkat, symlinkat, truncate, renameat2 src, linkat src). /// Read from child user memory, not TOCTOU-safe for enforcement but /// sufficient for learn-mode observation. /// `None` for non-file syscalls or when resolution fails. pub path: Option, + /// Second resolved path for two-path syscalls (renameat2 dst, linkat dst). + /// `None` for single-path syscalls. + pub path2: Option, /// Open flags for openat (the `flags` argument, e.g. `O_RDONLY`, /// `O_WRONLY`, `O_CREAT`). `None` for non-openat syscalls. pub flags: Option, @@ -492,6 +496,7 @@ mod tests { argv: Some(vec!["python3".into(), "-c".into(), "print(1)".into()]), denied: false, path: None, + path2: None, flags: None, }; assert!(event.argv_contains("python3")); @@ -513,6 +518,7 @@ mod tests { argv: None, denied: false, path: None, + path2: None, flags: None, }; assert!(!event.argv_contains("anything")); diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index 003c6ae1..7647d183 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -1271,6 +1271,17 @@ fn syscall_name(nr: i64) -> &'static str { n if n == libc::SYS_getrandom => "getrandom", n if n == libc::SYS_unlinkat => "unlinkat", n if n == libc::SYS_mkdirat => "mkdirat", + n if n == libc::SYS_renameat2 => "renameat2", + n if n == libc::SYS_linkat => "linkat", + n if n == libc::SYS_symlinkat => "symlinkat", + n if n == libc::SYS_truncate => "truncate", + // Legacy single-path variants (x86_64 only). + n if Some(n) == arch::sys_mkdir() => "mkdirat", + n if Some(n) == arch::sys_rmdir() => "unlinkat", + n if Some(n) == arch::sys_unlink() => "unlinkat", + n if Some(n) == arch::sys_symlink() => "symlinkat", + n if Some(n) == arch::sys_link() => "linkat", + n if Some(n) == arch::sys_rename() => "renameat2", _ => "unknown", } } @@ -1394,10 +1405,43 @@ fn resolve_path_for_notif(notif: &SeccompNotif, notif_fd: RawFd) -> Option { + let path = read_path_for_event(notif, notif.data.args[1], notif_fd)?; + resolve_at_path_for_event(notif, notif.data.args[0] as i64, &path) + } + // unlinkat(dirfd, pathname, flags) + n if n == libc::SYS_unlinkat => { + let path = read_path_for_event(notif, notif.data.args[1], notif_fd)?; + resolve_at_path_for_event(notif, notif.data.args[0] as i64, &path) + } + // symlinkat(target, newdirfd, linkpath): args[2] is the created symlink path. + // args[0] is the target string (not a filesystem path to grant rights on). + n if n == libc::SYS_symlinkat => { + let path = read_path_for_event(notif, notif.data.args[2], notif_fd)?; + resolve_at_path_for_event(notif, notif.data.args[1] as i64, &path) + } + // truncate(path, length): absolute path, no dirfd. + n if n == libc::SYS_truncate => { + let path = read_path_for_event(notif, notif.data.args[0], notif_fd)?; + resolve_at_path_for_event(notif, libc::AT_FDCWD as i64, &path) + } + // Legacy single-path variants: mkdir, rmdir, unlink share args[0]=path. + n if Some(n) == arch::sys_mkdir() || Some(n) == arch::sys_rmdir() + || Some(n) == arch::sys_unlink() => + { + let path = read_path_for_event(notif, notif.data.args[0], notif_fd)?; + resolve_at_path_for_event(notif, libc::AT_FDCWD as i64, &path) + } + // symlink(target, linkpath): args[1] is the created symlink path. + n if Some(n) == arch::sys_symlink() => { + let path = read_path_for_event(notif, notif.data.args[1], notif_fd)?; + resolve_at_path_for_event(notif, libc::AT_FDCWD as i64, &path) + } + // symlinkat/symlink intentionally omitted from deny-path gating: creating + // a symlink does not access its target, so there is nothing to gate here. + // Any later open through the symlink resolves to the real target and is + // denied race-free on the open path (issue #111). See `on_behalf_open_for_deny`. // link(oldpath, newpath) — legacy, AT_FDCWD implied for both n if Some(n) == arch::sys_link() => { let path = read_path_for_event(notif, notif.data.args[0], notif_fd)?; @@ -1552,6 +1596,7 @@ async fn emit_policy_event( let mut size = None; let mut argv = None; let mut path = None; + let mut path2 = None; let mut flags = None; if !denied && (nr == libc::SYS_execve || nr == libc::SYS_execveat) { @@ -1610,6 +1655,19 @@ async fn emit_policy_event( } } + // mkdirat, unlinkat, symlinkat, truncate: single resolved path. + // renameat2, linkat (and their legacy equivalents): src path + dst path2. + let is_fs_mutating = nr == libc::SYS_mkdirat || nr == libc::SYS_unlinkat + || nr == libc::SYS_symlinkat || nr == libc::SYS_truncate + || nr == libc::SYS_renameat2 || nr == libc::SYS_linkat + || Some(nr) == arch::sys_mkdir() || Some(nr) == arch::sys_rmdir() + || Some(nr) == arch::sys_unlink() || Some(nr) == arch::sys_symlink() + || Some(nr) == arch::sys_link() || Some(nr) == arch::sys_rename(); + if is_fs_mutating { + path = resolve_path_for_notif(notif, notif_fd).map(std::path::PathBuf::from); + path2 = resolve_second_path_for_notif(notif, notif_fd).map(std::path::PathBuf::from); + } + let event = crate::policy_fn::SyscallEvent { syscall: name.to_string(), category, @@ -1621,6 +1679,7 @@ async fn emit_policy_event( argv, denied, path, + path2, flags, };