use std::process::Command;
use tracing::{debug, info};
use crate::policy::{SandboxPolicy, NetworkPolicy, PathPermission};
use crate::error::SandboxError;
use crate::Sandbox;
pub struct SeatbeltSandbox {
policy: SandboxPolicy,
policy_string: String,
}
impl SeatbeltSandbox {
pub fn new(policy: SandboxPolicy) -> Result<Self, SandboxError> {
let policy_string = generate_seatbelt_policy(&policy)?;
debug!(policy = %policy_string, "Generated Seatbelt policy");
Ok(Self {
policy,
policy_string,
})
}
pub fn policy_string(&self) -> &str {
&self.policy_string
}
}
impl Sandbox for SeatbeltSandbox {
fn wrap_command(&self, mut cmd: Command) -> Result<Command, SandboxError> {
let mut sandbox_cmd = Command::new("/usr/bin/sandbox-exec");
sandbox_cmd.arg("-p").arg(&self.policy_string);
let program = cmd.get_program().to_string_lossy().to_string();
sandbox_cmd.arg(program);
for arg in cmd.get_args() {
sandbox_cmd.arg(arg);
}
for var in &self.policy.env_passthrough {
if let Ok(value) = std::env::var(var) {
sandbox_cmd.env(var, value);
}
}
if let Some(cwd) = cmd.get_current_dir() {
sandbox_cmd.current_dir(cwd);
}
info!("Wrapped command with Seatbelt sandbox");
Ok(sandbox_cmd)
}
fn is_available() -> bool {
std::path::Path::new("/usr/bin/sandbox-exec").exists()
}
fn sandbox_type(&self) -> &'static str {
"seatbelt"
}
}
fn generate_seatbelt_policy(policy: &SandboxPolicy) -> Result<String, SandboxError> {
let mut sbpl = String::new();
sbpl.push_str("(version 1)\n");
sbpl.push_str("(deny default)\n\n");
sbpl.push_str("; Basic operations\n");
sbpl.push_str("(allow signal (target self))\n");
sbpl.push_str("(allow sysctl-read)\n");
sbpl.push_str("(allow mach-lookup)\n");
sbpl.push_str("(allow ipc-posix-shm-read-data)\n");
sbpl.push_str("(allow ipc-posix-shm-write-data)\n");
if policy.allow_spawn {
sbpl.push_str("\n; Process operations\n");
sbpl.push_str("(allow process-fork)\n");
sbpl.push_str("(allow process-exec)\n");
}
sbpl.push_str("\n; Read paths\n");
for perm in &policy.read_paths {
sbpl.push_str(&format_read_rule(perm));
}
if policy.flags.allow_system_read {
sbpl.push_str("(allow file-read* (subpath \"/usr\"))\n");
sbpl.push_str("(allow file-read* (subpath \"/lib\"))\n");
sbpl.push_str("(allow file-read* (subpath \"/bin\"))\n");
sbpl.push_str("(allow file-read* (subpath \"/sbin\"))\n");
sbpl.push_str("(allow file-read* (subpath \"/System\"))\n");
sbpl.push_str("(allow file-read* (subpath \"/Library\"))\n");
sbpl.push_str("(allow file-read* (subpath \"/private/var\"))\n");
}
if policy.flags.allow_tmp {
sbpl.push_str("(allow file-read* (subpath \"/tmp\"))\n");
sbpl.push_str("(allow file-write* (subpath \"/tmp\"))\n");
sbpl.push_str("(allow file-read* (subpath \"/private/tmp\"))\n");
sbpl.push_str("(allow file-write* (subpath \"/private/tmp\"))\n");
}
sbpl.push_str("\n; Write paths\n");
for perm in &policy.write_paths {
sbpl.push_str(&format_write_rule(perm));
}
sbpl.push_str("\n; Execute paths\n");
for perm in &policy.exec_paths {
sbpl.push_str(&format_exec_rule(perm));
}
sbpl.push_str("\n; Network\n");
match &policy.network {
NetworkPolicy::None => {
sbpl.push_str("; Network access denied\n");
}
NetworkPolicy::Localhost => {
sbpl.push_str("(allow network-outbound (local ip \"localhost:*\"))\n");
sbpl.push_str("(allow network-inbound (local ip \"localhost:*\"))\n");
}
NetworkPolicy::Full => {
sbpl.push_str("(allow network-outbound)\n");
sbpl.push_str("(allow network-inbound)\n");
}
NetworkPolicy::Allowlist(rules) => {
for rule in rules {
let port = rule.port.map(|p| format!(":{}", p)).unwrap_or_else(|| ":*".to_string());
sbpl.push_str(&format!("(allow network-outbound (remote ip \"{}{}\" ))\n", rule.host, port));
}
}
}
Ok(sbpl)
}
fn format_read_rule(perm: &PathPermission) -> String {
let path = perm.path.to_string_lossy();
if perm.recursive {
format!("(allow file-read* (subpath \"{}\"))\n", path)
} else {
format!("(allow file-read* (literal \"{}\"))\n", path)
}
}
fn format_write_rule(perm: &PathPermission) -> String {
let path = perm.path.to_string_lossy();
if perm.recursive {
format!("(allow file-write* (subpath \"{}\"))\n", path)
} else {
format!("(allow file-write* (literal \"{}\"))\n", path)
}
}
fn format_exec_rule(perm: &PathPermission) -> String {
let path = perm.path.to_string_lossy();
if perm.recursive {
format!("(allow process-exec (subpath \"{}\"))\n", path)
} else {
format!("(allow process-exec (literal \"{}\"))\n", path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_policy_generation() {
let policy = SandboxPolicy::default_for_tools(PathBuf::from("/tmp/test"));
let sbpl = generate_seatbelt_policy(&policy).unwrap();
assert!(sbpl.contains("(version 1)"));
assert!(sbpl.contains("(deny default)"));
assert!(sbpl.contains("/tmp/test"));
}
#[test]
fn test_network_policy() {
let policy = SandboxPolicy::new().allow_localhost();
let sbpl = generate_seatbelt_policy(&policy).unwrap();
assert!(sbpl.contains("localhost"));
}
}