use std::path::Path;
use crate::policy::sandbox_types::{
Cap, NetworkPolicy, PathMatch, RuleEffect, SandboxPolicy, resolve_symlinks,
};
use tracing::{Level, instrument};
use super::{SandboxError, SupportLevel};
#[instrument(level = Level::TRACE, skip(policy))]
pub fn exec_sandboxed(
policy: &SandboxPolicy,
cwd: &Path,
command: &[String],
_trace_path: Option<&Path>,
) -> Result<std::convert::Infallible, SandboxError> {
let cwd_str = cwd.to_string_lossy();
let profile = compile_to_sbpl(policy, &cwd_str);
let mut args = vec!["sandbox-exec".to_string()];
args.push("-p".to_string());
args.push(profile);
args.push("--".to_string());
args.extend_from_slice(command);
super::do_exec(&args)
}
#[instrument(level = Level::TRACE)]
pub fn check_support() -> SupportLevel {
if Path::new("/usr/bin/sandbox-exec").exists() {
SupportLevel::Full
} else {
SupportLevel::Unsupported {
reason: "/usr/bin/sandbox-exec not found".into(),
}
}
}
#[instrument(level = Level::TRACE)]
pub fn compile_to_sbpl(policy: &SandboxPolicy, cwd: &str) -> String {
let mut p = String::from("(version 1)\n(deny default)\n");
p += "(allow process-fork)\n";
p += "(allow sysctl-read)\n";
p += "(allow mach-lookup)\n";
p += "(allow mach-register)\n";
p += "(allow system-socket)\n";
emit_caps_for_path(&mut p, "/", policy.default, PathMatch::Subpath);
p += "(allow file-read* (literal \"/\"))\n";
p += "(allow file-write* (literal \"/dev/null\"))\n";
let mut ancestors: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
struct ResolvedRule {
effect: RuleEffect,
caps: Cap,
path_match: PathMatch,
canonical: String,
resolved: String,
}
let mut resolved_rules: Vec<ResolvedRule> = Vec::with_capacity(policy.rules.len());
for rule in &policy.rules {
let resolved = SandboxPolicy::resolve_path(&rule.path, cwd);
let resolved = if resolved.len() > 1 {
resolved.trim_end_matches('/').to_string()
} else {
resolved
};
let canonical = if rule.path_match != PathMatch::Regex {
resolve_symlinks(&resolved)
} else {
resolved.clone()
};
if rule.path_match != PathMatch::Regex {
for path in [&canonical, &resolved] {
let mut dir = path.as_str();
while let Some(pos) = dir.rfind('/') {
if pos == 0 {
break; }
dir = &dir[..pos];
if !ancestors.insert(dir.to_string()) {
break; }
}
}
}
resolved_rules.push(ResolvedRule {
effect: rule.effect,
caps: rule.caps,
path_match: rule.path_match,
canonical,
resolved,
});
}
resolved_rules.sort_by(|a, b| {
let depth_a = a.canonical.matches('/').count();
let depth_b = b.canonical.matches('/').count();
depth_a.cmp(&depth_b).then_with(|| {
let effect_ord = |e: &RuleEffect| match e {
RuleEffect::Deny => 0,
RuleEffect::Allow => 1,
};
effect_ord(&a.effect).cmp(&effect_ord(&b.effect))
})
});
for rule in &resolved_rules {
match rule.effect {
RuleEffect::Allow => {
emit_caps_for_path(&mut p, &rule.canonical, rule.caps, rule.path_match);
if rule.canonical != rule.resolved {
emit_caps_for_path(&mut p, &rule.resolved, rule.caps, rule.path_match);
}
}
RuleEffect::Deny => {
emit_deny_for_path(&mut p, &rule.canonical, rule.caps, rule.path_match);
if rule.canonical != rule.resolved {
emit_deny_for_path(&mut p, &rule.resolved, rule.caps, rule.path_match);
}
}
}
}
for ancestor in &ancestors {
p += &format!(
"(allow file-read* (literal \"{}\"))\n",
sbpl_escape(ancestor)
);
}
match &policy.network {
NetworkPolicy::Deny => {
p += "(deny network*)\n";
}
NetworkPolicy::Allow => {
p += "(allow network*)\n";
}
NetworkPolicy::Localhost | NetworkPolicy::AllowDomains(_) => {
p += "(allow network-outbound (remote ip \"localhost:*\"))\n";
p += "(deny network*)\n";
}
}
p
}
fn sbpl_escape(path: &str) -> String {
path.replace('\\', "\\\\").replace('"', "\\\"")
}
#[instrument(level = Level::TRACE)]
fn sbpl_filter(path: &str, path_match: PathMatch) -> String {
match path_match {
PathMatch::Subpath => format!("(subpath \"{}\")", sbpl_escape(path)),
PathMatch::Literal => format!("(literal \"{}\")", sbpl_escape(path)),
PathMatch::Regex => format!("(regex #\"{}\")", path),
}
}
#[instrument(level = Level::TRACE)]
fn emit_caps_for_path(profile: &mut String, path: &str, caps: Cap, path_match: PathMatch) {
let filter = sbpl_filter(path, path_match);
if caps.contains(Cap::READ) {
profile.push_str(&format!("(allow file-read* {})\n", filter));
}
if caps.contains(Cap::WRITE) {
profile.push_str(&format!("(allow file-write* {})\n", filter));
} else {
if caps.contains(Cap::CREATE) {
profile.push_str(&format!("(allow file-write-create {})\n", filter));
profile.push_str(&format!("(allow file-write-data {})\n", filter));
profile.push_str(&format!("(allow file-write-xattr {})\n", filter));
profile.push_str(&format!("(allow file-write-mode {})\n", filter));
profile.push_str(&format!("(allow file-write-flags {})\n", filter));
}
if caps.contains(Cap::DELETE) {
profile.push_str(&format!("(allow file-write-unlink {})\n", filter));
}
}
if caps.contains(Cap::EXECUTE) {
profile.push_str(&format!("(allow process-exec {})\n", filter));
}
}
#[instrument(level = Level::TRACE)]
fn emit_deny_for_path(profile: &mut String, path: &str, caps: Cap, path_match: PathMatch) {
let filter = sbpl_filter(path, path_match);
if caps.contains(Cap::READ) {
profile.push_str(&format!("(deny file-read* {})\n", filter));
}
if caps.contains(Cap::WRITE) {
profile.push_str(&format!("(deny file-write* {})\n", filter));
} else {
if caps.contains(Cap::CREATE) {
profile.push_str(&format!("(deny file-write-create {})\n", filter));
profile.push_str(&format!("(deny file-write-data {})\n", filter));
profile.push_str(&format!("(deny file-write-xattr {})\n", filter));
profile.push_str(&format!("(deny file-write-mode {})\n", filter));
profile.push_str(&format!("(deny file-write-flags {})\n", filter));
}
if caps.contains(Cap::DELETE) {
profile.push_str(&format!("(deny file-write-unlink {})\n", filter));
}
}
if caps.contains(Cap::EXECUTE) {
profile.push_str(&format!("(deny process-exec {})\n", filter));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::sandbox_types::SandboxRule;
#[test]
fn escape_normal_path_unchanged() {
assert_eq!(sbpl_escape("/usr/local/bin"), "/usr/local/bin");
}
#[test]
fn escape_path_with_double_quote() {
assert_eq!(sbpl_escape("/tmp/evil\"path"), "/tmp/evil\\\"path");
}
#[test]
fn escape_path_with_backslash() {
assert_eq!(sbpl_escape("/tmp/evil\\path"), "/tmp/evil\\\\path");
}
#[test]
fn escape_path_with_both_backslash_and_quote() {
assert_eq!(sbpl_escape("/tmp/e\\vi\"l"), "/tmp/e\\\\vi\\\"l");
}
#[test]
fn filter_subpath_normal() {
assert_eq!(
sbpl_filter("/usr/local", PathMatch::Subpath),
"(subpath \"/usr/local\")"
);
}
#[test]
fn filter_literal_normal() {
assert_eq!(
sbpl_filter("/usr/local/bin/rustc", PathMatch::Literal),
"(literal \"/usr/local/bin/rustc\")"
);
}
#[test]
fn filter_regex_normal() {
assert_eq!(
sbpl_filter("/tmp/build-.*", PathMatch::Regex),
"(regex #\"/tmp/build-.*\")"
);
}
#[test]
fn filter_subpath_with_quote_injection() {
let malicious = "/tmp/evil\") (allow default) (\"";
let result = sbpl_filter(malicious, PathMatch::Subpath);
assert_eq!(result, "(subpath \"/tmp/evil\\\") (allow default) (\\\"\")");
assert!(result.starts_with("(subpath \""));
assert!(result.ends_with("\")"));
}
#[test]
fn filter_literal_with_backslash_and_quote() {
let adversarial = "/tmp/a\\b\"c";
let result = sbpl_filter(adversarial, PathMatch::Literal);
assert_eq!(result, "(literal \"/tmp/a\\\\b\\\"c\")");
}
#[test]
fn filter_regex_is_not_escaped() {
let pattern = "/tmp/foo\"bar";
let result = sbpl_filter(pattern, PathMatch::Regex);
assert_eq!(result, "(regex #\"/tmp/foo\"bar\")");
}
#[test]
fn sbpl_localhost_allows_only_loopback() {
let policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Localhost,
doc: None,
};
let profile = compile_to_sbpl(&policy, "/tmp");
assert!(
profile.contains("(allow network-outbound (remote ip \"localhost:*\"))"),
"Localhost policy should allow outbound to localhost"
);
assert!(
profile.contains("(deny network*)"),
"Localhost policy should deny all other network"
);
}
#[test]
fn specific_allow_overrides_broad_deny() {
let policy = SandboxPolicy {
default: Cap::EXECUTE,
rules: vec![
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::READ | Cap::WRITE,
path: "/Users/eliot".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::READ | Cap::WRITE | Cap::CREATE | Cap::DELETE | Cap::EXECUTE,
path: "/Users".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
],
network: NetworkPolicy::Deny,
doc: None,
};
let profile = compile_to_sbpl(&policy, "/tmp");
let deny_pos = profile
.find("(deny file-read* (subpath \"/Users\"))")
.expect("should contain deny on /Users");
let allow_pos = profile
.find("(allow file-read* (subpath \"/Users/eliot\"))")
.expect("should contain allow on /Users/eliot");
assert!(
deny_pos < allow_pos,
"deny /Users (pos {deny_pos}) must come before allow /Users/eliot (pos {allow_pos})\nprofile:\n{profile}"
);
}
#[test]
fn deny_at_same_depth_loses_to_allow() {
let policy = SandboxPolicy {
default: Cap::empty() | Cap::EXECUTE,
rules: vec![
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::WRITE,
path: "/data".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: "/data".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
],
network: NetworkPolicy::Deny,
doc: None,
};
let profile = compile_to_sbpl(&policy, "/tmp");
let deny_pos = profile
.find("(deny file-write* (subpath \"/data\"))")
.expect("should contain deny write on /data");
let allow_pos = profile
.find("(allow file-write* (subpath \"/data\"))")
.expect("should contain allow write on /data");
assert!(
deny_pos < allow_pos,
"deny should come before allow at same depth\nprofile:\n{profile}"
);
}
#[test]
fn three_level_specificity() {
let policy = SandboxPolicy {
default: Cap::EXECUTE,
rules: vec![
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::READ,
path: "/Users/eliot".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::READ,
path: "/Users".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::READ,
path: "/Users/eliot/.ssh".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
],
network: NetworkPolicy::Deny,
doc: None,
};
let profile = compile_to_sbpl(&policy, "/tmp");
let deny_users = profile
.find("(deny file-read* (subpath \"/Users\"))")
.expect("deny /Users");
let allow_eliot = profile
.find("(allow file-read* (subpath \"/Users/eliot\"))")
.expect("allow /Users/eliot");
let deny_ssh = profile
.find("(deny file-read* (subpath \"/Users/eliot/.ssh\"))")
.expect("deny /Users/eliot/.ssh");
assert!(
deny_users < allow_eliot,
"deny /Users must come before allow /Users/eliot"
);
assert!(
allow_eliot < deny_ssh,
"allow /Users/eliot must come before deny /Users/eliot/.ssh"
);
}
#[test]
fn sbpl_localhost_same_as_allow_domains() {
let localhost_policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Localhost,
doc: None,
};
let domains_policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::AllowDomains(vec!["example.com".into()]),
doc: None,
};
let localhost_profile = compile_to_sbpl(&localhost_policy, "/tmp");
let domains_profile = compile_to_sbpl(&domains_policy, "/tmp");
assert_eq!(localhost_profile, domains_profile);
}
}