use std::ffi::CString;
use crate::config::NetMode;
use crate::policy::Policy;
pub fn generate_sbpl_template_and_params(
policy: &Policy,
net: &NetMode,
) -> (String, Vec<(String, String)>) {
let mut sb = String::with_capacity(2048);
let mut params = Vec::new();
sb.push_str("(version 1)\n(deny default)\n");
sb.push_str("(allow process-exec)\n(allow process-fork)\n");
sb.push_str("(allow sysctl-read)\n");
sb.push_str("(allow mach-lookup)\n");
sb.push_str("(allow file-read* (subpath \"/\"))\n");
for (i, p) in policy.allow_write.iter().enumerate() {
let key = format!("ALLOW_WRITE_DIR_{i}");
let val = p.display().to_string();
sb.push_str(&format!(
"(allow file-write* (subpath (param \"{key}\")))\n"
));
params.push((key, val));
}
for (i, d) in policy.deny_resolved.iter().enumerate() {
let key = format!("DENY_PATH_{i}");
let val = d.path.display().to_string();
sb.push_str(&format!("(deny file-read* (subpath (param \"{key}\")))\n"));
sb.push_str(&format!("(deny file-write* (subpath (param \"{key}\")))\n"));
params.push((key, val));
}
match net {
NetMode::Off | NetMode::Allowlist(_) | NetMode::Strict(_) | NetMode::Ask => {
sb.push_str("(deny network*)\n");
sb.push_str("(allow network-outbound (remote unix-socket))\n");
}
}
sb.push_str("\n; generated by vetto native seatbelt\n");
(sb, params)
}
pub fn generate(policy: &Policy, net: &NetMode) -> String {
let (template, params) = generate_sbpl_template_and_params(policy, net);
let mut inlined = template;
for (k, v) in params {
let placeholder = format!("(param \"{k}\")");
let escaped_val = format!("\"{}\"", sb_escape(&v));
inlined = inlined.replace(&placeholder, &escaped_val);
}
inlined
}
fn sb_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub fn is_native_seatbelt_available() -> bool {
#[cfg(target_os = "macos")]
{
let lib = unsafe {
libc::dlopen(
b"libsandbox.1.dylib\0".as_ptr().cast(),
libc::RTLD_LAZY | libc::RTLD_LOCAL,
)
};
if lib.is_null() {
return false;
}
let sym = unsafe { libc::dlsym(lib, b"sandbox_init_with_parameters\0".as_ptr().cast()) };
!sym.is_null()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
pub fn apply_seatbelt(policy: &Policy, net: &NetMode) -> Result<(), String> {
let (template, params) = generate_sbpl_template_and_params(policy, net);
apply_seatbelt_raw(&template, ¶ms)
}
pub fn apply_seatbelt_raw(profile: &str, params: &[(String, String)]) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let lib = unsafe {
libc::dlopen(
b"libsandbox.1.dylib\0".as_ptr().cast(),
libc::RTLD_LAZY | libc::RTLD_LOCAL,
)
};
if lib.is_null() {
return Err("dlopen libsandbox.1.dylib failed".to_string());
}
let init_sym =
unsafe { libc::dlsym(lib, b"sandbox_init_with_parameters\0".as_ptr().cast()) };
let free_sym = unsafe { libc::dlsym(lib, b"sandbox_free_error\0".as_ptr().cast()) };
if init_sym.is_null() {
return Err("dlsym sandbox_init_with_parameters failed".to_string());
}
type SandboxInitWithParameters = unsafe extern "C" fn(
profile: *const libc::c_char,
flags: u64,
parameters: *const *const libc::c_char,
errorbuf: *mut *mut libc::c_char,
) -> libc::c_int;
type SandboxFreeError = unsafe extern "C" fn(errorbuf: *mut libc::c_char);
let sandbox_init: SandboxInitWithParameters = unsafe {
std::mem::transmute::<*mut libc::c_void, SandboxInitWithParameters>(init_sym)
};
let sandbox_free: Option<SandboxFreeError> = if !free_sym.is_null() {
Some(unsafe { std::mem::transmute::<*mut libc::c_void, SandboxFreeError>(free_sym) })
} else {
None
};
let profile_c = CString::new(profile).map_err(|e| format!("profile CString: {e}"))?;
let mut cstrings: Vec<CString> = Vec::with_capacity(params.len() * 2);
for (k, v) in params {
cstrings.push(CString::new(k.as_str()).map_err(|e| format!("param key CString: {e}"))?);
cstrings.push(CString::new(v.as_str()).map_err(|e| format!("param val CString: {e}"))?);
}
let mut param_ptrs: Vec<*const libc::c_char> =
cstrings.iter().map(|s| s.as_ptr()).collect();
param_ptrs.push(std::ptr::null());
let mut errorbuf: *mut libc::c_char = std::ptr::null_mut();
let ret =
unsafe { sandbox_init(profile_c.as_ptr(), 0, param_ptrs.as_ptr(), &mut errorbuf) };
if ret != 0 {
let err_msg = if !errorbuf.is_null() {
let msg = unsafe { std::ffi::CStr::from_ptr(errorbuf) }
.to_string_lossy()
.into_owned();
if let Some(free_fn) = sandbox_free {
unsafe { free_fn(errorbuf) };
}
msg
} else {
format!("sandbox_init_with_parameters failed with code {ret}")
};
return Err(err_msg);
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
let _ = (profile, params);
Err("Seatbelt is only available on macOS".to_string())
}
}
pub const SBPL_MAXIMUM_READ_SHAPE: &str = "shape-A-broad-plus-tail-deny";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SbplFragmentStatus {
Broken,
Ok,
}
impl SbplFragmentStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Broken => "broken (Apple regression)",
Self::Ok => "ok",
}
}
pub fn parse_from_output(success: bool, output: &str) -> Self {
if success && output.trim() == "sbpl-probe-ok" {
Self::Ok
} else {
Self::Broken
}
}
}
pub fn probe_sbpl_read_fragment() -> SbplFragmentStatus {
#[cfg(target_os = "macos")]
{
let temp_dir = std::env::temp_dir();
let probe_file = temp_dir.join(format!("vetto-sbpl-probe-{}.txt", std::process::id()));
if std::fs::write(&probe_file, "sbpl-probe-ok").is_err() {
return SbplFragmentStatus::Broken;
}
let probe_path_str = probe_file.to_string_lossy();
let profile = format!(
"(version 1)\n(deny default)\n(allow process-exec)\n(allow process-fork)\n(allow sysctl-read)\n(allow mach-lookup)\n(allow file-read* (literal \"{probe_path_str}\"))\n(allow file-read* (subpath \"/bin\"))\n(allow file-read* (subpath \"/usr\"))\n(allow file-read* (subpath \"/lib\"))\n(allow file-read* (subpath \"/System\"))\n"
);
let output = std::process::Command::new("/usr/bin/sandbox-exec")
.arg("-p")
.arg(profile)
.arg("/bin/cat")
.arg(&probe_file)
.output();
let _ = std::fs::remove_file(&probe_file);
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
SbplFragmentStatus::parse_from_output(out.status.success(), &stdout)
}
Err(_) => SbplFragmentStatus::Broken,
}
}
#[cfg(not(target_os = "macos"))]
{
SbplFragmentStatus::Broken
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn sbpl_fragment_status_parsing() {
assert_eq!(
SbplFragmentStatus::parse_from_output(true, "sbpl-probe-ok\n"),
SbplFragmentStatus::Ok
);
assert_eq!(
SbplFragmentStatus::parse_from_output(false, "sbpl-probe-ok\n"),
SbplFragmentStatus::Broken
);
assert_eq!(
SbplFragmentStatus::parse_from_output(true, "something else"),
SbplFragmentStatus::Broken
);
assert_eq!(
SbplFragmentStatus::Broken.as_str(),
"broken (Apple regression)"
);
assert_eq!(SbplFragmentStatus::Ok.as_str(), "ok");
}
#[test]
fn template_generation_contains_expected_params() {
let mut policy = Policy::default();
policy.allow_read.push(PathBuf::from("/test/read"));
policy.allow_write.push(PathBuf::from("/test/write"));
policy.deny_resolved.push(crate::policy::DenyEntry {
path: PathBuf::from("/test/secret"),
is_dir: false,
});
let (template, params) = generate_sbpl_template_and_params(&policy, &NetMode::Off);
assert!(template.contains("(param \"ALLOW_WRITE_DIR_0\")"));
assert!(template.contains("(param \"DENY_PATH_0\")"));
assert!(!template.contains("ALLOW_READ_DIR"));
assert!(template.contains("(deny network*)"));
assert!(template.contains("(allow network-outbound (remote unix-socket))"));
assert_eq!(params.len(), 2);
assert_eq!(
params[0],
("ALLOW_WRITE_DIR_0".to_string(), "/test/write".to_string())
);
assert_eq!(
params[1],
("DENY_PATH_0".to_string(), "/test/secret".to_string())
);
let inlined = generate(&policy, &NetMode::Off);
assert!(inlined.contains("\"/test/write\""));
assert!(inlined.contains("\"/test/secret\""));
}
}