diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 99f4514f..31263e36 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -111,6 +111,11 @@ struct RunArgs { #[arg(long = "env", value_name = "KEY=VALUE")] env_vars: Vec, + /// Report VALUE as a uname field: release, version, sysname, machine or + /// domainname (e.g. --uname release=8.0.0). Repeatable. + #[arg(long = "uname", value_name = "FIELD=VALUE")] + uname: Vec, + /// CPU cores to pin the sandbox to (e.g. --cpu-cores 0,2,3) #[arg(long = "cpu-cores", value_delimiter = ',')] cpu_cores: Vec, @@ -612,6 +617,7 @@ async fn run_command(args: RunArgs) -> Result { if let Some(n) = base.num_cpus { b = b.num_cpus(n); } + b.uname = base.uname.clone(); if let Some(n) = base.max_open_files { b = b.max_open_files(n); } @@ -816,6 +822,9 @@ async fn run_command(args: RunArgs) -> Result { if !args.cpu_cores.is_empty() { builder = builder.cpu_cores(args.cpu_cores.clone()); } + for spec in &args.uname { + builder = builder.uname_spec(spec); + } match &args.gpu { // `all` maps to an empty device list, which the core expands to every // present GPU; explicit indices pass through unchanged. @@ -1085,6 +1094,9 @@ fn validate_no_supervisor(args: &RunArgs) -> Result<()> { if !args.cpu_cores.is_empty() { bad.push("--cpu-cores"); } + if !args.uname.is_empty() { + bad.push("--uname"); + } if args.gpu.is_some() { bad.push("--gpu"); } @@ -1190,6 +1202,9 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> if profile.num_cpus.is_some() { bad.push("[limits].num_cpus"); } + if !profile.uname.is_empty() { + bad.push("[uname]"); + } if profile.random_seed.is_some() { bad.push("[determinism].random_seed"); } diff --git a/crates/sandlock-core/src/checkpoint/image.rs b/crates/sandlock-core/src/checkpoint/image.rs index 1ff35fd6..7bbeb2e5 100644 --- a/crates/sandlock-core/src/checkpoint/image.rs +++ b/crates/sandlock-core/src/checkpoint/image.rs @@ -33,7 +33,9 @@ use std::path::{Path, PathBuf}; // rules inside `net_allow`, which v3 would misread as explicit rules // (e.g. deny-only+HTTP loading as combined), so v2 is rejected outright // with no compatibility fallback (pre-1.0 policy). -const IMAGE_VERSION: u32 = 3; +// v4 adds Sandbox.uname to the positional bincode policy. Reject v3 +// rather than decoding its remaining fields as uname values. +const IMAGE_VERSION: u32 = 4; fn io_err(e: impl std::fmt::Display) -> SandlockError { SandlockError::Runtime(SandboxRuntimeError::Child(e.to_string())) @@ -373,6 +375,21 @@ mod tests { ); } + #[test] + fn image_rejects_pre_uname_policy_layout() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("meta.json"), + br#"{"name":"x","cow_snapshot":null,"version":3}"#, + ) + .unwrap(); + let msg = Checkpoint::load(dir.path()).unwrap_err().to_string(); + assert!( + msg.contains("unsupported checkpoint image version 3"), + "expected rejection before reading policy.dat, got: {msg}" + ); + } + fn round_trip_policy(policy: Sandbox) -> Sandbox { let dir = std::env::temp_dir().join(format!( "sandlock-mode-{}-{}", @@ -399,6 +416,19 @@ mod tests { loaded.policy } + #[test] + fn image_round_trip_preserves_uname() { + let policy = Sandbox::builder() + .uname("release", "8.0.0") + .uname("machine", "riscv64") + .build() + .unwrap(); + let loaded = round_trip_policy(policy); + assert_eq!(loaded.uname.release.as_deref(), Some("8.0.0")); + assert_eq!(loaded.uname.machine.as_deref(), Some("riscv64")); + assert_eq!(loaded.uname.sysname, None); + } + #[test] fn image_round_trip_preserves_combined_policy_without_origin_flag() { let policy = Sandbox::builder() diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index 8dfdcade..1418a68b 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -65,7 +65,7 @@ pub use policy_fn::PolicyFn; pub use protection::{Protection, ProtectionPolicy, ProtectionState, ProtectionStatus}; pub use result::{renames, Change, ChangeKind, Entry, EntryKind, ExitStatus, RunResult}; pub use sandbox::{ - BindPorts, Confinement, ConfinementBuilder, Process, Sandbox, SandboxBuilder, StdioMode, + BindPorts, Confinement, ConfinementBuilder, Process, Sandbox, SandboxBuilder, StdioMode, Uname, }; pub use sys::structs::{SeccompData, SeccompNotif}; #[cfg(feature = "cow")] diff --git a/crates/sandlock-core/src/procfs.rs b/crates/sandlock-core/src/procfs.rs index 44c64a57..628b4683 100644 --- a/crates/sandlock-core/src/procfs.rs +++ b/crates/sandlock-core/src/procfs.rs @@ -36,6 +36,7 @@ use std::sync::Arc; use tokio::sync::Mutex; +use crate::sandbox::Uname; use crate::seccomp::ctx::SupervisorCtx; use crate::seccomp::notif::{ content_memfd, dup_fd_from_pid, inject_open_result, openat2_at, write_child_mem, NotifAction, @@ -146,12 +147,19 @@ pub(crate) enum VirtualFile { Cgroup, Hostname, EtcHosts, + OsType, + OsRelease, + KernelVersion, + Arch, + DomainName, + ProcVersion, } /// The virtual file `path` names under `policy`, with every per-task /// spelling of a namespace entry collapsed first. The real file behind it /// must never be handed out in its place. pub(crate) fn virtual_file(path: &str, policy: &NotifPolicy) -> Option { + let uname = &policy.uname; Some(match canon_proc_namespace(path).as_ref() { "/proc/cpuinfo" if policy.num_cpus.is_some() => VirtualFile::CpuInfo, "/proc/stat" if policy.num_cpus.is_some() => VirtualFile::Stat, @@ -171,8 +179,20 @@ pub(crate) fn virtual_file(path: &str, policy: &NotifPolicy) -> Option VirtualFile::MountInfo, "/proc/self/mountstats" => VirtualFile::MountStats, "/proc/self/cgroup" => VirtualFile::Cgroup, - "/etc/hostname" if policy.virtual_hostname.is_some() => VirtualFile::Hostname, + "/etc/hostname" | "/proc/sys/kernel/hostname" if policy.virtual_hostname.is_some() => { + VirtualFile::Hostname + } "/etc/hosts" if !policy.virtual_etc_hosts.is_empty() => VirtualFile::EtcHosts, + "/proc/sys/kernel/ostype" if uname.sysname.is_some() => VirtualFile::OsType, + "/proc/sys/kernel/osrelease" if uname.release.is_some() => VirtualFile::OsRelease, + "/proc/sys/kernel/version" if uname.version.is_some() => VirtualFile::KernelVersion, + "/proc/sys/kernel/arch" if uname.machine.is_some() => VirtualFile::Arch, + "/proc/sys/kernel/domainname" if uname.domainname.is_some() => VirtualFile::DomainName, + "/proc/version" + if uname.sysname.is_some() || uname.release.is_some() || uname.version.is_some() => + { + VirtualFile::ProcVersion + } _ => return None, }) } @@ -233,6 +253,12 @@ pub(crate) async fn render_virtual_file( ) .into_bytes(), VirtualFile::EtcHosts => policy.virtual_etc_hosts.clone().into_bytes(), + VirtualFile::OsType => uts_line(&policy.uname.sysname), + VirtualFile::OsRelease => uts_line(&policy.uname.release), + VirtualFile::KernelVersion => uts_line(&policy.uname.version), + VirtualFile::Arch => uts_line(&policy.uname.machine), + VirtualFile::DomainName => uts_line(&policy.uname.domainname), + VirtualFile::ProcVersion => proc_version(&host_utsname(), &policy.uname).into_bytes(), } } @@ -283,6 +309,48 @@ pub(crate) fn filter_proc_stat(real: &str, num_cpus: u32) -> Vec { out.into_bytes() } +fn uts_line(value: &Option) -> Vec { + format!("{}\n", value.as_deref().unwrap_or_default()).into_bytes() +} + +/// /proc/version built from the uname fields alone. The host's compiler +/// banner is left out: it names the build host and differs across +/// machines, so pinned fields would still not give a reproducible file. +fn proc_version(host: &libc::utsname, uname: &Uname) -> String { + let field = |value: &Option, host: &[libc::c_char]| { + value.clone().unwrap_or_else(|| uts_field(host)) + }; + format!( + "{} version {} {}\n", + field(&uname.sysname, &host.sysname), + field(&uname.release, &host.release), + field(&uname.version, &host.version), + ) +} + +fn host_utsname() -> libc::utsname { + let mut uts: libc::utsname = unsafe { std::mem::zeroed() }; + unsafe { libc::uname(&mut uts) }; + uts +} + +fn uts_field(field: &[libc::c_char]) -> String { + let bytes: Vec = field + .iter() + .take_while(|&&c| c != 0) + .map(|&c| c as u8) + .collect(); + String::from_utf8_lossy(&bytes).into_owned() +} + +fn set_uts_field(field: &mut [libc::c_char], value: &str) { + let len = value.len().min(field.len() - 1); + field.fill(0); + for (dst, &b) in field.iter_mut().zip(&value.as_bytes()[..len]) { + *dst = b as libc::c_char; + } +} + // ============================================================ // /proc/cpuinfo generator // ============================================================ @@ -960,12 +1028,14 @@ pub(crate) fn handle_sched_getaffinity( // uname virtualization // ============================================================ -/// Handle uname() — override the nodename (hostname) field. -/// -/// uname(buf) writes a `struct utsname` to buf. We call the real uname() -/// in the supervisor, patch the nodename field, and write the result to -/// the child's buffer. -pub(crate) fn handle_uname(notif: &SeccompNotif, hostname: &str, notif_fd: RawFd) -> NotifAction { +/// Handle uname(): report the sandbox name as nodename and the overridden +/// `uname` fields over the host's. +pub(crate) fn handle_uname( + notif: &SeccompNotif, + hostname: &str, + uname: &Uname, + notif_fd: RawFd, +) -> NotifAction { let buf_addr = notif.data.args[0]; if buf_addr == 0 { return NotifAction::Continue; @@ -977,13 +1047,18 @@ pub(crate) fn handle_uname(notif: &SeccompNotif, hostname: &str, notif_fd: RawFd return NotifAction::Continue; } - // Overwrite nodename with the virtual hostname. - let name_bytes = hostname.as_bytes(); - let len = name_bytes.len().min(uts.nodename.len() - 1); - for (i, &b) in name_bytes[..len].iter().enumerate() { - uts.nodename[i] = b as libc::c_char; + set_uts_field(&mut uts.nodename, hostname); + for (field, value) in [ + (&mut uts.sysname[..], &uname.sysname), + (&mut uts.release[..], &uname.release), + (&mut uts.version[..], &uname.version), + (&mut uts.machine[..], &uname.machine), + (&mut uts.domainname[..], &uname.domainname), + ] { + if let Some(value) = value { + set_uts_field(field, value); + } } - uts.nodename[len] = 0; // Write the patched utsname to child memory. let bytes = unsafe { @@ -1731,6 +1806,31 @@ mod tests { assert_eq!(generate_cpu_list(4), b"0-3\n"); } + #[test] + fn proc_version_takes_unset_fields_from_the_host() { + let mut host: libc::utsname = unsafe { std::mem::zeroed() }; + set_uts_field(&mut host.sysname, "Linux"); + set_uts_field(&mut host.release, "6.8.0"); + set_uts_field(&mut host.version, "#1 SMP"); + let uname = Uname { + release: Some("8.0.0".into()), + ..Default::default() + }; + assert_eq!(proc_version(&host, &uname), "Linux version 8.0.0 #1 SMP\n"); + } + + #[test] + fn set_uts_field_truncates_and_terminates() { + let mut field = [b'x' as libc::c_char; 8]; + set_uts_field(&mut field, "0123456789"); + assert_eq!(uts_field(&field), "0123456"); + set_uts_field(&mut field, "ab"); + assert_eq!(uts_field(&field), "ab"); + assert!(field[2..].iter().all(|&c| c == 0)); + set_uts_field(&mut field, ""); + assert!(field.iter().all(|&c| c == 0)); + } + #[test] fn test_generate_cpuinfo_single() { let info = generate_cpuinfo(1); diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 6ce5408e..44f8b943 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -1,5 +1,5 @@ use crate::error::SandlockError; -use crate::sandbox::{ByteSize, Sandbox}; +use crate::sandbox::{ByteSize, Sandbox, Uname}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; @@ -32,6 +32,8 @@ pub struct ProfileInput { pub syscalls: SyscallsSection, #[serde(skip_serializing_if = "is_default")] pub limits: LimitsSection, + #[serde(skip_serializing_if = "is_default")] + pub uname: Uname, } fn is_false(b: &bool) -> bool { @@ -348,6 +350,23 @@ pub struct ResolvedProfile { pub cpu_cores: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub num_cpus: Option, + + #[serde( + skip_serializing_if = "Uname::is_empty", + serialize_with = "uname_as_map" + )] + pub uname: Uname, +} + +// The SDKs read this as a plain map, where a null value would be a field +// set to nothing rather than one left to the host. +fn uname_as_map(uname: &Uname, serializer: S) -> Result { + serializer.collect_map( + uname + .fields() + .into_iter() + .filter_map(|(field, value)| value.as_ref().map(|v| (field, v))), + ) } fn pairs_as_map( @@ -446,6 +465,8 @@ pub fn resolve(input: ProfileInput) -> Result { gpu_devices: l.gpu_devices, cpu_cores: l.cpu_cores, num_cpus: l.num_cpus, + + uname: input.uname, }) } @@ -600,6 +621,7 @@ impl ResolvedProfile { if let Some(n) = self.num_cpus { b = b.num_cpus(n); } + b.uname = self.uname; let policy = b.build()?; Ok(( @@ -871,6 +893,7 @@ pub fn sandbox_to_profile(s: &Sandbox, extra_denied: &[String]) -> ProfileInput cpu_cores: s.cpu_cores.clone(), num_cpus: s.num_cpus, }, + uname: s.uname.clone(), } } @@ -1368,6 +1391,22 @@ mod tests { } } + #[test] + fn profile_uname_round_trips() { + let toml = "[uname]\nrelease = \"8.0.0\"\nmachine = \"riscv64\"\n"; + let (policy, _spec) = parse_profile(toml).unwrap(); + assert_eq!(policy.uname.release.as_deref(), Some("8.0.0")); + assert_eq!(policy.uname.machine.as_deref(), Some("riscv64")); + assert_eq!(sandbox_to_profile(&policy, &[]).uname, policy.uname); + } + + #[test] + fn profile_uname_rejects_unknown_and_oversized_fields() { + assert!(parse_profile("[uname]\nnodename = \"x\"\n").is_err()); + let long = format!("[uname]\nrelease = \"{}\"\n", "x".repeat(65)); + assert!(parse_profile(&long).is_err()); + } + #[test] fn profile_network_deny_parses() { let toml = r#" diff --git a/crates/sandlock-core/src/resource.rs b/crates/sandlock-core/src/resource.rs index 533958d8..3bc3a1cc 100644 --- a/crates/sandlock-core/src/resource.rs +++ b/crates/sandlock-core/src/resource.rs @@ -1011,6 +1011,7 @@ mod tests { chroot_mount_ro: Vec::new(), deterministic_dirs: false, virtual_hostname: None, + uname: Default::default(), has_http_acl: false, virtual_etc_hosts: String::new(), ca_inject_paths: Vec::new(), diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index cae6c29b..9da68da5 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -239,6 +239,71 @@ impl std::str::FromStr for RunAs { } } +/// Kernel identity the sandbox sees in `uname()` and the procfs files that +/// repeat it. A field left `None` shows the host's value; `nodename` is +/// always the sandbox name. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Uname { + pub sysname: Option, + pub release: Option, + pub version: Option, + pub machine: Option, + pub domainname: Option, +} + +impl Uname { + pub fn is_empty(&self) -> bool { + *self == Self::default() + } + + /// Set the field named as in `struct utsname`. + pub fn set(&mut self, field: &str, value: &str) -> Result<(), String> { + let slot = match field { + "sysname" => &mut self.sysname, + "release" => &mut self.release, + "version" => &mut self.version, + "machine" => &mut self.machine, + "domainname" => &mut self.domainname, + "nodename" => return Err("uname nodename is the sandbox name; set the name".into()), + _ => return Err(format!("unknown uname field {field:?}")), + }; + *slot = Some(value.to_string()); + self.validate() + } + + /// Set a field from `FIELD=VALUE`. + pub fn set_spec(&mut self, spec: &str) -> Result<(), String> { + let (field, value) = spec + .split_once('=') + .ok_or_else(|| format!("expected FIELD=VALUE, got {spec:?}"))?; + self.set(field, value) + } + + pub fn validate(&self) -> Result<(), String> { + for (field, value) in self.fields() { + let Some(value) = value else { continue }; + // Each utsname field is a NUL-terminated char[65]. + if value.len() > 64 || value.contains('\0') { + return Err(format!( + "uname {field} must be at most 64 bytes without NUL, got {value:?}" + )); + } + } + Ok(()) + } + + pub(crate) fn fields(&self) -> [(&'static str, &Option); 5] { + [ + ("sysname", &self.sysname), + ("release", &self.release), + ("version", &self.version), + ("machine", &self.machine), + ("domainname", &self.domainname), + ] + } +} + /// Confinement for confining the current process in place. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Confinement { @@ -396,6 +461,9 @@ impl TryFrom<&Sandbox> for Confinement { if sandbox.num_cpus.is_some() { unsupported.push("num_cpus"); } + if !sandbox.uname.is_empty() { + unsupported.push("uname"); + } if sandbox.port_remap { unsupported.push("port_remap"); } @@ -800,6 +868,9 @@ pub struct Sandbox { pub num_cpus: Option, pub port_remap: bool, + #[serde(default)] + pub uname: Uname, + /// Skip the seccomp user-notification supervisor. The sandbox runs /// with Landlock + a kernel-only deny filter, with none of the /// supervisor-mediated features (IP allowlist, resource limits, @@ -930,6 +1001,7 @@ impl Clone for Sandbox { cpu_cores: self.cpu_cores.clone(), num_cpus: self.num_cpus, port_remap: self.port_remap, + uname: self.uname.clone(), no_supervisor: self.no_supervisor, user: self.user, policy_fn: self.policy_fn.clone(), @@ -2724,6 +2796,7 @@ impl Sandbox { chroot_mount_ro: self.fs_mount_ro.clone(), deterministic_dirs: self.deterministic_dirs, virtual_hostname: Some(rt_name), + uname: self.uname.clone(), has_http_acl: resolved.features.http_acl, virtual_etc_hosts, ca_inject_paths: self.http_inject_ca.clone(), diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index a1abec60..8fbfb975 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -209,6 +209,10 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "port-remap"))] pub port_remap: bool, + // The CLI takes repeated FIELD=VALUE specs on RunArgs. + #[cfg_attr(feature = "cli", clap(skip))] + pub uname: super::Uname, + /// Skip the seccomp user-notification supervisor. The CLI exposes /// its own `--no-supervisor` flag on `RunArgs` (which short-circuits /// to a direct exec); this field is the API-level counterpart used @@ -318,6 +322,7 @@ impl Default for SandboxBuilder { cpu_cores: None, num_cpus: None, port_remap: false, + uname: Default::default(), no_supervisor: false, user: None, protection_policy: ProtectionPolicy::default(), @@ -384,6 +389,7 @@ impl Clone for SandboxBuilder { cpu_cores: self.cpu_cores.clone(), num_cpus: self.num_cpus, port_remap: self.port_remap, + uname: self.uname.clone(), no_supervisor: self.no_supervisor, user: self.user, protection_policy: self.protection_policy.clone(), @@ -832,6 +838,23 @@ impl SandboxBuilder { self } + /// Report `value` as the `uname()` field named `field` (`release`, + /// `version`, `sysname`, `machine` or `domainname`). + pub fn uname(mut self, field: &str, value: &str) -> Self { + match self.uname.set(field, value) { + Ok(()) => self, + Err(msg) => self.reject(msg), + } + } + + /// [`uname`](Self::uname) from `FIELD=VALUE`. + pub fn uname_spec(mut self, spec: &str) -> Self { + match self.uname.set_spec(spec) { + Ok(()) => self, + Err(msg) => self.reject(msg), + } + } + pub fn port_remap(mut self, v: bool) -> Self { self.port_remap = v; self @@ -915,6 +938,7 @@ impl SandboxBuilder { if let Some(msg) = self.rejected { return Err(SandboxError::Invalid(msg)); } + self.uname.validate().map_err(SandboxError::Invalid)?; validate_syscall_names(&self.extra_deny_syscalls)?; validate_allow_groups(&self.extra_allow_syscalls)?; validate_allow_deny_disjoint(&self.extra_allow_syscalls, &self.extra_deny_syscalls)?; @@ -1280,6 +1304,7 @@ impl SandboxBuilder { cpu_cores: self.cpu_cores, num_cpus: self.num_cpus, port_remap: self.port_remap, + uname: self.uname.clone(), no_supervisor: self.no_supervisor, user: self.user, policy_fn, diff --git a/crates/sandlock-core/src/sandbox/tests.rs b/crates/sandlock-core/src/sandbox/tests.rs index da1a3303..4036afc1 100644 --- a/crates/sandlock-core/src/sandbox/tests.rs +++ b/crates/sandlock-core/src/sandbox/tests.rs @@ -692,3 +692,25 @@ fn timestamp_formats_in_utc_and_round_trips() { let t = parse_timestamp("t", "2026-01-01T08:00:00+08:00").unwrap(); assert_eq!(format_timestamp(t).as_deref(), Some("2026-01-01T00:00:00Z")); } + +#[test] +fn builder_uname_sets_fields() { + let sb = Sandbox::builder() + .uname("release", "8.0.0") + .uname_spec("machine=riscv64") + .build() + .unwrap(); + assert_eq!(sb.uname.release.as_deref(), Some("8.0.0")); + assert_eq!(sb.uname.machine.as_deref(), Some("riscv64")); + assert_eq!(sb.uname.sysname, None); +} + +#[test] +fn builder_uname_rejects_bad_specs() { + for spec in ["nodename=x", "kernel=8.0", "release", "release=a\0b"] { + let err = Sandbox::builder().uname_spec(spec).build(); + assert!(err.is_err(), "{spec:?} must be rejected"); + } + let long = "x".repeat(65); + assert!(Sandbox::builder().uname("release", &long).build().is_err()); +} diff --git a/crates/sandlock-core/src/seccomp/dispatch.rs b/crates/sandlock-core/src/seccomp/dispatch.rs index 7b8ec809..7eec5b22 100644 --- a/crates/sandlock-core/src/seccomp/dispatch.rs +++ b/crates/sandlock-core/src/seccomp/dispatch.rs @@ -619,11 +619,13 @@ pub(crate) fn build_dispatch_table( if let Some(ref hostname) = policy.virtual_hostname { let hostname_for_uname = hostname.clone(); let hostname_for_open = hostname.clone(); + let uname = policy.uname.clone(); table.register(libc::SYS_uname, move |cx: &HandlerCtx| { let notif = cx.notif; let notif_fd = cx.notif_fd; let hostname = hostname_for_uname.clone(); - async move { crate::procfs::handle_uname(¬if, &hostname, notif_fd) } + let uname = uname.clone(); + async move { crate::procfs::handle_uname(¬if, &hostname, &uname, notif_fd) } }); for nr in open_family_syscalls() { let hostname = hostname_for_open.clone(); @@ -1285,6 +1287,7 @@ mod handler_tests { chroot_mount_ro: Vec::new(), deterministic_dirs: false, virtual_hostname: None, + uname: Default::default(), has_http_acl: false, virtual_etc_hosts: String::new(), ca_inject_paths: Vec::new(), diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index b93c4915..a0c604ee 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -1349,6 +1349,7 @@ pub struct NotifPolicy { pub chroot_mount_ro: Vec, pub deterministic_dirs: bool, pub virtual_hostname: Option, + pub uname: crate::sandbox::Uname, pub has_http_acl: bool, /// Synthetic `/etc/hosts` served to the sandbox. Always populated: /// `openat("/etc/hosts")` returns a memfd with this content so the diff --git a/crates/sandlock-core/tests/integration/test_determinism.rs b/crates/sandlock-core/tests/integration/test_determinism.rs index f37d46bf..c0a118e9 100644 --- a/crates/sandlock-core/tests/integration/test_determinism.rs +++ b/crates/sandlock-core/tests/integration/test_determinism.rs @@ -207,7 +207,7 @@ async fn test_deterministic_dirs() { ); } -/// Test that hostname virtualizes both uname() and /etc/hostname. +/// Test that hostname virtualizes uname() and every file naming the host. #[tokio::test] async fn test_hostname_virtualization() { let policy = Sandbox::builder() @@ -239,21 +239,61 @@ async fn test_hostname_virtualization() { stdout.trim() ); - // Verify /etc/hostname also returns the virtual hostname. + for file in ["/etc/hostname", "/proc/sys/kernel/hostname"] { + let result = policy + .clone() + .with_name(&name) + .run(&["cat", file]) + .await + .unwrap(); + assert!(result.success(), "cat {file} failed"); + let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); + assert_eq!( + stdout.trim(), + name, + "Expected {file} {name:?}, got: {:?}", + stdout.trim() + ); + } +} + +/// A uname override must agree across uname() and every procfs file that +/// repeats the same field. +#[tokio::test] +async fn test_uname_override() { + let policy = Sandbox::builder() + .fs_read("/usr") + .fs_read("/lib") + .fs_read_if_exists("/lib64") + .fs_read("/bin") + .uname("release", "8.0.0-sandlock") + .uname("version", "#1 SMP fake") + .uname("machine", "riscv64") + .build() + .unwrap(); + let script = "uname -r; uname -v; uname -m; \ + cat /proc/sys/kernel/osrelease /proc/sys/kernel/version \ + /proc/sys/kernel/arch /proc/version"; let result = policy - .clone() - .with_name(&name) - .run(&["cat", "/etc/hostname"]) + .with_name(&format!("uname-{}", std::process::id())) + .run(&["sh", "-c", script]) .await .unwrap(); - assert!(result.success(), "cat /etc/hostname failed"); + assert!(result.success(), "uname probe failed: {result:?}"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); + let lines: Vec<&str> = stdout.lines().collect(); assert_eq!( - stdout.trim(), - name, - "Expected /etc/hostname {name:?}, got: {:?}", - stdout.trim() + lines[..6], + [ + "8.0.0-sandlock", + "#1 SMP fake", + "riscv64", + "8.0.0-sandlock", + "#1 SMP fake", + "riscv64" + ] ); + assert_eq!(lines[6], "Linux version 8.0.0-sandlock #1 SMP fake"); } /// The /etc/hostname shim used to do a literal `path == "/etc/hostname"` diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 3249c1c1..a172cadb 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -705,6 +705,16 @@ sandlock_builder_t *sandlock_sandbox_builder_env_var(sandlock_builder_t *b, const char *key, const char *value); +/** + * An unknown `field` or an oversized `value` fails the build. + * + * # Safety + * `b`, `field`, and `value` must be valid pointers. + */ +sandlock_builder_t *sandlock_sandbox_builder_uname(sandlock_builder_t *b, + const char *field, + const char *value); + /** * `timestamp` is RFC 3339 with an offset, e.g. "2000-01-01T00:00:00Z". A value * that does not parse fails the build with the reason; null returns null. diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index feacc17b..a41e1c2f 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -745,6 +745,25 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_env_var( Box::into_raw(Box::new(builder.env_var(key, value))) } +/// An unknown `field` or an oversized `value` fails the build. +/// +/// # Safety +/// `b`, `field`, and `value` must be valid pointers. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_uname( + b: *mut SandboxBuilder, + field: *const c_char, + value: *const c_char, +) -> *mut SandboxBuilder { + if b.is_null() || field.is_null() || value.is_null() { + return b; + } + let field = CStr::from_ptr(field).to_str().unwrap_or(""); + let value = CStr::from_ptr(value).to_str().unwrap_or(""); + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.uname(field, value))) +} + /// `timestamp` is RFC 3339 with an offset, e.g. "2000-01-01T00:00:00Z". A value /// that does not parse fails the build with the reason; null returns null. /// diff --git a/go/profile_linux.go b/go/profile_linux.go index 215f163d..442645bb 100644 --- a/go/profile_linux.go +++ b/go/profile_linux.go @@ -62,6 +62,7 @@ type resolvedProfile struct { GPUDevices []uint32 `json:"gpu_devices"` CPUCores []uint32 `json:"cpu_cores"` NumCPUs uint32 `json:"num_cpus"` + Uname map[string]string `json:"uname"` } // ParseProfile builds a Sandbox from profile TOML using sandlock's own @@ -116,7 +117,7 @@ func ParseProfile(toml string) (*Sandbox, error) { RandomSeed: r.RandomSeed, TimeStart: r.TimeStart, NoRandomizeMemory: r.NoRandomizeMemory, NoHugePages: r.NoHugePages, DeterministicDirs: r.DeterministicDirs, - CleanEnv: r.CleanEnv, Env: r.Env, + CleanEnv: r.CleanEnv, Env: r.Env, Uname: r.Uname, UID: r.UID, GID: r.GID, NoCoredump: r.NoCoredump, FSStorage: r.FSStorage, OnExit: onExit, OnError: onError, }, nil diff --git a/go/profile_linux_test.go b/go/profile_linux_test.go index 6b629238..51d16f37 100644 --- a/go/profile_linux_test.go +++ b/go/profile_linux_test.go @@ -34,6 +34,9 @@ allow_bind = [8080, "9000-9001"] [limits] memory = "64M" cpu = 50 + +[uname] +release = "8.0.0" `) if err != nil { t.Fatal(err) @@ -51,6 +54,7 @@ cpu = 50 NetAllowBind: []string{"8080", "9000-9001"}, MaxMemory: "64M", MaxCPU: 50, + Uname: map[string]string{"release": "8.0.0"}, } if !reflect.DeepEqual(sb, want) { t.Fatalf("got %+v\nwant %+v", sb, want) diff --git a/go/sandbox.go b/go/sandbox.go index fa53fc95..468f9fa6 100644 --- a/go/sandbox.go +++ b/go/sandbox.go @@ -307,6 +307,11 @@ type Sandbox struct { CleanEnv bool // start from a minimal environment Env map[string]string // variables to set/override in the child + // Kernel identity reported by uname() and the /proc files that repeat + // it, keyed by utsname field: release, version, sysname, machine, + // domainname. Unset fields show the host's values. + Uname map[string]string + // Misc. UID *int // map to this UID inside a user namespace; nil = unset GID *int // map to this GID inside the user namespace; must be set together with UID diff --git a/go/sandlock_linux.go b/go/sandlock_linux.go index e07fa2df..a223d3af 100644 --- a/go/sandlock_linux.go +++ b/go/sandlock_linux.go @@ -191,18 +191,13 @@ func (s *Sandbox) validateStrings() error { } } } - for _, m := range []map[string]string{s.FSMount, s.FSMountRO} { + for _, m := range []map[string]string{s.FSMount, s.FSMountRO, s.Env, s.Uname} { for k, v := range m { if hasNUL(k) || hasNUL(v) { return ErrInvalidString } } } - for k, v := range s.Env { - if hasNUL(k) || hasNUL(v) { - return ErrInvalidString - } - } if img := s.Image; img != nil { c := img.Config for _, g := range [][]string{{img.Rootfs, c.WorkingDir}, c.Entrypoint, c.Cmd, c.Env} { @@ -432,6 +427,12 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { C.free(unsafe.Pointer(ck)) C.free(unsafe.Pointer(cv)) } + for field, value := range s.Uname { + cf, cv := C.CString(field), C.CString(value) + b = C.sandlock_sandbox_builder_uname(b, cf, cv) + C.free(unsafe.Pointer(cf)) + C.free(unsafe.Pointer(cv)) + } // Misc. if s.UID != nil || s.GID != nil { diff --git a/go/sandlock_linux_test.go b/go/sandlock_linux_test.go index f1dd264d..79e60d04 100644 --- a/go/sandlock_linux_test.go +++ b/go/sandlock_linux_test.go @@ -144,6 +144,23 @@ func TestRunEmptyCommand(t *testing.T) { } } +func TestUnameOverride(t *testing.T) { + requireLandlock(t) + sb := &sandlock.Sandbox{FSReadable: rootfs, Uname: map[string]string{"release": "8.0.0"}} + res, err := sb.Run(context.Background(), "sh", "-c", "uname -r; cat /proc/sys/kernel/osrelease") + if err != nil { + t.Fatalf("Run: %v", err) + } + if got := string(res.Stdout); got != "8.0.0\n8.0.0\n" { + t.Fatalf("stdout = %q, stderr = %q", got, res.Stderr) + } + + bad := &sandlock.Sandbox{FSReadable: rootfs, Uname: map[string]string{"kernel": "8"}} + if _, err := bad.Run(context.Background(), "true"); err == nil || !strings.Contains(err.Error(), "unknown uname field") { + t.Fatalf("err = %v, want unknown uname field", err) + } +} + func TestRunNULRejected(t *testing.T) { sb := &sandlock.Sandbox{} if _, err := sb.Run(context.Background(), "echo", "a\x00b"); err != sandlock.ErrInvalidString { diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 25ce9ab9..ad3d6d5a 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -134,6 +134,7 @@ def _builder_fn(name, *extra_args): _b_random_seed = _builder_fn("sandlock_sandbox_builder_random_seed", ctypes.c_uint64) _b_clean_env = _builder_fn("sandlock_sandbox_builder_clean_env", ctypes.c_bool) _b_env_var = _builder_fn("sandlock_sandbox_builder_env_var", ctypes.c_char_p, ctypes.c_char_p) +_b_uname = _builder_fn("sandlock_sandbox_builder_uname", ctypes.c_char_p, ctypes.c_char_p) _b_time_start = _builder_fn("sandlock_sandbox_builder_time_start", ctypes.c_char_p) _b_extra_deny_syscalls = _builder_fn("sandlock_sandbox_builder_extra_deny_syscalls", ctypes.c_char_p) _b_extra_allow_syscalls = _builder_fn("sandlock_sandbox_builder_extra_allow_syscalls", ctypes.c_char_p) @@ -1314,7 +1315,7 @@ def __del__(self): "port_remap", "registration", "http_allow", "http_deny", "http_ports", "http_ca", "http_key", "uid", - "random_seed", "time_start", "clean_env", "env", + "random_seed", "time_start", "clean_env", "env", "uname", "extra_deny_syscalls", "extra_allow_syscalls", "max_open_files", "no_randomize_memory", "no_huge_pages", "no_coredump", "deterministic_dirs", # Landlock protection opt-out (see Protection IntEnum): @@ -1436,6 +1437,8 @@ def _build_from_policy(policy: PolicyDataclass): b = _b_clean_env(b, True) for k, v in (policy.env or {}).items(): b = _b_env_var(b, _encode(k), _encode(v)) + for field, value in policy.uname.items(): + b = _b_uname(b, _encode(field), _encode(value)) if policy.extra_deny_syscalls: b = _b_extra_deny_syscalls(b, _encode(",".join(policy.extra_deny_syscalls or []))) diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 481af63f..dd939566 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -438,6 +438,12 @@ class Sandbox: (renumbered 0..N-1). Also virtualizes /proc/meminfo when max_memory is set. Requires seccomp user notification (automatic).""" + uname: Mapping[str, str] = field(default_factory=dict) + """Kernel identity reported in place of the host's, keyed by + ``struct utsname`` field: ``release``, ``version``, ``sysname``, + ``machine``, ``domainname``. Also answers the /proc/sys/kernel files + and /proc/version that repeat them, e.g. ``{"release": "8.0.0"}``.""" + port_remap: bool = False """Enable transparent TCP port virtualization. Each sandbox gets a full virtual port space — bind(3000) is silently remapped to a unique diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index 631126ac..48eded32 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -1308,3 +1308,16 @@ def test_fs_storage_with_quota(self, tmp_path): ["sh", "-c", f"echo x >> {workdir}/big.bin"] ) assert not result.success + + +class TestUname: + def test_override_agrees_across_channels(self): + result = _policy(uname={"release": "8.0.0", "machine": "riscv64"}).run( + ["sh", "-c", "uname -r; uname -m; cat /proc/sys/kernel/osrelease"] + ) + assert result.success, result.error + assert result.stdout.decode().split() == ["8.0.0", "riscv64", "8.0.0"] + + def test_unknown_field_is_rejected(self): + with pytest.raises(RuntimeError, match="unknown uname field"): + _policy(uname={"kernel": "8.0.0"}).run(["true"])