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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ struct RunArgs {
#[arg(long = "env", value_name = "KEY=VALUE")]
env_vars: Vec<String>,

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

/// CPU cores to pin the sandbox to (e.g. --cpu-cores 0,2,3)
#[arg(long = "cpu-cores", value_delimiter = ',')]
cpu_cores: Vec<u32>,
Expand Down Expand Up @@ -612,6 +617,7 @@ async fn run_command(args: RunArgs) -> Result<i32> {
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);
}
Expand Down Expand Up @@ -816,6 +822,9 @@ async fn run_command(args: RunArgs) -> Result<i32> {
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.
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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");
}
Expand Down
32 changes: 31 additions & 1 deletion crates/sandlock-core/src/checkpoint/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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-{}-{}",
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion crates/sandlock-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
126 changes: 113 additions & 13 deletions crates/sandlock-core/src/procfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<VirtualFile> {
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,
Expand All @@ -171,8 +179,20 @@ pub(crate) fn virtual_file(path: &str, policy: &NotifPolicy) -> Option<VirtualFi
"/proc/self/mountinfo" => 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,
})
}
Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -283,6 +309,48 @@ pub(crate) fn filter_proc_stat(real: &str, num_cpus: u32) -> Vec<u8> {
out.into_bytes()
}

fn uts_line(value: &Option<String>) -> Vec<u8> {
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<String>, 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<u8> = 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
// ============================================================
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
41 changes: 40 additions & 1 deletion crates/sandlock-core/src/profile.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -348,6 +350,23 @@ pub struct ResolvedProfile {
pub cpu_cores: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_cpus: Option<u32>,

#[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<S: serde::Serializer>(uname: &Uname, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_map(
uname
.fields()
.into_iter()
.filter_map(|(field, value)| value.as_ref().map(|v| (field, v))),
)
}

fn pairs_as_map<S: serde::Serializer>(
Expand Down Expand Up @@ -446,6 +465,8 @@ pub fn resolve(input: ProfileInput) -> Result<ResolvedProfile, SandlockError> {
gpu_devices: l.gpu_devices,
cpu_cores: l.cpu_cores,
num_cpus: l.num_cpus,

uname: input.uname,
})
}

Expand Down Expand Up @@ -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((
Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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#"
Expand Down
1 change: 1 addition & 0 deletions crates/sandlock-core/src/resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading