use std::fmt::Write;
use std::path::Path;
use std::process::Command;
use super::{JailConfig, Sandbox, SandboxCapabilities, SandboxKind};
const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct SeatbeltSandbox;
impl Sandbox for SeatbeltSandbox {
fn wrap(&self, shim: &Path, config_path: &Path, jail: &JailConfig) -> Option<Command> {
if !Path::new(SANDBOX_EXEC).is_file() {
return None;
}
let profile = generate_profile(shim, config_path, jail);
let mut cmd = Command::new(SANDBOX_EXEC);
cmd.args(["-p", &profile, "--"]);
cmd.arg(shim);
cmd.arg(config_path);
Some(cmd)
}
fn capabilities(&self) -> SandboxCapabilities {
SandboxCapabilities {
namespaces: false,
seccomp: false,
mandatory_access_control: true,
}
}
fn kind(&self) -> SandboxKind {
SandboxKind::Seatbelt
}
}
fn generate_profile(shim: &Path, config_path: &Path, config: &JailConfig) -> String {
let mut p = String::with_capacity(1024);
p.push_str("(version 1)\n(deny default)\n\n");
p.push_str("(allow process-exec process-fork)\n");
p.push_str("(allow signal (target same-sandbox))\n");
p.push_str("(allow process-info* (target same-sandbox))\n");
p.push_str("(allow sysctl-read)\n\n");
p.push_str("(allow file-read*\n");
p.push_str(" (literal \"/\")\n");
p.push_str(" (literal \"/var\")\n");
p.push_str(" (literal \"/tmp\")\n");
p.push_str(" (subpath \"/usr/lib\")\n");
p.push_str(" (subpath \"/System\")\n");
p.push_str(" (subpath \"/Library/Frameworks\")\n");
p.push_str(" (subpath \"/private/var/db/dyld\")\n");
p.push_str(" (literal \"/dev/null\")\n");
p.push_str(" (literal \"/dev/urandom\")\n");
p.push_str(" (literal \"/dev/random\")\n");
p.push_str(")\n\n");
p.push_str("(allow file-read* file-write*\n");
p.push_str(" (subpath \"/private/tmp\")\n");
p.push_str(" (subpath \"/private/var/tmp\")\n");
p.push_str(" (subpath \"/private/var/folders\")\n");
p.push_str(")\n\n");
if let Some(shim_dir) = shim.parent() {
allow_read(&mut p, &shim_dir.to_string_lossy());
} else {
allow_read(&mut p, &shim.to_string_lossy());
}
allow_read(&mut p, &config_path.to_string_lossy());
allow_readwrite(&mut p, &config.socks_dir.to_string_lossy());
if let Some(rootfs) = &config.rootfs {
allow_readwrite(&mut p, &rootfs.to_string_lossy());
}
if let Some(disk) = &config.root_disk {
allow_readwrite(&mut p, &disk.to_string_lossy());
}
for path in &config.readonly_paths {
allow_read(&mut p, &path.to_string_lossy());
}
for path in &config.virtiofs_paths {
allow_readwrite(&mut p, &path.to_string_lossy());
}
p.push_str("(allow iokit-open\n");
p.push_str(" (iokit-registry-entry-class \"RootDomainUserClient\")\n");
p.push_str(")\n");
p.push_str("(allow mach-lookup\n");
p.push_str(" (global-name \"com.apple.system.opendirectoryd.libinfo\")\n");
p.push_str(" (global-name \"com.apple.PowerManagement.control\")\n");
p.push_str(" (global-name \"com.apple.logd\")\n");
p.push_str(" (global-name \"com.apple.system.notification_center\")\n");
p.push_str(")\n");
p.push_str("(allow network-bind network-outbound\n");
p.push_str(" (local unix)\n");
p.push_str(" (remote unix)\n");
p.push_str(")\n");
if config.network_host {
p.push_str("\n(allow network-outbound)\n");
p.push_str("(allow network-inbound)\n");
p.push_str("(allow system-socket)\n");
p.push_str("(allow mach-lookup\n");
p.push_str(" (global-name \"com.apple.SystemConfiguration.DNSConfiguration\")\n");
p.push_str(" (global-name \"com.apple.SystemConfiguration.configd\")\n");
p.push_str(" (global-name \"com.apple.networkd\")\n");
p.push_str(")\n");
p.push_str("(allow file-read*\n");
p.push_str(" (literal \"/etc/resolv.conf\")\n");
p.push_str(" (subpath \"/etc/resolv.conf\")\n");
p.push_str(" (literal \"/private/etc/resolv.conf\")\n");
p.push_str(" (subpath \"/private/etc/resolv.conf\")\n");
p.push_str(" (literal \"/etc/hosts\")\n");
p.push_str(" (subpath \"/etc/hosts\")\n");
p.push_str(" (literal \"/private/etc/hosts\")\n");
p.push_str(" (subpath \"/private/etc/hosts\")\n");
p.push_str(" (literal \"/etc/nsswitch.conf\")\n");
p.push_str(" (subpath \"/etc/nsswitch.conf\")\n");
p.push_str(")\n");
}
p
}
fn allow_read(profile: &mut String, path: &str) {
allow_path(profile, path, false);
}
fn allow_readwrite(profile: &mut String, path: &str) {
allow_path(profile, path, true);
}
#[allow(clippy::missing_docs_in_private_items, reason = "private helper")]
fn allow_path(profile: &mut String, path: &str, write: bool) {
emit_path_rule(profile, path, write);
if let Ok(canonical_path) = std::fs::canonicalize(path) {
let canonical = canonical_path.to_string_lossy();
if canonical != path {
emit_path_rule(profile, &canonical, write);
}
}
}
#[allow(clippy::missing_docs_in_private_items, reason = "private helper")]
fn emit_path_rule(profile: &mut String, path: &str, write: bool) {
if Path::new(path).is_dir() {
if write {
writeln!(
profile,
"(allow file-read* file-write* (literal \"{path}\"))"
)
.ok();
writeln!(
profile,
"(allow file-read* file-write* (subpath \"{path}\"))"
)
.ok();
} else {
writeln!(profile, "(allow file-read* (literal \"{path}\"))").ok();
writeln!(profile, "(allow file-read* (subpath \"{path}\"))").ok();
}
} else if write {
writeln!(
profile,
"(allow file-read* file-write* (literal \"{path}\"))"
)
.ok();
} else {
writeln!(profile, "(allow file-read* (literal \"{path}\"))").ok();
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "unit tests")]
mod tests {
use super::*;
use crate::JailConfig;
use std::path::PathBuf;
fn jail(network_host: bool) -> JailConfig {
JailConfig {
rootfs: None,
root_disk: None,
readonly_paths: vec![],
socks_dir: PathBuf::from("/tmp/bux-socks"),
virtiofs_paths: vec![],
watchdog_fd: None,
sandbox: None,
stderr_file: None,
landlock: false,
allow_degraded_security: false,
die_with_parent: true,
network_host,
bwrap_path: None,
}
}
#[test]
fn network_host_allows_outbound_and_resolver() {
let profile = generate_profile(
Path::new("/usr/bin/true"),
Path::new("/tmp/cfg.json"),
&jail(true),
);
assert!(profile.contains("(allow network-outbound)"));
assert!(profile.contains("(allow network-inbound)"));
assert!(profile.contains("(allow system-socket)"));
assert!(profile.contains("com.apple.SystemConfiguration.DNSConfiguration"));
assert!(profile.contains("com.apple.networkd"));
assert!(profile.contains("/etc/resolv.conf"));
assert!(profile.contains("/etc/hosts"));
assert!(!profile.contains("DARWIN_USER_CACHE_DIR"));
assert!(!profile.contains("SecurityServer"));
assert!(!profile.contains("ocspd"));
assert!(!profile.contains("trustd"));
}
#[test]
fn offline_omits_host_network_rules() {
let profile = generate_profile(
Path::new("/usr/bin/true"),
Path::new("/tmp/cfg.json"),
&jail(false),
);
assert!(!profile.contains("(allow network-outbound)"));
assert!(!profile.contains("com.apple.networkd"));
assert!(profile.contains("(local unix)"));
assert!(profile.contains("(remote unix)"));
assert!(profile.contains("file-read* file-write*"));
}
}