use std::fmt::Write as _;
use openmoat_core::Kind;
use openmoat_core::ir::{Access, Effect, Enforcement, Rule};
use super::patterns::{RUNS_LATER, literal_tree, runs_later_exclusion, split};
use super::{Grants, Report};
const PLATFORM_READ: [&str; 9] = [
"/",
"/dev/null",
"/dev/zero",
"/dev/random",
"/dev/urandom",
"/dev/tty",
"/dev/ttys[0-9]*",
"/dev/fd/**",
"/private/var/select/**",
];
const PLATFORM_WRITE: [&str; 6] = [
"/dev/null",
"/dev/zero",
"/dev/tty",
"/dev/dtracehelper",
"/dev/ttys[0-9]*",
"/dev/fd/**",
];
const PROCESS: &str = "\
(allow process-exec process-fork)
(allow signal (target same-sandbox))
(allow process-info* (target same-sandbox))
(allow sysctl-read)
(allow pseudo-tty)
(allow file-ioctl)
(allow ipc-posix-shm* ipc-posix-sem)
(allow mach-lookup
(global-name \"com.apple.system.opendirectoryd.libinfo\")
(global-name \"com.apple.system.opendirectoryd.membership\"))
";
const KEYCHAIN: &str = "\
; never the keychain: the agent and its scripts share this profile
(deny mach-lookup
(global-name \"com.apple.SecurityServer\")
(global-name \"com.apple.securityd\"))
";
const TERMINAL_INPUT: &str = "\
; never typing into the user's terminal: its shell would run it after the session
(deny file-ioctl (ioctl-command TIOCSTI))
";
#[derive(Debug, Clone)]
pub struct Generated {
pub profile: String,
pub report: Report,
}
pub fn generate(ir: &Enforcement, grants: &Grants) -> Generated {
let mut p = Generated {
profile: String::from(
"(version 1)\n\
; generated by OpenMoat from policy.yaml for `moat run` (Lightweight tier, ADR-018)\n\
(deny default)\n",
),
report: Report::default(),
};
p.profile.push_str(PROCESS);
p.profile
.push_str("; seatbelt.metadata\n(allow file-read-metadata)\n");
let metadata = "the existence, size and times of every file stay visible (stat); \
contents follow the rules";
p.report
.allowance(Kind::FsRead, "seatbelt.metadata", Vec::new(), metadata);
let platform = |list: &[&str]| list.iter().map(|p| (*p).to_owned()).collect();
let (read, write) = (Kind::FsRead, Kind::FsWrite);
let starts = "every process reads these to start";
p.grant(
read,
"seatbelt.platform",
"file-read*",
platform(&PLATFORM_READ),
starts,
);
let devices = "the devices every process writes";
p.grant(
write,
"seatbelt.platform",
"file-write-data",
platform(&PLATFORM_WRITE),
devices,
);
if let Some(program) = &grants.program {
let message = "the program `moat run` starts, which must be readable to start";
p.grant(
read,
"seatbelt.program",
"file-read*",
vec![program.clone()],
message,
);
}
p.allows(&ir.fs.read, "allow file-read*");
for allowance in &ir.allowances {
rule(
&mut p.profile,
&allowance.rule,
"allow file-read*",
&allowance.patterns,
);
p.report.allowances.push(allowance.clone());
}
p.allows(&ir.fs.write, "allow file-write*");
let both = "file-read* file-write*";
if let Some(tmpdir) = &grants.tmpdir {
let message = "commands may read and write the temp directory; the hook asks";
p.grant(
write,
"seatbelt.tmpdir",
both,
vec![format!("{tmpdir}/**")],
message,
);
}
if !grants.writes.is_empty() {
let trees = grants.writes.iter().map(|w| format!("{w}/**")).collect();
let message = "`moat run --write`: commands may read and write these; deny rules still win";
p.grant(write, "seatbelt.write", both, trees, message);
}
rules(
&mut p.profile,
&ir.fs.read.deny,
"deny file-read-data file-read-xattr",
);
rules(&mut p.profile, &ir.fs.write.deny, "deny file-write*");
p.profile.push_str(KEYCHAIN);
p.profile.push_str(TERMINAL_INPUT);
network(&mut p, &ir.egress.net, grants.proxy_port);
if ir.fs.case_insensitive {
let message = "Seatbelt compares paths in their on-disk spelling, so a pattern spelled in \
another case than the file on disk does not match it";
p.report
.allowance(read, "seatbelt.case", Vec::new(), message);
}
p
}
impl Generated {
fn grant(&mut self, kind: Kind, id: &str, operation: &str, patterns: Vec<String>, why: &str) {
rule(
&mut self.profile,
id,
&format!("allow {operation}"),
&patterns,
);
self.report.allowance(kind, id, patterns, why);
}
fn allows(&mut self, access: &Access, operation: &str) {
if access.default == Effect::Allow {
let _ = writeln!(self.profile, "; default\n({operation})");
}
rules(&mut self.profile, &access.allow, operation);
let runs_later = access
.allow
.iter()
.any(|r| split(&r.patterns).1.iter().any(|p| runs_later_exclusion(p)));
if runs_later {
self.report.allowance(
Kind::FsWrite,
"seatbelt.runs-later",
RUNS_LATER.iter().map(|f| (*f).to_owned()).collect(),
"commands may write CI configuration, commit-hook managers' files and editor \
tasks (an install runs `husky`); the hook asks before the agent writes one \
(`runs-later`)",
);
}
}
}
fn rules(out: &mut String, rules: &[Rule], operation: &str) {
let verb = operation.split(' ').next().unwrap_or_default();
for r in rules {
let comment = format!("{verb} {} ({})", r.id, r.list);
rule(out, &comment, operation, &with_real_paths(&r.patterns));
}
}
fn with_real_paths(patterns: &[String]) -> Vec<String> {
let mut out = patterns.to_vec();
for real in patterns.iter().filter_map(|p| through_links(p)) {
if !out.contains(&real) {
out.push(real);
}
}
out
}
fn rule(out: &mut String, comment: &str, operation: &str, patterns: &[String]) {
let patterns: Vec<String> = patterns.iter().flat_map(|p| alternatives(p)).collect();
let patterns = &patterns[..];
let (mut positive, mut excluded) = split(patterns);
excluded.retain(|p| !runs_later_exclusion(p));
positive.retain(|p| !patterns.contains(&format!("{p}/**")));
if positive.is_empty() {
return;
}
let lines = |list: &[&str], indent: &str| {
let filters: Vec<String> = list.iter().map(|p| filter(p)).collect();
format!("\n{indent}{}", filters.join(&format!("\n{indent}")))
};
let _ = write!(out, "; {comment}\n({operation}");
if excluded.is_empty() {
out.push_str(&lines(&positive, " "));
} else {
let _ = write!(
out,
"\n (require-all\n (require-any{})\n (require-not (require-any{})))",
lines(&positive, " "),
lines(&excluded, " ")
);
}
out.push_str(")\n");
}
fn network(p: &mut Generated, net: &Access, port: Option<u16>) {
match port {
Some(port) => {
let _ = writeln!(
p.profile,
"; network: only OpenMoat's egress proxy, which decides each host by the policy\n\
(allow network-outbound (remote ip \"localhost:{port}\"))"
);
}
None => p.profile.push_str("; network: none\n"),
}
p.report.proxy_only(net);
}
const MAX_ALTERNATIVES: usize = 64;
fn alternatives(pattern: &str) -> Vec<String> {
let mut out = Vec::new();
if expand(pattern, &mut out) {
out
} else {
vec![pattern.to_owned()]
}
}
fn expand(pattern: &str, out: &mut Vec<String>) -> bool {
let Some((open, close, options)) = first_group(pattern) else {
out.push(pattern.to_owned());
return true;
};
let (head, tail) = (&pattern[..open], &pattern[close + 1..]);
options.iter().all(|option| {
expand(&format!("{head}{option}{tail}"), out) && out.len() <= MAX_ALTERNATIVES
})
}
fn first_group(pattern: &str) -> Option<(usize, usize, Vec<&str>)> {
let (mut depth, mut open, mut start) = (0usize, None, 0);
let mut options = Vec::new();
let mut chars = pattern.char_indices();
while let Some((i, c)) = chars.next() {
match c {
'\\' => {
chars.next();
}
'{' => {
if depth == 0 {
open = Some(i);
start = i + 1;
}
depth += 1;
}
',' if depth == 1 => {
options.push(&pattern[start..i]);
start = i + 1;
}
'}' if depth > 0 => {
depth -= 1;
if depth == 0 {
options.push(&pattern[start..i]);
return Some((open?, i, options));
}
}
_ => {}
}
}
None
}
fn through_links(pattern: &str) -> Option<String> {
if !cfg!(target_os = "macos") {
return None;
}
let (bang, body) = match pattern.strip_prefix('!') {
Some(rest) => ("!", rest),
None => ("", pattern),
};
if !body.starts_with('/') {
return None;
}
let literal_end = body.find(['*', '?', '[', '{', '\\']).unwrap_or(body.len());
let literal = &body[..literal_end];
let dir_end = literal.rfind('/')?;
let mut dir = std::path::Path::new(&literal[..dir_end.max(1)]);
loop {
if let Ok(real) = std::fs::canonicalize(dir) {
let (dir, real) = (dir.to_str()?, real.to_str()?.to_owned());
if real == dir {
return None;
}
return Some(format!("{bang}{real}{}", &body[dir.len()..]));
}
dir = dir.parent()?;
}
}
fn filter(pattern: &str) -> String {
match literal_tree(pattern) {
Some(dir) if pattern.ends_with("/**") => format!("(subpath {})", quote(dir)),
Some(path) => format!("(literal {})", quote(path)),
None => format!("(regex {})", quote(®ex(pattern))),
}
}
fn quote(text: &str) -> String {
format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
}
fn regex(glob: &str) -> String {
let mut re = String::from("^");
let mut depth = 0usize;
let mut i = 0;
while let Some(c) = glob[i..].chars().next() {
let rest = &glob[i..];
let (piece, used) = if rest == "/**" {
("(/.*)?".to_owned(), 3)
} else if rest.starts_with("/**/") {
("/(.*/)?".to_owned(), 4)
} else if i == 0 && rest == "**" {
(".*".to_owned(), 2)
} else if i == 0 && rest.starts_with("**/") {
("(.*/)?".to_owned(), 3)
} else {
match c {
'*' => ("[^/]*".to_owned(), 1),
'?' => ("[^/]".to_owned(), 1),
'{' => {
depth += 1;
("(".to_owned(), 1)
}
'}' if depth > 0 => {
depth -= 1;
(")".to_owned(), 1)
}
',' if depth > 0 => ("|".to_owned(), 1),
'[' => class(rest).unwrap_or_else(|| (escape('['), 1)),
'\\' => match rest[1..].chars().next() {
Some(next) => (escape(next), 1 + next.len_utf8()),
None => (escape('\\'), 1),
},
other => (escape(other), other.len_utf8()),
}
};
re.push_str(&piece);
i += used;
}
re.push('$');
re
}
fn class(rest: &str) -> Option<(String, usize)> {
let body = &rest[1..];
let (negated, body) = match body.strip_prefix(['!', '^']) {
Some(b) => (true, b),
None => (false, body),
};
let end = body.char_indices().skip(1).find(|(_, c)| *c == ']')?.0;
let members = body[..end].replace('\\', "\\\\");
let negation = if negated { "^/" } else { "" };
Some((
format!("[{negation}{members}]"),
rest.len() - body.len() + end + 1,
))
}
fn escape(c: char) -> String {
if ".^$|()+[]{}\\*?".contains(c) {
format!("\\{c}")
} else {
c.to_string()
}
}
#[cfg(test)]
mod tests;