//! Kernel-enforced deny paths for sandbox profiles. //! //! macOS: Seatbelt platform rules via [`nono::CapabilitySet::add_platform_rule`]. //! Linux: Landlock cannot deny a subpath of an allowed tree; read-deny is //! enforced via bwrap bind-over (see [`crate::bwrap_reexec_command`]). #[cfg(all(feature = "enforce", unix))] use nono::CapabilitySet; #[cfg(all(feature = "enforce", unix))] use std::path::{Path, PathBuf}; // Glob deny entries (detection, macOS regex translation, Linux launch-time // expansion) live in a submodule; re-exported so call sites use `deny::…`. #[cfg(all(feature = "enforce", unix))] mod glob; #[cfg(all(feature = "enforce", target_os = "linux"))] pub(crate) use glob::{ DENY_GLOB_MAX_DEPTH, DENY_GLOB_MAX_ENTRIES, DENY_GLOB_MAX_MATCHES, expand_deny_globs, }; #[cfg(all(feature = "enforce", unix))] pub(crate) use glob::{apply_deny_globs_to_capability_set, partition_deny_entries}; /// Escape a path for use inside a Seatbelt `(literal "...")` / `(subpath "...")` /// filter (used for both forms, hence the generic name). #[cfg(all(feature = "enforce", target_os = "macos"))] fn escape_seatbelt_path(path: &Path) -> Option { let s = path.to_str()?; // Reject all control chars (matching nono's escape_path); silently passing // one through would target a different path than intended. if s.chars().any(|c| c.is_control()) { return None; } Some(s.replace('\\', "\\\\").replace('"', "\\\"")) } /// All literal paths a deny rule must cover on macOS: the as-given path, its /// canonical form, and the `/private` firmlink alias of each (e.g. `/tmp/x` <-> /// `/private/tmp/x`) so a deny cannot be bypassed via an alias. #[cfg(all(feature = "enforce", target_os = "macos"))] fn macos_deny_aliases(path: &Path, canonical: &Path) -> Vec { let mut forms: Vec = vec![path.to_path_buf()]; if canonical != path { forms.push(canonical.to_path_buf()); } for form in forms.clone() { if let Some(alias) = toggle_private_prefix(&form) && !forms.contains(&alias) { forms.push(alias); } } forms } /// Toggle the macOS `/private` firmlink prefix for `/tmp`, `/var`, `/etc` /// (e.g. `/private/tmp/x` <-> `/tmp/x`). Returns `None` for unaffected paths. #[cfg(all(feature = "enforce", target_os = "macos"))] fn toggle_private_prefix(path: &Path) -> Option { let s = path.to_str()?; for dir in ["tmp", "var", "etc"] { if let Some(rest) = s.strip_prefix(&format!("/private/{dir}")) && (rest.is_empty() || rest.starts_with('/')) { return Some(PathBuf::from(format!("/{dir}{rest}"))); } if let Some(rest) = s.strip_prefix(&format!("/{dir}")) && (rest.is_empty() || rest.starts_with('/')) { return Some(PathBuf::from(format!("/private/{dir}{rest}"))); } } None } /// Specific Seatbelt write sub-actions denied for a denied path. /// /// `(deny file-write* ...)` alone does NOT win: nono emits platform rules /// between the read-allows and the write-allows, so the broad workspace /// `(allow file-write* (subpath ))` is emitted AFTER our deny and wins by /// last-match — leaving an in-workspace denied path writable (so `mv x y && cat y` /// could relocate and read it). Empirically, denying each concrete write /// sub-action (every one more specific than the `file-write*` grant) makes the /// deny win regardless of emission order, fully blocking overwrite AND relocation /// (rename/unlink). This is observed per-operation rule-list behavior, not a /// guaranteed action-specificity rule — the macOS e2e is the contract. #[cfg(all(feature = "enforce", target_os = "macos"))] const SEATBELT_WRITE_DENY_ACTIONS: &[&str] = &[ "file-write-data", "file-write-create", "file-write-unlink", "file-write-mode", "file-write-owner", "file-write-flags", "file-write-times", "file-write-setugid", ]; /// Emit the full read+write deny rule set for a single Seatbelt `filter` /// (`(literal ...)` or `(subpath ...)`). See [`SEATBELT_WRITE_DENY_ACTIONS`] for /// why the specific write sub-actions are required in addition to `file-write*`. #[cfg(all(feature = "enforce", target_os = "macos"))] fn emit_seatbelt_deny(caps: &mut CapabilitySet, filter: &str) -> anyhow::Result<()> { // Read-deny wins via last-match (platform rules are emitted after read-allows). caps.add_platform_rule(format!("(deny file-read* {filter})"))?; // Catch-all write-deny (wins for out-of-workspace paths with no competing // write grant, e.g. ~/.ssh) ... caps.add_platform_rule(format!("(deny file-write* {filter})"))?; // ... plus action-specific write denies that also win inside the workspace. for action in SEATBELT_WRITE_DENY_ACTIONS { caps.add_platform_rule(format!("(deny {action} {filter})"))?; } Ok(()) } /// Apply kernel-level deny rules for the given paths. /// /// On macOS, adds Seatbelt read-deny + write-deny (incl. specific write /// sub-actions) rules. On Linux, this is a no-op — callers must use bwrap /// bind-over for read-deny. #[cfg(all(feature = "enforce", unix))] pub(crate) fn apply_deny_paths_to_capability_set( caps: &mut CapabilitySet, deny_paths: &[PathBuf], ) -> anyhow::Result<()> { if deny_paths.is_empty() { return Ok(()); } #[cfg(target_os = "macos")] { // Every literal path a deny rule was emitted for, so explicit file caps // colliding with a denied path can be removed for all alias forms too. let mut rule_paths: Vec = Vec::new(); for path in deny_paths { let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.clone()); // Dir-ness (subpath vs literal) is decided by existence and applies // to all alias forms of this path. let use_subpath = deny_path_is_dir(&canonical); // The base profile grants `(allow file-read* (subpath "/"))`, which // matches every *literal* path. macOS reaches /tmp, /var, /etc via // symlinks into /private, so denying only the canonical form is // bypassable through the alias — emit a deny for each alias form. for form in macos_deny_aliases(path, &canonical) { let Some(escaped) = escape_seatbelt_path(&form) else { // Fail CLOSED: a deny path we can't express as a Seatbelt // filter would otherwise be silently unprotected while the // sandbox still reports active. Erroring leaves apply() not // applied so the shell's macOS `!is_applied` guard refuses to // start — matching Linux's any-bind-fails-closed. anyhow::bail!("cannot escape deny path {form:?} for Seatbelt"); }; // `literal` for files, `subpath` for dirs, so deny rules are more // specific than parent-directory allows. let filter = if use_subpath { format!("(subpath \"{escaped}\")") } else { format!("(literal \"{escaped}\")") }; emit_seatbelt_deny(caps, &filter)?; rule_paths.push(form); } } let _removed = caps.remove_exact_file_caps_for_paths(&rule_paths); tracing::info!( count = deny_paths.len(), "Applied Seatbelt deny rules for sandbox deny paths" ); } #[cfg(target_os = "linux")] { let _ = caps; tracing::debug!( count = deny_paths.len(), "Linux deny paths require bwrap bind-over (applied at process re-exec)" ); } Ok(()) } /// Resolve deny path strings from a profile against the workspace. /// /// Relative paths are joined with `workspace`. Absolute paths are used as-is. #[cfg(all(feature = "enforce", unix))] pub(crate) fn resolve_deny_paths(workspace: &Path, deny: &[PathBuf]) -> Vec { deny.iter() .map(|p| { if p.is_absolute() { p.clone() } else { workspace.join(p) } }) .collect() } /// Resolve, sort, and dedup a profile's deny list into the canonical set of /// paths to enforce. Shared by the Seatbelt (profiles.rs) and bwrap (lib.rs) sites. #[cfg(all(feature = "enforce", unix))] pub(crate) fn effective_deny_paths(workspace: &Path, deny: &[PathBuf]) -> Vec { let mut paths = resolve_deny_paths(workspace, deny); paths.sort(); paths.dedup(); paths } /// Resolve already-partitioned EXACT (non-glob) deny entries into bwrap bind /// strings: resolved against `workspace`, sorted, deduped, stringified. The /// caller passes the exact slice from `partition_deny_entries`. A Linux bwrap /// concern (macOS denies via Seatbelt, not path strings) — the exact-path /// parallel to glob's `expand_deny_globs`, so both deny resolutions live in `deny/`. #[cfg(all(feature = "enforce", target_os = "linux"))] pub(crate) fn exact_deny_path_strings(workspace: &Path, exact: &[PathBuf]) -> Vec { effective_deny_paths(workspace, exact) .into_iter() .map(|p| p.display().to_string()) .collect() } /// Whether a deny path should be treated as a directory (Seatbelt `subpath` / /// bwrap dir-bind) rather than a single file: true for existing directories, /// false otherwise. Shared by the macOS and Linux deny sites so the two cannot /// silently diverge. /// /// Limitation: a non-existent deny path is treated as a single file (macOS emits /// `(literal …)`); if it is later created as a directory its children are not /// covered on macOS. Name concrete existing paths to deny a whole directory tree. #[cfg(all(feature = "enforce", unix))] pub(crate) fn deny_path_is_dir(canonical: &Path) -> bool { canonical.is_dir() } #[cfg(test)] mod tests { // All tests here exercise enforce+unix paths; without the gate `super::*` // is unused on `--no-default-features`. #[cfg(all(feature = "enforce", unix))] use super::*; #[test] #[cfg(all(feature = "enforce", unix))] fn resolve_deny_paths_relative() { let ws = PathBuf::from("/tmp/project"); let deny = vec![PathBuf::from(".env"), PathBuf::from("/etc/shadow")]; let resolved = resolve_deny_paths(&ws, &deny); assert_eq!(resolved[0], PathBuf::from("/tmp/project/.env")); assert_eq!(resolved[1], PathBuf::from("/etc/shadow")); } #[test] #[cfg(all(feature = "enforce", target_os = "linux"))] fn exact_deny_path_strings_resolves_sorts_dedups() { let ws = PathBuf::from("/ws"); // Already-partitioned exact entries (relative + absolute), with a duplicate. let exact = vec![ PathBuf::from("src/server.pem"), PathBuf::from(".env"), PathBuf::from("/etc/shadow"), PathBuf::from(".env"), ]; let paths = exact_deny_path_strings(&ws, &exact); assert!(paths.iter().any(|p| p == "/ws/.env"), "{paths:?}"); assert!(paths.iter().any(|p| p == "/ws/src/server.pem"), "{paths:?}"); assert!(paths.iter().any(|p| p == "/etc/shadow"), "{paths:?}"); // Sorted + deduped (the duplicate `.env` collapses to one). let mut sorted = paths.clone(); sorted.sort(); sorted.dedup(); assert_eq!(paths, sorted, "must be sorted and deduped: {paths:?}"); assert!( !paths.iter().any(|p| p.contains('*')), "no globs: {paths:?}" ); } #[test] #[cfg(all(feature = "enforce", target_os = "macos"))] fn seatbelt_escape_handles_quotes() { let p = Path::new("/tmp/foo\"bar"); let escaped = escape_seatbelt_path(p).unwrap(); assert!(escaped.contains("\\\"")); } #[test] #[cfg(all(feature = "enforce", target_os = "macos"))] fn seatbelt_escape_rejects_control_chars() { assert!(escape_seatbelt_path(Path::new("/tmp/a\u{07}b")).is_none()); } #[test] #[cfg(all(feature = "enforce", target_os = "macos"))] fn macos_deny_aliases_cover_private_symlink() { // A canonical /private/tmp denied path must also be denied via its /tmp alias, // otherwise the broad read-allow leaves it readable through the alias. let canonical = Path::new("/private/tmp/proj/.env"); let aliases = macos_deny_aliases(canonical, canonical); assert!( aliases.iter().any(|p| p == Path::new("/tmp/proj/.env")), "expected /tmp alias in {aliases:?}" ); assert_eq!( toggle_private_prefix(Path::new("/tmp/proj/.env")), Some(PathBuf::from("/private/tmp/proj/.env")) ); // Non-firmlink paths (e.g. home credential dirs) have no alias. assert_eq!(toggle_private_prefix(Path::new("/Users/x/.ssh")), None); } }