309 lines
13 KiB
Rust
309 lines
13 KiB
Rust
//! 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<String> {
|
|
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<PathBuf> {
|
|
let mut forms: Vec<PathBuf> = 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<PathBuf> {
|
|
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 <ws>))` 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<PathBuf> = 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<PathBuf> {
|
|
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<PathBuf> {
|
|
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<String> {
|
|
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);
|
|
}
|
|
}
|