diff --git a/Cargo.lock b/Cargo.lock index 543cddfe1..d640aa5fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2034,6 +2034,7 @@ dependencies = [ "getrandom 0.4.3", "gridcraft-engine", "gridcraft-mcp", + "gridcraft-sandbox", "gridcraft-ui-egui", "image", "log", @@ -2076,6 +2077,7 @@ version = "0.16.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", + "gridcraft-sandbox", "log", "quick-xml 0.41.0", "serde_json", @@ -2176,6 +2178,17 @@ dependencies = [ name = "gridcraft-pdf" version = "0.16.0" +[[package]] +name = "gridcraft-sandbox" +version = "0.16.0" +dependencies = [ + "landlock", + "libc", + "nix", + "seccompiler", + "thiserror 2.0.21", +] + [[package]] name = "gridcraft-ui-egui" version = "0.16.0" @@ -2873,6 +2886,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "landlock" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.21", +] + [[package]] name = "lazy_static" version = "1.5.1" @@ -4544,6 +4568,15 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + [[package]] name = "security-framework" version = "3.7.0" diff --git a/Cargo.toml b/Cargo.toml index 1941c9d1e..d3aa9b50c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ repository = "https://github.com/storytold/gridcraft" # internal gridcraft-core = { path = "crates/core" } gridcraft-numfmt = { path = "crates/numfmt" } +gridcraft-sandbox = { path = "crates/sandbox" } gridcraft-formula = { path = "crates/formula" } gridcraft-functions = { path = "crates/functions" } gridcraft-model = { path = "crates/model" } diff --git a/apps/gridcraft-cli/Cargo.toml b/apps/gridcraft-cli/Cargo.toml index 21e958005..83c45d0f2 100644 --- a/apps/gridcraft-cli/Cargo.toml +++ b/apps/gridcraft-cli/Cargo.toml @@ -14,6 +14,7 @@ name = "gridcraft-cli" path = "src/main.rs" [dependencies] +gridcraft-sandbox = { workspace = true } gridcraft-engine = { workspace = true } gridcraft-mcp = { workspace = true } serde_json = { workspace = true } diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 5170ba0c5..2c49c6033 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -11,6 +11,7 @@ //! gridcraft-cli functions [--json] [--search X] [--category C] //! gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] //! gridcraft-cli send [json] +//! gridcraft-cli sandbox-check [--read PATH]... [--exec PATH]... [-- PROGRAM ARGS...] //! gridcraft-cli version | --version //! ``` #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -30,6 +31,7 @@ macro_rules! outln { } mod check; +mod sandbox_check; use gridcraft_engine::Session; use serde_json::{Value, json}; @@ -49,6 +51,8 @@ const USAGE: &str = "usage: gridcraft-cli functions [--json] [--search X] [--category C] gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] gridcraft-cli send [json] + gridcraft-cli sandbox-check [--read PATH]... [--exec PATH]... [-- PROGRAM ARGS...] + whether this system confines programs (no program: a confined self-test) gridcraft-cli version"; /// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly; any @@ -62,6 +66,11 @@ fn stdout_failed(e: std::io::Error) -> ! { } fn main() -> ExitCode { + // A confined launcher run (and its probes) comes first, before anything else starts. + let raw: Vec = std::env::args_os().skip(1).collect(); + if let Some(code) = gridcraft_sandbox::handle_args(&raw) { + return ExitCode::from(u8::try_from(code).unwrap_or(1)); + } let args: Vec = std::env::args().skip(1).collect(); let rest = args.get(1..).unwrap_or_default(); let r = match args.first().map(String::as_str) { @@ -79,6 +88,7 @@ fn main() -> ExitCode { Some("cat") => cat(rest), Some("run") => run(rest), Some("check") => return check::main(rest), + Some("sandbox-check") => return sandbox_check::main(rest), Some("commands") => commands(rest), Some("functions") => functions(rest), Some("mcp") => mcp(rest), diff --git a/apps/gridcraft-cli/src/sandbox_check.rs b/apps/gridcraft-cli/src/sandbox_check.rs new file mode 100644 index 000000000..e40bb9ac5 --- /dev/null +++ b/apps/gridcraft-cli/src/sandbox_check.rs @@ -0,0 +1,98 @@ +//! `gridcraft-cli sandbox-check [--read PATH]... [--exec PATH]... [-- PROGRAM ARGS...]`: whether +//! this system confines programs, for a person or a reviewer checking a machine. +//! +//! Without a program it starts a confined copy of itself that tries what the sandbox must deny +//! (reading outside the policy, writing, the network, starting a process) and what it must still +//! allow (a thread, reading an allowed file), and prints one line per attempt. With a program it +//! runs that program confined, with the given read and executable paths and the system's shared +//! libraries, and passes its output and exit status through; its own lines then go to standard +//! error. Every path is absolute. Exit status: 0 when every attempt is +//! as required (or the program succeeded), 1 otherwise (a refused launch included), 2 when GridCraft +//! has no sandbox for this kind of system. + +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::ExitCode; + +use gridcraft_sandbox::{Canary, Policy, REFUSED_EXIT_CODE, REPORT_FLAG, Support}; + +pub fn main(args: &[String]) -> ExitCode { + match run(args) { + Ok(code) => code, + Err(e) => { + eprintln!("gridcraft-cli: sandbox-check: {e}"); + ExitCode::FAILURE + } + } +} + +fn run(args: &[String]) -> Result { + let mut policy = Policy::new().with_system_libraries(); + let mut it = args.iter(); + let mut program: Option<(PathBuf, Vec)> = None; + while let Some(a) = it.next() { + match a.as_str() { + "--read" => policy = policy.read(it.next().ok_or("--read needs a path")?), + "--exec" => policy = policy.exec(it.next().ok_or("--exec needs a path")?), + "--" => { + let p = it.next().ok_or("a program follows `--`")?; + program = Some((PathBuf::from(p), it.by_ref().map(OsString::from).collect())); + } + other => return Err(format!("unknown option `{other}`")), + } + } + // With a program, its standard output is passed through untouched, so this command's own + // lines go to standard error; the self-test's report is the output, on standard output. + let report = program.is_none(); + let say = |line: String| { + if report { + outln!("{line}"); + } else { + eprintln!("{line}"); + } + }; + match gridcraft_sandbox::support() { + Support::Available(how) => say(format!("sandbox: available ({how})")), + Support::Unsupported(why) => { + say(format!("sandbox: unsupported ({why})")); + return Ok(ExitCode::from(2)); + } + } + let canary = Canary::create().map_err(|e| e.to_string())?; + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let (program, program_args) = match program { + Some((p, a)) => (p, a), + // A confined copy of this program, reporting each attempt; it reads its own executable as + // the allowed file. + None => { + let home = std::env::var_os("HOME").unwrap_or_default(); + (exe.clone(), vec![REPORT_FLAG.into(), canary.file().into(), exe.clone().into(), home]) + } + }; + let policy = policy.exec(program.clone()); + let mut cmd = gridcraft_sandbox::launch_command(&policy, &canary, &program, &program_args).map_err(|e| e.to_string())?; + let status = cmd.status().map_err(|e| format!("couldn't start the launcher: {e}"))?; + drop(canary); + match status.code() { + Some(0) => { + say(if report { "result: every attempt is as the sandbox requires" } else { "result: the program ran confined and succeeded" }.into()); + Ok(ExitCode::SUCCESS) + } + Some(REFUSED_EXIT_CODE) => { + // The launcher's refusal and a program's own exit share the code; the launcher's reason, + // when it refused, is the `gridcraft-sandbox: refused:` line above. + say(format!( + "result: exit code {REFUSED_EXIT_CODE}: the launcher refused to run the program (its reason is above), or the program itself exited with {REFUSED_EXIT_CODE}" + )); + Ok(ExitCode::FAILURE) + } + other => { + say(format!( + "result: {} ({})", + if report { "some attempts are not as the sandbox requires" } else { "the program failed" }, + other.map_or("no exit code".into(), |c| format!("exit code {c}")) + )); + Ok(ExitCode::FAILURE) + } + } +} diff --git a/apps/gridcraft-cli/tests/it/main.rs b/apps/gridcraft-cli/tests/it/main.rs index 5906b3967..2078aedfd 100644 --- a/apps/gridcraft-cli/tests/it/main.rs +++ b/apps/gridcraft-cli/tests/it/main.rs @@ -1,3 +1,4 @@ //! The crate's integration tests, one module per file, built as one test binary. mod cli; +mod sandbox; mod text_to_columns_quotes; diff --git a/apps/gridcraft-cli/tests/it/sandbox.rs b/apps/gridcraft-cli/tests/it/sandbox.rs new file mode 100644 index 000000000..19861f8d8 --- /dev/null +++ b/apps/gridcraft-cli/tests/it/sandbox.rs @@ -0,0 +1,384 @@ +//! The confined launcher, through the real binary: what a confined program can't do, what it +//! still can, and that a launch whose check fails runs nothing. Linux and macOS confine; other +//! systems report that they can't. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +/// Whether this machine confines programs: a supported system whose self-test passes. On a Linux +/// or macOS machine that reports no sandbox (another architecture, no `sandbox-exec`) or whose +/// kernel doesn't enforce Landlock, the confinement tests have nothing to check and pass with a +/// note. On CI (`CI` set) the machine must confine, so a failure there is never skipped. +fn confines() -> bool { + static CONFINES: std::sync::OnceLock = std::sync::OnceLock::new(); + *CONFINES.get_or_init(|| { + if !cfg!(any(target_os = "linux", target_os = "macos")) { + return false; + } + let o = cli(&["sandbox-check"]); + let out = text(&o); + let missing = out.contains("sandbox: unsupported") || out.contains("Landlock is not enforced"); + if missing && std::env::var_os("CI").is_none() { + eprintln!("this machine doesn't confine programs; skipping:\n{out}"); + return false; + } + true + }) +} + +fn cli(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")).args(args).output().unwrap() +} + +fn text(o: &Output) -> String { + format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) +} + +/// A directory of the test's own in the temporary directory, with one file. +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().canonicalize().unwrap().join(format!("gridcraft-sandbox-it-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("allowed.txt"), "allowed text\n").unwrap(); + dir +} + +/// `cat` as this system names it. +fn cat() -> &'static str { + if Path::new("/bin/cat").exists() { "/bin/cat" } else { "/usr/bin/cat" } +} + +#[test] +fn the_self_test_finds_every_denial() { + // A home directory of the test's own, so the home-directory attempt always runs. + let home = scratch("home"); + let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")).arg("sandbox-check").env("HOME", &home).output().unwrap(); + let out = text(&o); + if !cfg!(any(target_os = "linux", target_os = "macos")) { + assert_eq!(o.status.code(), Some(2), "{out}"); + assert!(out.contains("sandbox: unsupported"), "{out}"); + return; + } + if !confines() { + return; + } + assert!(o.status.success(), "{out}"); + for line in [ + "denied: read the canary", + "denied: change the canary's permissions", + "denied: truncate the canary", + "denied: write next to the canary", + "denied: write in the temporary directory", + "denied: TCP connection", + "denied: start a process", + "denied: signal another process", + "denied: UDP socket", + "denied: list the home directory", + "works: start a thread", + "works: read an allowed file", + ] { + assert!(out.contains(line), "missing `{line}` in:\n{out}"); + } + std::fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn a_confined_program_reads_only_what_it_is_given() { + if !confines() { + return; + } + let dir = scratch("read"); + let outside = scratch("outside"); + let d = dir.to_string_lossy().into_owned(); + let allowed = dir.join("allowed.txt").to_string_lossy().into_owned(); + let o = cli(&["sandbox-check", "--read", &d, "--", cat(), &allowed]); + assert!(o.status.success(), "{}", text(&o)); + // The program's output is passed through untouched; this command's own lines go to stderr. + assert_eq!(String::from_utf8_lossy(&o.stdout), "allowed text\n", "{}", text(&o)); + assert!(String::from_utf8_lossy(&o.stderr).contains("result: the program ran confined"), "{}", text(&o)); + // The same program, a file outside the policy: the read is refused and nothing is printed. + let other = outside.join("allowed.txt").to_string_lossy().into_owned(); + let o = cli(&["sandbox-check", "--read", &d, "--", cat(), &other]); + assert!(!o.status.success(), "{}", text(&o)); + assert!(!String::from_utf8_lossy(&o.stdout).contains("allowed text"), "{}", text(&o)); + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside).unwrap(); +} + +#[test] +fn a_launch_whose_check_fails_runs_nothing() { + // A run that says it is already confined and isn't finds the canary readable and must refuse. + // On macOS that is the second stage started without `sandbox-exec`; on Linux the launcher never + // uses that flag, and the same refusal must hold. + let canary = scratch("canary"); + let echo = if Path::new("/bin/echo").exists() { "/bin/echo" } else { "/usr/bin/echo" }; + let probe = canary.join("allowed.txt").to_string_lossy().into_owned(); + // A dynamically linked `echo` needs the system's shared libraries and its loader. + // The second stage takes the process the first looked at: here, the parent of what `cli` starts. + let mut launch = vec![ + "--sandbox-confined".to_string(), + std::process::id().to_string(), + "--exec".into(), + echo.into(), + "--exec".into(), + env!("CARGO_BIN_EXE_gridcraft-cli").into(), + ]; + for lib in ["/lib", "/lib64", "/usr/lib", "/usr/lib64"].into_iter().filter(|d| Path::new(d).exists()) { + launch.extend(["--exec".to_string(), lib.into()]); + } + launch.extend(["--probe".to_string(), probe.clone(), "--".into(), echo.into(), "the program ran".into()]); + let o = cli(&launch.iter().map(String::as_str).collect::>()); + let out = text(&o); + assert_eq!(o.status.code(), Some(97), "{out}"); + assert!(!out.contains("the program ran"), "{out}"); + assert!(out.contains("gridcraft-sandbox: refused"), "{out}"); + assert!(out.contains("read the canary"), "the check refused, not the arguments: {out}"); + // Without that process, the second stage has nothing to try a signal on. + let without: Vec<&str> = std::iter::once("--sandbox-confined").chain(launch[2..].iter().map(String::as_str)).collect(); + let o = cli(&without); + assert_eq!(o.status.code(), Some(97), "{}", text(&o)); + assert!(text(&o).contains("needs the process"), "{}", text(&o)); + // The normal path: the launcher confines itself first, and the program runs. + if confines() { + launch.splice(0..2, ["--sandbox-launch".to_string()]); + // The launcher may be under a folder in the list, on either system. + let mut by_folder = launch.clone(); + by_folder[4] = Path::new(env!("CARGO_BIN_EXE_gridcraft-cli")).parent().unwrap().to_string_lossy().into_owned(); + let o = cli(&by_folder.iter().map(String::as_str).collect::>()); + assert!(o.status.success(), "{}", text(&o)); + assert!(text(&o).contains("the program ran"), "{}", text(&o)); + let o = cli(&launch.iter().map(String::as_str).collect::>()); + let out = text(&o); + assert!(o.status.success() && out.contains("the program ran"), "{out}"); + } + // A canary inside an allowed path is refused before anything runs, everywhere. + let dir = canary.to_string_lossy().into_owned(); + let o = cli(&["--sandbox-launch", "--read", &dir, "--exec", echo, "--probe", &probe, "--", echo, "the program ran"]); + assert_eq!(o.status.code(), Some(97), "{}", text(&o)); + assert!(!text(&o).contains("the program ran")); + std::fs::remove_dir_all(canary).unwrap(); +} + +/// A Python found on this system: the interpreter to run confined, and its installation, the path it +/// needs to read. `None` when there is no `python3`. +fn system_python() -> Option<(String, String)> { + // On macOS `/usr/bin/python3` is a stub that starts Xcode's Python, outside any read path given + // here; an installed Python comes first. + let candidates = if cfg!(target_os = "macos") { + ["/opt/homebrew/bin/python3", "/usr/local/bin/python3"] + } else { + ["/usr/bin/python3", "/usr/local/bin/python3"] + }; + let python = candidates.into_iter().find(|p| Path::new(p).exists())?; + let prefix = Command::new(python).args(["-c", "import sys; print(sys.base_prefix)"]).output().unwrap(); + let prefix = String::from_utf8_lossy(&prefix.stdout).trim().to_string(); + // A framework build's `bin/python3` only starts `Python.app`'s interpreter as a new process, + // which the sandbox refuses; the interpreter itself is run instead. + let framework = Path::new(&prefix).join("Resources/Python.app/Contents/MacOS/Python"); + let real = if framework.exists() { framework } else { std::fs::canonicalize(python).unwrap() }; + Some((real.to_string_lossy().into_owned(), prefix)) +} + +/// Confined, a program can't signal another process (this test, signal 0 delivering nothing), but can +/// still signal itself and its own threads. On Linux, where the filter names them, it also can't +/// change another process's limits or priority, or type into a terminal: no call here reaches a real +/// terminal, since its standard input isn't one. +#[test] +fn a_confined_program_leaves_other_processes_alone() { + if !confines() { + return; + } + let Some((python, prefix)) = system_python() else { + return; + }; + let code = r#" +import os, signal, sys, threading +other = int(sys.argv[1]) +def refused(call, *args): + try: + call(*args) + except PermissionError: + return True + except OSError as e: + print("other error", call, args, e) + return False +assert refused(os.kill, other, 0), "a signal to another process" +os.kill(os.getpid(), 0) +seen = [] +signal.signal(signal.SIGUSR1, lambda *_: seen.append(1)) +signal.raise_signal(signal.SIGUSR1) +worker = threading.Thread(target=lambda: signal.pthread_kill(threading.main_thread().ident, signal.SIGUSR1)) +worker.start() +worker.join() +assert len(seen) >= 1, "a signal to itself" +if sys.platform == "linux": + import ctypes, errno, fcntl, resource, termios + libc = ctypes.CDLL(None, use_errno=True) + assert libc.shmget(0, 4096, 0o600) == -1 and ctypes.get_errno() == errno.EPERM, "System V shared memory" + assert libc.msgget(0, 0o600) == -1 and ctypes.get_errno() == errno.EPERM, "a System V message queue" + assert libc.semget(0, 1, 0o600) == -1 and ctypes.get_errno() == errno.EPERM, "a System V semaphore" + if hasattr(libc, "mq_open"): + assert libc.mq_open(b"/gridcraft-check", os.O_RDONLY) == -1 and ctypes.get_errno() == errno.EPERM, "a POSIX message queue" + # macOS answers these for the processes the profile lets it signal, which are only itself. + assert refused(os.kill, -1, 0), "a signal to every process" + assert refused(os.kill, 0, 0), "a signal to the process group" + assert refused(resource.prlimit, other, resource.RLIMIT_NOFILE), "another process's limits" + assert refused(os.setpriority, os.PRIO_PROCESS, other, 0), "another process's priority" + assert refused(os.setpriority, os.PRIO_USER, 0, 0), "every process's priority" + assert refused(os.sched_setaffinity, other, {0}), "another process's processors" + assert refused(os.pidfd_open, other), "a handle on another process" + resource.prlimit(0, resource.RLIMIT_NOFILE) + resource.prlimit(os.getpid(), resource.RLIMIT_NOFILE) + os.setpriority(os.PRIO_PROCESS, 0, os.getpriority(os.PRIO_PROCESS, 0)) + assert refused(fcntl.ioctl, 0, termios.TIOCSTI, b"x"), "typing into a terminal" + assert refused(fcntl.ioctl, 0, termios.TIOCSTI | (1 << 32), b"x"), "the same with high bits set" + assert refused(fcntl.ioctl, 0, 0x541C, b"x"), "TIOCLINUX" +print("left alone") +"#; + let me = std::process::id().to_string(); + let o = cli(&["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me]); + assert!(o.status.success(), "{}", text(&o)); + assert!(String::from_utf8_lossy(&o.stdout).contains("left alone"), "{}", text(&o)); +} + +/// Confined, a program changes no file, whether the policy lets it read that file or not, by path or +/// by a descriptor: not its contents, permissions, owner, extended attributes, times or flags. +/// Skipped when there is no `python3`. +#[test] +fn a_confined_program_changes_no_file() { + if !confines() { + return; + } + let Some((python, prefix)) = system_python() else { + return; + }; + let code = r#" +import errno, fcntl, os, struct, sys +readable, outside = sys.argv[1:3] +def refused(what, call, *args): + try: + call(*args) + except PermissionError: + return + except OSError as e: + sys.exit(f"{what}: another error, not a refusal: {e}") + sys.exit(f"{what}: it worked") +fd = os.open(readable, os.O_RDONLY) +for target in (readable, outside, fd): + refused(f"permissions of {target}", os.chmod, target, 0) + refused(f"owner of {target}", os.chown, target, os.getuid(), os.getgid()) + refused(f"times of {target}", os.utime, target, (0, 0)) + if sys.platform == "linux": + refused(f"setting an attribute of {target}", os.setxattr, target, "user.gridcraft", b"1") + refused(f"removing an attribute of {target}", os.removexattr, target, "user.gridcraft") +for path in (readable, outside): + refused(f"truncate {path}", os.truncate, path, 0) + refused(f"open {path} with O_TRUNC", os.open, path, os.O_RDONLY | os.O_TRUNC) + refused(f"open {path} to write", os.open, path, os.O_WRONLY) +# A descriptor opened to read can't be truncated either way; the file's contents are checked after. +try: + os.ftruncate(fd, 0) +except OSError: + pass +if sys.platform == "linux": + import ctypes + libc = ctypes.CDLL(None, use_errno=True) + # Newer calls, which the kernel running this may not have: the filter answers before it would. + for name, number in (("fchmodat2", 452), ("setxattrat", 463), ("removexattrat", 466), ("file_setattr", 469)): + ctypes.set_errno(0) + result = libc.syscall(number, -100, readable.encode(), 0, 0) + assert result == -1 and ctypes.get_errno() == errno.EPERM, f"{name}: {result}, {ctypes.get_errno()}" + # On a descriptor opened only to read, these change the file's flags and attributes. + refused("FS_IOC_SETFLAGS", fcntl.ioctl, fd, 0x40086602, struct.pack("l", 0)) + refused("FS_IOC_FSSETXATTR", fcntl.ioctl, fd, 0x401C5820, bytes(28)) + refused("FS_IOC_SET_ENCRYPTION_POLICY", fcntl.ioctl, fd, 0x800C6613, bytes(12)) + # What a program still needs: its own descriptors' flags, and a file of its own in memory. + os.set_inheritable(fd, True) + os.set_inheritable(fd, False) + os.set_blocking(fd, False) + own = os.memfd_create("gridcraft-check") + os.ftruncate(own, 16) + assert os.fstat(own).st_size == 16 +print("unchanged") +"#; + let inside = scratch("unchanged-inside"); + let elsewhere = scratch("unchanged-outside"); + let (readable, outside) = (inside.join("allowed.txt"), elsewhere.join("allowed.txt")); + let state = |file: &Path| { + let m = std::fs::metadata(file).unwrap(); + (std::fs::read(file).unwrap(), m.permissions(), m.modified().unwrap()) + }; + let before = (state(&readable), state(&outside)); + let d = inside.to_string_lossy().into_owned(); + let (r, x) = (readable.to_string_lossy().into_owned(), outside.to_string_lossy().into_owned()); + let o = cli(&["sandbox-check", "--read", &prefix, "--read", &d, "--", &python, "-I", "-c", code, &r, &x]); + assert!(o.status.success(), "{}", text(&o)); + assert!(String::from_utf8_lossy(&o.stdout).contains("unchanged"), "{}", text(&o)); + assert_eq!((state(&readable), state(&outside)), before); + std::fs::remove_dir_all(inside).unwrap(); + std::fs::remove_dir_all(elsewhere).unwrap(); +} + +/// A Python found on this system starts and imports its standard library while confined, with +/// read access to its installation only. Skipped when there is no `python3`. +#[test] +fn python_starts_confined() { + if !confines() { + return; + } + let Some((real, prefix)) = system_python() else { + return; + }; + let o = + cli(&["sandbox-check", "--read", &prefix, "--", &real, "-I", "-c", "import json, math, datetime, decimal; print(json.dumps([math.pi > 3]))"]); + assert!(o.status.success(), "{}", text(&o)); + assert!(String::from_utf8_lossy(&o.stdout).contains("[true]"), "{}", text(&o)); + // Confined Python can't open the network or a file outside its installation. + let o = cli(&["sandbox-check", "--read", &prefix, "--", &real, "-I", "-c", "import socket; socket.create_connection(('127.0.0.1', 9), 2)"]); + assert!(!o.status.success(), "{}", text(&o)); + assert!(text(&o).contains("PermissionError") || text(&o).contains("Operation not permitted"), "{}", text(&o)); +} + +#[test] +fn a_confined_program_inherits_no_secrets() { + if !confines() { + return; + } + let env = if Path::new("/usr/bin/env").exists() { "/usr/bin/env" } else { "/bin/env" }; + let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")) + .args(["sandbox-check", "--", env]) + .env("GRIDCRAFT_CONTROL_TOKEN", "secret-token") + .env("AWS_SECRET_ACCESS_KEY", "secret-key") + .env("LANG", "en_US.UTF-8") + .output() + .unwrap(); + let out = text(&o); + assert!(o.status.success(), "{out}"); + assert!(!out.contains("secret-"), "the environment reached the program:\n{out}"); + assert!(out.contains("LANG=en_US.UTF-8"), "the locale is kept:\n{out}"); + assert!(out.contains("PATH=/usr/bin:/bin"), "{out}"); +} + +/// A descriptor the parent leaves open (here fd 9, opened by a shell on a file outside any policy) +/// would survive `exec` with access the sandbox doesn't revoke, so the launcher closes it: the +/// program runs, and can't read through it. (A desktop app's system libraries leave such files open, +/// so refusing to run would block Python there.) +#[test] +fn an_inherited_descriptor_never_reaches_the_program() { + if !confines() { + return; + } + let outside = scratch("leak"); + let leaked = outside.join("allowed.txt").to_string_lossy().into_owned(); + let cli = env!("CARGO_BIN_EXE_gridcraft-cli"); + // The shell opens fd 9 (not close-on-exec) and then becomes the command, which inherits it. The + // confined shell then tries to read through it, with a built-in: starting `cat` is refused. Not + // fd 3: on macOS the system's libraries hold a socket of their own there under the sandbox. + let bash = if cfg!(target_os = "macos") { "--exec /bin/bash" } else { "" }; + let program = "read line <&9 && echo \"leaked: $line\"; echo finished"; + let script = format!("exec 9<'{leaked}'; exec '{cli}' sandbox-check {bash} -- /bin/sh -c '{program}'"); + let o = Command::new("/bin/sh").args(["-c", &script]).output().unwrap(); + let out = text(&o); + assert!(out.contains("finished"), "the program ran:\n{out}"); + assert!(!out.contains("leaked:"), "the program read through the descriptor:\n{out}"); + std::fs::remove_dir_all(outside).unwrap(); +} diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 75acee3ff..9365f3e15 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] gridcraft-engine = { workspace = true } gridcraft-ui-egui = { workspace = true } +gridcraft-sandbox = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] } egui = { workspace = true } serde_json = { workspace = true } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index cc67e241a..f05b421df 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -241,6 +241,12 @@ fn icon() -> Option { } fn main() -> eframe::Result<()> { + // A confined launcher run (`gridcraft_sandbox::launch_command`) comes before everything else: + // it opens no window, writes no log, and becomes the program it confines. + let raw: Vec = std::env::args_os().skip(1).collect(); + if let Some(code) = gridcraft_sandbox::handle_args(&raw) { + std::process::exit(code); + } // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); // Commands catch their panics (`Session::execute`), but the default hook only prints to diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml new file mode 100644 index 000000000..ec02bfb94 --- /dev/null +++ b/crates/sandbox/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "gridcraft-sandbox" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "GridCraft's confined launcher for helper processes: Landlock and seccomp on Linux, sandbox-exec on macOS, refused elsewhere" + +[lints] +workspace = true + +[dependencies] +thiserror = { workspace = true } + +# The confinement layers' system calls sit behind these crates' safe APIs; GridCraft's own code +# has no `unsafe`. Exact pins: they are the security boundary. +[target.'cfg(target_os = "linux")'.dependencies] +landlock = "=0.4.7" +seccompiler = "=0.5.0" +libc = "0.2" + +[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] +nix = { version = "=0.31.3", default-features = false, features = ["fs", "signal"] } diff --git a/crates/sandbox/src/canary.rs b/crates/sandbox/src/canary.rs new file mode 100644 index 000000000..d5a71096a --- /dev/null +++ b/crates/sandbox/src/canary.rs @@ -0,0 +1,50 @@ +//! The canary: a file outside every allowed path, which a confined launcher must fail to read. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::Result; + +static NEXT: AtomicU64 = AtomicU64::new(0); + +/// A fresh directory in the system's temporary directory holding one file. The launcher's +/// enforcement check must be denied reading the file and writing next to it. Both are removed +/// when the canary is dropped. +#[derive(Debug)] +pub struct Canary { + dir: PathBuf, + file: PathBuf, +} + +impl Canary { + pub fn create() -> Result { + let base = std::env::temp_dir().canonicalize()?; + // `create_dir` (not `create_dir_all`) fails if the name exists, so the directory found is + // new; a name another process left behind is skipped. + let mut tries = 0; + let dir = loop { + let dir = base.join(format!("gridcraft-sandbox-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + match std::fs::create_dir(&dir) { + Ok(()) => break dir, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && tries < 100 => tries += 1, + Err(e) => return Err(e.into()), + } + }; + let file = dir.join("canary"); + if let Err(e) = std::fs::write(&file, b"gridcraft sandbox canary\n") { + let _ = std::fs::remove_dir_all(&dir); + return Err(e.into()); + } + Ok(Canary { dir, file }) + } + + pub fn file(&self) -> &Path { + &self.file + } +} + +impl Drop for Canary { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} diff --git a/crates/sandbox/src/lib.rs b/crates/sandbox/src/lib.rs new file mode 100644 index 000000000..5c591c5d9 --- /dev/null +++ b/crates/sandbox/src/lib.rs @@ -0,0 +1,263 @@ +//! A confined launcher for helper processes that run code GridCraft doesn't trust, such as a +//! workbook's Python cells. +//! +//! The parent builds a [`Policy`] (the paths the program may read, and the programs it may +//! execute), creates a [`Canary`], and spawns the command [`launch_command`] returns. That command +//! runs the application itself again with [`LAUNCH_FLAG`]; the application hands its arguments to +//! [`handle_args`] before anything else, and the launcher: +//! +//! 1. closes every descriptor besides standard input, output and error, and refuses if one stays +//! open: the confinement doesn't revoke what is already open, and an app's system libraries leave +//! files open that a child inherits; +//! 2. confines itself: on Linux with Landlock (read-only access to the policy's paths, execute +//! access only to its programs, no writes) and seccomp (no new processes, no sockets, none of +//! the calls that escape or inspect a sandbox or that change a file where Landlock has no right +//! for it); on macOS it runs itself again under +//! `sandbox-exec` with a deny-by-default profile built from the same policy ([`CONFINED_FLAG`]); +//! 3. checks that the confinement holds: reading the canary, changing its permissions, truncating +//! it, writing next to it, a TCP connection and starting a process must each be denied; +//! 4. only then replaces itself with the program. +//! +//! When any step fails, the program never runs: the launcher writes one line, +//! `gridcraft-sandbox: refused: `, to its standard error and exits with +//! [`REFUSED_EXIT_CODE`]. Other platforms (Windows, FreeBSD, the web) have no confinement here: +//! [`support`] says so and [`launch_command`] refuses, so callers show `#BLOCKED!`. +//! +//! The crate has no `unsafe` code: the system calls sit behind the `landlock`, `seccompiler` and +//! `nix` crates, and behind `/usr/bin/sandbox-exec` on macOS. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod canary; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +mod policy; +mod probe; + +use std::ffi::{OsStr, OsString}; +use std::path::Path; +use std::process::Command; + +pub use canary::Canary; +pub use policy::Policy; +pub use probe::{Check, Outcome}; + +/// The first argument of a launcher run: the application passes its arguments to [`handle_args`]. +pub const LAUNCH_FLAG: &str = "--sandbox-launch"; +/// The first argument of a launcher already running confined (macOS, under `sandbox-exec`): it +/// goes straight to the enforcement check, which refuses when it isn't confined after all. +pub const CONFINED_FLAG: &str = "--sandbox-confined"; +/// The first argument of the child the enforcement check tries to start; it exits at once. +pub const PROBE_CHILD_FLAG: &str = "--sandbox-probe-child"; +/// The first argument of a confined run that reports what it may and may not do +/// (`gridcraft-cli sandbox-check`). +pub const REPORT_FLAG: &str = "--sandbox-report"; +/// The exit code of a launcher that refused to run its program. +pub const REFUSED_EXIT_CODE: i32 = 97; + +/// Why a program can't be run confined. +#[derive(Debug, thiserror::Error)] +pub enum SandboxError { + #[error("this system has no supported sandbox: {0}")] + Unsupported(String), + #[error("bad sandbox policy: {0}")] + BadPolicy(String), + #[error("the sandbox is not enforced: {0}")] + NotEnforced(String), + #[error("couldn't start the sandbox: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; + +/// Whether this system can confine a program. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Support { + /// The sandbox is available; the text names what provides it. + Available(String), + /// It is not, and why. + Unsupported(String), +} + +/// Whether this system can confine a program. A [`launch_command`] can still be refused when the +/// running kernel turns out not to enforce a restriction: the launcher checks again. +pub fn support() -> Support { + platform::support() +} + +/// The command that runs `program` with `args`, confined by `policy`, checked against `canary`. +/// The parent spawns it like any other command and keeps `canary` alive until the program has +/// started (its standard streams are the program's). Those stay open with the rights they were +/// opened with, which the confinement doesn't revoke: a stream that is a file can be written and +/// truncated, and one that is a socket can still send. So give the program pipes. The command runs the current executable +/// again, which must pass its arguments to [`handle_args`] first. +pub fn launch_command(policy: &Policy, canary: &Canary, program: &Path, args: &[OsString]) -> Result { + launch_command_with(&std::env::current_exe()?, policy, canary, program, args) +} + +/// [`launch_command`] with another executable as the launcher: one that also passes its arguments +/// to [`handle_args`] first. +pub fn launch_command_with(exe: &Path, policy: &Policy, canary: &Canary, program: &Path, args: &[OsString]) -> Result { + let checked = policy.checked(exe, program, canary)?; + let mut launch = vec![OsString::from(LAUNCH_FLAG)]; + launch.extend(checked.to_args(canary.file())); + launch.push(OsString::from("--")); + launch.push(checked.program().as_os_str().to_owned()); + launch.extend(args.iter().cloned()); + platform::command(&checked, exe, &launch) +} + +/// Handles the application's own sandbox runs; call it with the process's arguments (without the +/// program name) before anything else. `None`: the arguments are not one of those runs, carry on. +/// `Some(code)`: exit with `code`. A launcher run whose checks pass doesn't return at all: it +/// becomes the program. +pub fn handle_args(args: &[OsString]) -> Option { + let (first, rest) = args.split_first()?; + if first == OsStr::new(LAUNCH_FLAG) || first == OsStr::new(CONFINED_FLAG) { + let Err(e) = if first == OsStr::new(LAUNCH_FLAG) { launch(rest) } else { confined_again(rest) }; + eprintln!("gridcraft-sandbox: refused: {e}"); + return Some(REFUSED_EXIT_CODE); + } + if first == OsStr::new(PROBE_CHILD_FLAG) { + return Some(0); + } + if first == OsStr::new(REPORT_FLAG) { + return Some(probe::report(rest)); + } + None +} + +/// The launcher: check what it inherited, confine, check the confinement, become the program. +/// Returns only on failure. +fn launch(args: &[OsString]) -> Result { + // Arguments that can't be a launch are refused with nothing done. Reading them leaves nothing open. + let (checked, _) = policy::Checked::from_args(args)?; + // Before the launcher opens anything itself. On macOS this is also before the sandbox: under a + // deny-by-default profile the system's libraries hold a socket of their own, which closes at + // `exec` and which must not be taken for an inherited one. + close_inherited_descriptors()?; + // What the check tries on a process has to be allowed now, or its refusal then proves nothing. + let target = probe::signal_target().map_err(SandboxError::NotEnforced)?; + platform::confine(&checked, target, args)?; + confined(target, args) +} + +/// The launcher run again under the confinement ([`CONFINED_FLAG`]): the process the first run looked +/// at comes before its arguments. +fn confined_again(args: &[OsString]) -> Result { + let parsed = args.split_first().and_then(|(target, rest)| Some((target.to_str()?.parse().ok()?, rest))); + let (target, rest) = parsed.ok_or_else(|| SandboxError::NotEnforced(format!("{CONFINED_FLAG} needs the process the launcher looked at")))?; + confined(target, rest) +} + +/// The launcher once it is confined: check that it holds, then become the program. +fn confined(target: u32, args: &[OsString]) -> Result { + let (checked, canary) = policy::Checked::from_args(args)?; + let exe = std::env::current_exe()?; + for check in probe::enforcement(&canary, &exe, target) { + if let Outcome::Allowed(detail) = check.outcome { + return Err(SandboxError::NotEnforced(format!("{}: {detail}", check.name))); + } + } + platform::exec(checked.program(), checked.args()) +} + +#[cfg(target_os = "linux")] +use linux as platform; +#[cfg(target_os = "macos")] +use macos as platform; +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +use unsupported as platform; + +/// Platforms without confinement: every launch is refused. +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod unsupported { + use super::{Command, OsString, Path, Result, SandboxError, Support, policy::Checked}; + + const WHY: &str = "GridCraft confines programs on Linux and macOS only"; + + pub fn support() -> Support { + Support::Unsupported(WHY.into()) + } + + pub fn command(_: &Checked, _: &Path, _: &[OsString]) -> Result { + Err(SandboxError::Unsupported(WHY.into())) + } + + pub fn confine(_: &Checked, _: u32, _: &[OsString]) -> Result<()> { + Err(SandboxError::Unsupported(WHY.into())) + } + + pub fn exec(_: &Path, _: &[OsString]) -> Result { + Err(SandboxError::Unsupported(WHY.into())) + } +} + +/// Closes every descriptor besides standard input, output and error, and refuses the launch if one +/// stays open or the list can't be read. One left open (a file outside the policy, a connected +/// socket) would survive `exec`, and the confinement doesn't revoke what is already open. Refusing +/// alone wouldn't do: in a desktop app the system's own libraries (Metal's shader cache on macOS) +/// leave files open without close-on-exec, and they reach every child. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn close_inherited_descriptors() -> Result<()> { + for fd in descriptors_above_stdio()? { + // The listing's own descriptor is on the list and is closed already; that error is fine. + let _ = nix::unistd::close(fd); + } + let left = descriptors_above_stdio()?; + // The second listing holds its own directory open, so one above stdio is expected. + if left.len() > 1 { + return Err(SandboxError::NotEnforced(format!("descriptors other than standard input, output and error stayed open ({left:?})"))); + } + Ok(()) +} + +/// The open descriptors above standard error, the listing's own included. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn descriptors_above_stdio() -> Result> { + let dir = if cfg!(target_os = "linux") { "/proc/self/fd" } else { "/dev/fd" }; + let listing = std::fs::read_dir(dir).map_err(|e| SandboxError::NotEnforced(format!("can't list open descriptors ({dir}): {e}")))?; + let mut above_stdio = Vec::new(); + for entry in listing { + let entry = entry.map_err(|e| SandboxError::NotEnforced(format!("can't list open descriptors ({dir}): {e}")))?; + match entry.file_name().to_str().and_then(|n| n.parse::().ok()) { + Some(fd) if fd > 2 => above_stdio.push(fd), + Some(_) => {} + None => return Err(SandboxError::NotEnforced(format!("unexpected entry in {dir}"))), + } + } + above_stdio.sort_unstable(); + Ok(above_stdio) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn close_inherited_descriptors() -> Result<()> { + Ok(()) +} + +/// Replaces this process with `program` (the confining platforms). +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn exec_unix(program: &Path, args: &[OsString]) -> Result { + use std::os::unix::process::CommandExt as _; + let mut cmd = Command::new(program); + cmd.args(args).env_clear().env("PATH", CONFINED_PATH); + for name in KEPT_ENV { + if let Some(v) = std::env::var_os(name) { + cmd.env(name, v); + } + } + // `exec` returns only when it failed. + Err(SandboxError::Io(cmd.exec())) +} + +/// The only variables a confined program inherits, besides [`CONFINED_PATH`]: the locale and the +/// time zone. Everything else (tokens, credentials, the user's paths) stays with the parent. +pub const KEPT_ENV: [&str; 4] = ["LANG", "LC_ALL", "LC_CTYPE", "TZ"]; + +/// The `PATH` a confined program sees. +pub const CONFINED_PATH: &str = "/usr/bin:/bin"; + +#[cfg(test)] +mod tests; diff --git a/crates/sandbox/src/linux.rs b/crates/sandbox/src/linux.rs new file mode 100644 index 000000000..aaeff2647 --- /dev/null +++ b/crates/sandbox/src/linux.rs @@ -0,0 +1,497 @@ +//! Linux: the launcher confines itself with Landlock, then seccomp, then `exec`s the program, +//! which inherits both. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::Path; +use std::process::Command; + +use landlock::{ + ABI, Access, AccessFs, AccessNet, CompatLevel, Compatible, CreateRulesetError, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetError, + RulesetStatus, Scope, path_beneath_rules, +}; +use seccompiler::{BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, SeccompRule, TargetArch}; + +use crate::policy::Checked; +use crate::{Result, SandboxError, Support}; + +/// The oldest Landlock ABI a launch accepts, whose filesystem rights are required: the first that +/// covers truncating a file (Linux 6.2). Before it, `truncate` and `open` with `O_TRUNC` empty a file +/// whatever the rules say. A filter can't take its place: `openat2` hides its flags from seccomp, and +/// refusing `O_TRUNC` would refuse opening `/dev/null` to write, as `open(os.devnull, "w")` does. +const OLDEST: ABI = ABI::V3; +/// The newest Landlock ABI this crate knows. Rights it adds past [`OLDEST`] are requested on a +/// best-effort basis. +const NEWEST: ABI = ABI::V9; + +// System calls the `libc` crate doesn't name on both architectures. Numbers from 424 up are the same +// on x86_64 and aarch64. +const SYS_FCHMODAT2: libc::c_long = 452; +const SYS_SETXATTRAT: libc::c_long = 463; +const SYS_REMOVEXATTRAT: libc::c_long = 466; +const SYS_OPEN_TREE_ATTR: libc::c_long = 467; +const SYS_FILE_SETATTR: libc::c_long = 469; + +pub fn support() -> Support { + match arch() { + Some(_) => Support::Available("Landlock and seccomp (checked again at each launch)".into()), + None => Support::Unsupported(format!("seccomp filters are built for x86_64 and aarch64, not {}", std::env::consts::ARCH)), + } +} + +/// Linux runs the launcher directly: it confines itself. +pub fn command(_: &Checked, exe: &Path, launch: &[OsString]) -> Result { + arch().ok_or_else(|| SandboxError::Unsupported(format!("no seccomp filter for {}", std::env::consts::ARCH)))?; + let mut c = Command::new(exe); + c.args(launch); + Ok(c) +} + +pub fn confine(policy: &Checked, _: u32, _: &[OsString]) -> Result<()> { + landlock(policy)?; + seccomp() +} + +pub fn exec(program: &Path, args: &[OsString]) -> Result { + crate::exec_unix(program, args) +} + +fn not_enforced(e: impl std::fmt::Display) -> SandboxError { + SandboxError::NotEnforced(e.to_string()) +} + +/// A kernel without Landlock, or with it off, in the Landlock crate's own text. Only that case says +/// this, since the tests that skip on such a machine look for it: any other failure, such as too many +/// layers or a refused ruleset, comes from a machine that has Landlock, which must not be taken for +/// one that doesn't. +fn landlock_not_enforced(e: impl std::fmt::Display) -> SandboxError { + SandboxError::NotEnforced(format!("Landlock is not enforced: {e}")) +} + +/// What a failed `landlock_create_ruleset` says about the kernel: `ENOSYS` is one built without +/// Landlock and `EOPNOTSUPP` one with it off at boot. Anything else, such as `EINVAL` or too many open +/// files, comes from a kernel that has it. +fn says_no_landlock(errno: Option) -> bool { + matches!(errno, Some(libc::ENOSYS | libc::EOPNOTSUPP)) +} + +/// A kernel without the rights a launch requires ([`OLDEST`]). +fn too_old_for_truncation(e: impl std::fmt::Display) -> SandboxError { + landlock_not_enforced(format!("this kernel has no Landlock, or one older than ABI 3 (Linux 6.2), which can't refuse truncating a file: {e}")) +} + +fn ruleset_error(e: RulesetError) -> SandboxError { + let absent = + matches!(&e, RulesetError::CreateRuleset(CreateRulesetError::CreateRulesetCall { source, .. }) if says_no_landlock(source.raw_os_error())); + if absent { landlock_not_enforced(e) } else { not_enforced(e) } +} + +/// Filesystem: every right Landlock handles is denied except reading the policy's read paths, +/// executing (and reading) its exec paths, and `/dev/null`. On kernels with network rights (ABI +/// 4), TCP binding and connecting are denied too; with scopes (ABI 6), signals to and abstract +/// Unix sockets of processes outside the sandbox. +fn landlock(policy: &Checked) -> Result<()> { + let ruleset = Ruleset::default() + .set_compatibility(CompatLevel::HardRequirement) + .handle_access(AccessFs::from_all(OLDEST)) + .map_err(too_old_for_truncation)? + .set_compatibility(CompatLevel::BestEffort) + .handle_access(AccessFs::from_all(NEWEST)) + .and_then(|r| r.handle_access(AccessNet::from_all(NEWEST))) + .and_then(|r| r.scope(Scope::from_all(NEWEST))) + .map_err(not_enforced)? + .create() + .map_err(ruleset_error)?; + let read = AccessFs::ReadFile | AccessFs::ReadDir; + let ruleset = ruleset + .set_compatibility(CompatLevel::HardRequirement) + .add_rules(path_beneath_rules(&policy.read, read)) + .and_then(|r| r.add_rules(path_beneath_rules(&policy.exec, read | AccessFs::Execute))) + .and_then(|r| r.add_rules(path_beneath_rules(["/dev/null"], AccessFs::ReadFile | AccessFs::WriteFile))) + .map_err(not_enforced)?; + let status = ruleset.restrict_self().map_err(not_enforced)?; + if !status.no_new_privs { + return Err(SandboxError::NotEnforced("no_new_privs is not set".into())); + } + // The rights of `OLDEST` are a hard requirement, so "partially" means only newer rights are + // missing on this kernel; "not enforced" means nothing is. + if status.ruleset == RulesetStatus::NotEnforced { + return Err(SandboxError::NotEnforced(format!("Landlock is not enforced ({:?})", status.landlock))); + } + Ok(()) +} + +fn arch() -> Option { + match std::env::consts::ARCH { + "x86_64" => Some(TargetArch::x86_64), + "aarch64" => Some(TargetArch::aarch64), + _ => None, + } +} + +/// System calls that fail with `EPERM` in the sandbox, unconditionally. +fn denied_calls() -> Vec { + let calls = vec![ + // Network: no socket of any family (a Unix datagram socket can send without + // `connect`), and no connection or listener on one the program might hold. + libc::SYS_socket, + libc::SYS_socketpair, + libc::SYS_connect, + libc::SYS_bind, + libc::SYS_listen, + libc::SYS_accept, + libc::SYS_accept4, + // io_uring runs operations, connections included, outside the seccomp filter. + libc::SYS_io_uring_setup, + libc::SYS_io_uring_enter, + libc::SYS_io_uring_register, + // Inspecting or changing other processes. + libc::SYS_ptrace, + libc::SYS_process_vm_readv, + libc::SYS_process_vm_writev, + libc::SYS_pidfd_getfd, + libc::SYS_pidfd_open, + libc::SYS_pidfd_send_signal, + libc::SYS_kcmp, + libc::SYS_get_robust_list, + libc::SYS_migrate_pages, + libc::SYS_move_pages, + // System V shared memory, message queues and semaphores: a segment or queue this user can + // open holds what another process put there, and no path rule covers them. + libc::SYS_shmget, + libc::SYS_shmat, + libc::SYS_shmctl, + libc::SYS_shmdt, + libc::SYS_msgget, + libc::SYS_msgsnd, + libc::SYS_msgrcv, + libc::SYS_msgctl, + libc::SYS_semget, + libc::SYS_semop, + libc::SYS_semctl, + libc::SYS_semtimedop, + // What changes a file without Landlock having a right for it: permissions, owner, extended + // attributes, times and flags, by path or by descriptor, on any file this user owns. + libc::SYS_fchmod, + libc::SYS_fchmodat, + SYS_FCHMODAT2, + libc::SYS_fchown, + libc::SYS_fchownat, + libc::SYS_setxattr, + libc::SYS_lsetxattr, + libc::SYS_fsetxattr, + SYS_SETXATTRAT, + libc::SYS_removexattr, + libc::SYS_lremovexattr, + libc::SYS_fremovexattr, + SYS_REMOVEXATTRAT, + libc::SYS_utimensat, + SYS_FILE_SETATTR, + // POSIX message queues, which no path rule covers either: another process of the same user can + // open one, by name. + libc::SYS_mq_open, + libc::SYS_mq_unlink, + libc::SYS_mq_timedsend, + libc::SYS_mq_timedreceive, + libc::SYS_mq_notify, + libc::SYS_mq_getsetattr, + // Mounts, namespaces and file handles (ways around path-based rules). + libc::SYS_mount, + libc::SYS_umount2, + libc::SYS_pivot_root, + libc::SYS_chroot, + libc::SYS_unshare, + libc::SYS_setns, + libc::SYS_open_by_handle_at, + libc::SYS_name_to_handle_at, + libc::SYS_open_tree, + SYS_OPEN_TREE_ATTR, + libc::SYS_move_mount, + libc::SYS_fsopen, + libc::SYS_fsconfig, + libc::SYS_fsmount, + libc::SYS_fspick, + libc::SYS_mount_setattr, + // The kernel's own extension points and system state. + libc::SYS_bpf, + libc::SYS_perf_event_open, + libc::SYS_userfaultfd, + libc::SYS_keyctl, + libc::SYS_add_key, + libc::SYS_request_key, + libc::SYS_init_module, + libc::SYS_finit_module, + libc::SYS_delete_module, + libc::SYS_kexec_load, + libc::SYS_kexec_file_load, + libc::SYS_swapon, + libc::SYS_swapoff, + libc::SYS_reboot, + libc::SYS_syslog, + ]; + // x86_64 keeps the older process and file calls and port I/O; aarch64 has none of them. + #[cfg(target_arch = "x86_64")] + let calls = [ + calls, + vec![ + libc::SYS_fork, + libc::SYS_vfork, + libc::SYS_iopl, + libc::SYS_ioperm, + libc::SYS_chmod, + libc::SYS_chown, + libc::SYS_lchown, + libc::SYS_utime, + libc::SYS_utimes, + libc::SYS_futimesat, + ], + ] + .concat(); + calls +} + +/// `who` and `which` of `setpriority` and `ioprio_set` for one process. +const PRIO_PROCESS: u64 = 0; +const IOPRIO_WHO_PROCESS: u64 = 1; +// `libc::Ioctl` differs by C library. +#[allow(clippy::unnecessary_cast)] +const TIOCSTI: u64 = libc::TIOCSTI as u64; +#[allow(clippy::unnecessary_cast)] +const TIOCLINUX: u64 = libc::TIOCLINUX as u64; +/// The bit of an `ioctl` request, on both architectures, that says it hands the kernel data. +const IOC_WRITE: u64 = 0x4000_0000; +/// Changes a directory, though its number says it only reads, as the kernel's headers have it. +const FS_IOC_SET_ENCRYPTION_POLICY: u64 = 0x800C_6613; + +/// What the main filter refuses with `EPERM`: the calls above, and these when their arguments match. +/// - `clone` without `CLONE_THREAD`: a new process. +/// - A signal to any process but `me`, whose ID `exec` keeps. Landlock's signal scope needs a newer +/// kernel than the rest (ABI 6), and `kill(-1, 9)` must not get through on an older one. +/// - A change to the limits, priority or scheduling of any process but `me`. +/// - `TIOCSTI` and `TIOCLINUX`, which type into a terminal the program inherited, as when +/// `gridcraft-cli sandbox-check` runs a program from a shell. +/// - An `ioctl` that hands the kernel data ([`IOC_WRITE`]), as those that change a file's flags or +/// attributes do (`FS_IOC_SETFLAGS`, `FS_IOC_FSSETXATTR`) on a descriptor opened only to read. +/// Landlock's rights don't cover them for files. Those that ask, as `isatty` does, and those that +/// set a descriptor's own flags, as `FIONBIO` does, carry no such bit and still work. +/// +/// Process IDs and `ioctl` requests are compared as the kernel reads them, in 32 bits, so high bits +/// set on the 64-bit argument don't get past a rule. +fn main_rules(me: u32) -> Result>> { + let me = u64::from(me); + let word = |arg: u8, op: SeccompCmpOp, value: u64| SeccompCondition::new(arg, SeccompCmpArgLen::Dword, op, value).map_err(not_enforced); + let rule = |conditions: Vec| SeccompRule::new(conditions).map_err(not_enforced); + let mut rules: BTreeMap> = denied_calls().into_iter().map(|nr| (nr, Vec::new())).collect(); + let clone_thread = u64::try_from(libc::CLONE_THREAD).map_err(not_enforced)?; + let new_process = SeccompCondition::new(0, SeccompCmpArgLen::Qword, SeccompCmpOp::MaskedEq(clone_thread), 0).map_err(not_enforced)?; + rules.insert(libc::SYS_clone, vec![rule(vec![new_process])?]); + for nr in [libc::SYS_kill, libc::SYS_tkill, libc::SYS_tgkill, libc::SYS_rt_sigqueueinfo, libc::SYS_rt_tgsigqueueinfo] { + rules.insert(nr, vec![rule(vec![word(0, SeccompCmpOp::Ne, me)?])?]); + } + // These take 0 for the caller too. + for nr in [libc::SYS_prlimit64, libc::SYS_sched_setaffinity, libc::SYS_sched_setscheduler, libc::SYS_sched_setparam, libc::SYS_sched_setattr] { + rules.insert(nr, vec![rule(vec![word(0, SeccompCmpOp::Ne, me)?, word(0, SeccompCmpOp::Ne, 0)?])?]); + } + for (nr, one_process) in [(libc::SYS_setpriority, PRIO_PROCESS), (libc::SYS_ioprio_set, IOPRIO_WHO_PROCESS)] { + let another = rule(vec![word(1, SeccompCmpOp::Ne, me)?, word(1, SeccompCmpOp::Ne, 0)?])?; + rules.insert(nr, vec![rule(vec![word(0, SeccompCmpOp::Ne, one_process)?])?, another]); + } + rules.insert( + libc::SYS_ioctl, + vec![ + rule(vec![word(1, SeccompCmpOp::Eq, TIOCSTI)?])?, + rule(vec![word(1, SeccompCmpOp::Eq, TIOCLINUX)?])?, + rule(vec![word(1, SeccompCmpOp::MaskedEq(IOC_WRITE), IOC_WRITE)?])?, + rule(vec![word(1, SeccompCmpOp::Eq, FS_IOC_SET_ENCRYPTION_POLICY)?])?, + ], + ); + Ok(rules) +} + +/// The filters: the first answers `clone3` with `ENOSYS`, so the C library falls back to `clone`, +/// whose flags a filter can read (a thread passes `CLONE_THREAD`; a new process doesn't); the +/// second is [`main_rules`]; on x86_64 a third refuses every x32 call, which carries the same +/// architecture value but other numbers. +fn seccomp() -> Result<()> { + let arch = arch().ok_or_else(|| SandboxError::Unsupported(format!("no seccomp filter for {}", std::env::consts::ARCH)))?; + let program = |rules: BTreeMap>, errno: i32| -> Result { + let errno = u32::try_from(errno).map_err(not_enforced)?; + SeccompFilter::new(rules, SeccompAction::Allow, SeccompAction::Errno(errno), arch).and_then(BpfProgram::try_from).map_err(not_enforced) + }; + let clone3 = program(BTreeMap::from([(libc::SYS_clone3, Vec::new())]), libc::ENOSYS)?; + let main = program(main_rules(std::process::id())?, libc::EPERM)?; + // Landlock already set no_new_privs, which installing a filter without privileges needs. + if std::env::consts::ARCH == "x86_64" { + seccompiler::apply_filter(&x32_filter()).map_err(not_enforced)?; + } + seccompiler::apply_filter(&clone3).map_err(not_enforced)?; + seccompiler::apply_filter(&main).map_err(not_enforced)?; + Ok(()) +} + +/// The x32 bit of a system call number on x86_64. +const X32_SYSCALL_BIT: u32 = 0x4000_0000; + +/// A filter that refuses with `EPERM` every call whose number has the x32 bit. x32 calls report +/// the x86_64 architecture value, so the other filters' architecture check lets them through and +/// their numbers match none of the rules. A call of another architecture is left to the other +/// filters, which end the process. +fn x32_filter() -> BpfProgram { + // `struct seccomp_data`: the call number at offset 0, the architecture at offset 4. + const LD_W_ABS: u16 = 0x20; + const JEQ_K: u16 = 0x15; + const JGE_K: u16 = 0x35; + const RET_K: u16 = 0x06; + const AUDIT_ARCH_X86_64: u32 = 62 | 0x8000_0000 | 0x4000_0000; + let op = |code: u16, jt: u8, jf: u8, k: u32| seccompiler::sock_filter { code, jt, jf, k }; + let errno = u32::try_from(libc::EPERM).unwrap_or(1); + vec![ + op(LD_W_ABS, 0, 0, 4), + op(JEQ_K, 1, 0, AUDIT_ARCH_X86_64), + op(RET_K, 0, 0, libc::SECCOMP_RET_ALLOW), + op(LD_W_ABS, 0, 0, 0), + op(JGE_K, 0, 1, X32_SYSCALL_BIT), + op(RET_K, 0, 0, libc::SECCOMP_RET_ERRNO | errno), + op(RET_K, 0, 0, libc::SECCOMP_RET_ALLOW), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_filters_build_for_both_architectures() { + // Building only: installing one would confine the test process. + for arch in [TargetArch::x86_64, TargetArch::aarch64] { + let f = SeccompFilter::new(main_rules(4242).unwrap(), SeccompAction::Allow, SeccompAction::Errno(1), arch); + assert!(f.and_then(BpfProgram::try_from).is_ok(), "{arch:?}"); + } + } + + #[test] + fn the_calls_that_reach_another_process_have_rules() { + let rules = main_rules(4242).unwrap(); + for nr in [ + libc::SYS_kill, + libc::SYS_tkill, + libc::SYS_tgkill, + libc::SYS_rt_sigqueueinfo, + libc::SYS_rt_tgsigqueueinfo, + libc::SYS_prlimit64, + libc::SYS_setpriority, + libc::SYS_ioprio_set, + libc::SYS_sched_setaffinity, + libc::SYS_sched_setscheduler, + libc::SYS_sched_setparam, + libc::SYS_sched_setattr, + ] { + assert_eq!(rules.get(&nr).map(Vec::len), Some(if nr == libc::SYS_setpriority || nr == libc::SYS_ioprio_set { 2 } else { 1 }), "{nr}"); + } + for nr in [ + libc::SYS_pidfd_open, + libc::SYS_pidfd_send_signal, + libc::SYS_kcmp, + libc::SYS_get_robust_list, + libc::SYS_migrate_pages, + libc::SYS_move_pages, + libc::SYS_shmget, + libc::SYS_shmat, + libc::SYS_msgget, + libc::SYS_semget, + libc::SYS_mq_open, + libc::SYS_mq_unlink, + libc::SYS_mq_timedsend, + libc::SYS_mq_timedreceive, + libc::SYS_mq_notify, + libc::SYS_mq_getsetattr, + libc::SYS_syslog, + ] { + assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{nr} is refused outright"); + } + assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(4)); + } + + #[test] + fn what_landlock_has_no_right_for_is_refused_outright() { + let rules = main_rules(4242).unwrap(); + let every_architecture = [ + (libc::SYS_fchmod, "fchmod"), + (libc::SYS_fchmodat, "fchmodat"), + (SYS_FCHMODAT2, "fchmodat2"), + (libc::SYS_fchown, "fchown"), + (libc::SYS_fchownat, "fchownat"), + (libc::SYS_setxattr, "setxattr"), + (libc::SYS_lsetxattr, "lsetxattr"), + (libc::SYS_fsetxattr, "fsetxattr"), + (SYS_SETXATTRAT, "setxattrat"), + (libc::SYS_removexattr, "removexattr"), + (libc::SYS_lremovexattr, "lremovexattr"), + (libc::SYS_fremovexattr, "fremovexattr"), + (SYS_REMOVEXATTRAT, "removexattrat"), + (libc::SYS_utimensat, "utimensat"), + (SYS_FILE_SETATTR, "file_setattr"), + (SYS_OPEN_TREE_ATTR, "open_tree_attr"), + ]; + for (nr, name) in every_architecture { + assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{name}"); + } + #[cfg(target_arch = "x86_64")] + for (nr, name) in [ + (libc::SYS_chmod, "chmod"), + (libc::SYS_chown, "chown"), + (libc::SYS_lchown, "lchown"), + (libc::SYS_utime, "utime"), + (libc::SYS_utimes, "utimes"), + (libc::SYS_futimesat, "futimesat"), + ] { + assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{name}"); + } + } + + #[test] + fn the_numbers_written_here_are_the_kernels() { + // `libc` names this one on x86_64, so a slip in the shared table shows there. + #[cfg(target_arch = "x86_64")] + assert_eq!(SYS_FCHMODAT2, libc::SYS_fchmodat2); + assert_eq!((SYS_SETXATTRAT, SYS_REMOVEXATTRAT, SYS_OPEN_TREE_ATTR, SYS_FILE_SETATTR), (463, 466, 467, 469)); + } + + #[test] + fn a_kernel_that_cant_refuse_truncating_a_file_is_not_accepted() { + assert!(AccessFs::from_all(OLDEST).contains(AccessFs::Truncate)); + assert!(!AccessFs::from_all(ABI::V2).contains(AccessFs::Truncate), "the oldest ABI with the right"); + // It reads as a machine that can't confine, which the tests that skip on one look for. + let e = too_old_for_truncation("the crate's text").to_string(); + assert!(e.contains("Landlock is not enforced:") && e.contains("Linux 6.2") && e.contains("the crate's text"), "{e}"); + } + + #[test] + fn a_kernel_without_landlock_says_so() { + // What the tests and a person read to tell this from a sandbox that failed its check. + assert!(landlock_not_enforced("the crate's text").to_string().contains("Landlock is not enforced: the crate's text")); + } + + #[test] + fn only_a_missing_or_disabled_landlock_says_it_is_not_enforced() { + assert!(says_no_landlock(Some(libc::ENOSYS))); + assert!(says_no_landlock(Some(libc::EOPNOTSUPP))); + for errno in [libc::EINVAL, libc::E2BIG, libc::EFAULT, libc::EMFILE, libc::ENOMEM, libc::EPERM] { + assert!(!says_no_landlock(Some(errno)), "{errno}"); + } + assert!(!says_no_landlock(None)); + // A ruleset failure of another kind, from the crate itself, on any kernel. + let e = Ruleset::default().create().err().map(ruleset_error).map(|e| e.to_string()); + assert!(e.is_some_and(|e| !e.contains("Landlock is not enforced"))); + } + + #[test] + fn the_x32_filter_refuses_numbers_with_the_x32_bit() { + let f = x32_filter(); + assert_eq!(f.len(), 7); + // Load the number, then jump past the refusal for numbers below the x32 bit. + assert_eq!((f[3].code, f[3].k), (0x20, 0)); + assert_eq!((f[4].code, f[4].jt, f[4].jf, f[4].k), (0x35, 0, 1, X32_SYSCALL_BIT)); + assert_eq!(f[5].k, libc::SECCOMP_RET_ERRNO | 1); + } +} diff --git a/crates/sandbox/src/macos.rs b/crates/sandbox/src/macos.rs new file mode 100644 index 000000000..090f28ccd --- /dev/null +++ b/crates/sandbox/src/macos.rs @@ -0,0 +1,92 @@ +//! macOS: the launcher runs itself again under `sandbox-exec` with a deny-by-default profile built +//! from the policy, checks that the profile holds, and `exec`s the program, which stays under it. + +use std::ffi::OsString; +use std::os::unix::process::CommandExt as _; +use std::path::Path; +use std::process::Command; + +use crate::policy::Checked; +use crate::{CONFINED_FLAG, Result, SandboxError, Support}; + +const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec"; + +pub fn support() -> Support { + if Path::new(SANDBOX_EXEC).exists() { + Support::Available("sandbox-exec with a deny-by-default profile (checked again at each launch)".into()) + } else { + Support::Unsupported(format!("{SANDBOX_EXEC} is missing")) + } +} + +/// The launcher starts unconfined, so it can look at what it inherited first ([`confine`]). A +/// policy the profile can't express is refused here, before anything starts. +pub fn command(policy: &Checked, exe: &Path, launch: &[OsString]) -> Result { + if !Path::new(SANDBOX_EXEC).exists() { + return Err(SandboxError::Unsupported(format!("{SANDBOX_EXEC} is missing"))); + } + profile(policy)?; + let mut c = Command::new(exe); + c.args(launch); + Ok(c) +} + +/// Runs the launcher again under `sandbox-exec`, with the profile applied before it starts; the +/// enforcement check of that run proves it. Returns only on failure. +pub fn confine(policy: &Checked, target: u32, args: &[OsString]) -> Result<()> { + let exe = std::env::current_exe()?; + let mut c = Command::new(SANDBOX_EXEC); + c.arg("-p").arg(profile(policy)?).arg(exe).arg(CONFINED_FLAG).arg(target.to_string()).args(args); + Err(SandboxError::Io(c.exec())) +} + +pub fn exec(program: &Path, args: &[OsString]) -> Result { + crate::exec_unix(program, args) +} + +/// The profile: everything is denied except what the base system profile allows a process to +/// start (`system.sb`: the system libraries, the dynamic loader's cache and a few system +/// services), reading the policy's paths, executing its programs, reading file metadata (path +/// resolution), reading system settings (`sysctl`) and writing `/dev/null`. There is no rule for +/// starting processes, writing files or the network, so they stay denied. +pub fn profile(policy: &Checked) -> Result { + let mut p = String::from("(version 1)\n(deny default)\n(import \"system.sb\")\n"); + p.push_str("(allow process-exec"); + for path in &policy.exec { + p.push_str(&path_rule(path)?); + } + p.push_str(")\n(allow file-read*"); + for path in policy.exec.iter().chain(&policy.read) { + p.push_str(&path_rule(path)?); + } + p.push_str(")\n(allow file-read-metadata)\n(allow sysctl-read)\n(allow file-write-data (literal \"/dev/null\"))\n"); + Ok(p) +} + +/// A profile rule for `path`. A literal matches only that path, so a directory, whose contents the +/// policy means, as Landlock takes it on Linux, is a subpath. +fn path_rule(path: &Path) -> Result { + let rule = if path.is_dir() { "subpath" } else { "literal" }; + Ok(format!(" ({rule} {})", quoted(path)?)) +} + +/// `path` as a profile string literal: in double quotes, with `"` and `\` escaped. Paths with +/// other control characters (the policy already refuses NUL and line breaks) or that aren't UTF-8 +/// are refused rather than quoted. +pub fn quoted(path: &Path) -> Result { + let s = path.to_str().ok_or_else(|| SandboxError::BadPolicy(format!("{} is not UTF-8", path.display())))?; + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' | '\\' => { + out.push('\\'); + out.push(c); + } + c if c.is_control() => return Err(SandboxError::BadPolicy(format!("{} holds a control character", path.display()))), + c => out.push(c), + } + } + out.push('"'); + Ok(out) +} diff --git a/crates/sandbox/src/policy.rs b/crates/sandbox/src/policy.rs new file mode 100644 index 000000000..d39f07fcf --- /dev/null +++ b/crates/sandbox/src/policy.rs @@ -0,0 +1,214 @@ +//! What a confined program may touch, and how the launcher receives it on its command line. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +use crate::{Canary, Result, SandboxError}; + +/// At most this many paths in each list. +pub const MAX_PATHS: usize = 64; +/// At most this many bytes in one path. +pub const MAX_PATH_BYTES: usize = 4096; + +/// The paths a confined program may read and the programs it may execute. Everything else is +/// denied: writing anywhere, network access and starting other processes have no switch. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Policy { + read: Vec, + exec: Vec, +} + +impl Policy { + pub fn new() -> Policy { + Policy::default() + } + + /// Lets the program read `path`: a file, or a directory and everything under it. + pub fn read(mut self, path: impl Into) -> Policy { + self.read.push(path.into()); + self + } + + /// Lets the program execute (and read) the file `path`. + pub fn exec(mut self, path: impl Into) -> Policy { + self.exec.push(path.into()); + self + } + + /// Adds what a dynamically linked program needs to start on this system: the shared-library + /// directories and the dynamic loader's cache on Linux (macOS's base profile covers its own). + /// Paths that don't exist here are skipped. + pub fn with_system_libraries(mut self) -> Policy { + if cfg!(target_os = "linux") { + for dir in ["/lib", "/lib64", "/usr/lib", "/usr/lib64"] { + // The dynamic loader is executed, so its directories get execute access. + if Path::new(dir).exists() { + self.exec.push(dir.into()); + } + } + // What the C library and Python's `zoneinfo` read for the time zone. + for path in ["/etc/ld.so.cache", "/etc/localtime", "/usr/share/zoneinfo"] { + if Path::new(path).exists() { + self.read.push(path.into()); + } + } + } + // Fonts, which a plot's text is drawn with. Only the system's: fonts in the home folder stay + // out of reach. macOS's own `/System/Library/Fonts` comes with its base profile. + let fonts: &[&str] = if cfg!(target_os = "linux") { + &["/usr/share/fonts", "/usr/local/share/fonts"] + } else if cfg!(target_os = "macos") { + &["/Library/Fonts"] + } else { + &[] + }; + for dir in fonts { + if Path::new(dir).exists() { + self.read.push((*dir).into()); + } + } + self + } + + pub fn read_paths(&self) -> &[PathBuf] { + &self.read + } + + pub fn exec_paths(&self) -> &[PathBuf] { + &self.exec + } + + /// The policy as the launcher will apply it: every path absolute, existing and canonical, the + /// lists within their caps, the current executable `exe` executable (the enforcement check + /// starts it), `program` executable, and the canary outside every allowed path. + pub(crate) fn checked(&self, exe: &Path, program: &Path, canary: &Canary) -> Result { + let read = canonical_all(&self.read)?; + let mut exec = canonical_all(&self.exec)?; + let program = canonical(program)?; + for extra in [canonical(exe)?, program.clone()] { + if !exec.contains(&extra) { + exec.push(extra); + } + } + if exec.len() > MAX_PATHS { + return Err(SandboxError::BadPolicy(format!("more than {MAX_PATHS} executable paths"))); + } + let probe = canonical(canary.file())?; + if read.iter().chain(&exec).any(|p| probe.starts_with(p)) { + return Err(SandboxError::BadPolicy(format!("the canary {} is inside an allowed path", probe.display()))); + } + Ok(Checked { read, exec, program, args: Vec::new() }) + } +} + +fn canonical_all(paths: &[PathBuf]) -> Result> { + if paths.len() > MAX_PATHS { + return Err(SandboxError::BadPolicy(format!("more than {MAX_PATHS} paths"))); + } + let mut out: Vec = Vec::with_capacity(paths.len()); + for p in paths { + let c = canonical(p)?; + if !out.contains(&c) { + out.push(c); + } + } + Ok(out) +} + +/// `path` made canonical, after refusing what can't be one: an empty or relative path, one over +/// [`MAX_PATH_BYTES`], one with a NUL or a line break (which no sandbox profile can quote), and +/// one that doesn't exist. +fn canonical(path: &Path) -> Result { + let bytes = path.as_os_str().as_encoded_bytes(); + if bytes.is_empty() || !path.is_absolute() { + return Err(SandboxError::BadPolicy(format!("{} is not an absolute path", path.display()))); + } + if bytes.len() > MAX_PATH_BYTES { + return Err(SandboxError::BadPolicy(format!("a path is longer than {MAX_PATH_BYTES} bytes"))); + } + if bytes.iter().any(|b| matches!(b, 0 | b'\n' | b'\r')) { + return Err(SandboxError::BadPolicy("a path holds a NUL or a line break".into())); + } + let c = path.canonicalize().map_err(|e| SandboxError::BadPolicy(format!("{}: {e}", path.display())))?; + if c.as_os_str().as_encoded_bytes().len() > MAX_PATH_BYTES { + return Err(SandboxError::BadPolicy(format!("a path is longer than {MAX_PATH_BYTES} bytes"))); + } + Ok(c) +} + +/// A policy checked by [`Policy::checked`], with the program and its arguments: what the launcher +/// applies. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Checked { + pub read: Vec, + pub exec: Vec, + program: PathBuf, + args: Vec, +} + +impl Checked { + pub fn program(&self) -> &Path { + &self.program + } + + pub fn args(&self) -> &[OsString] { + &self.args + } + + /// The launcher's options for this policy (`--read`, `--exec`, `--probe`), without the + /// program, which follows a `--`. + pub fn to_args(&self, canary: &Path) -> Vec { + let mut out = Vec::new(); + for (flag, paths) in [("--read", &self.read), ("--exec", &self.exec)] { + for p in paths { + out.push(OsString::from(flag)); + out.push(p.as_os_str().to_owned()); + } + } + out.push(OsString::from("--probe")); + out.push(canary.as_os_str().to_owned()); + out + } + + /// Reads the launcher's arguments back: options, `--`, the program and its arguments. The + /// paths are checked again (the launcher trusts nothing), and the canary is required. + pub fn from_args(args: &[OsString]) -> Result<(Checked, PathBuf)> { + let mut read = Vec::new(); + let mut exec = Vec::new(); + let mut probe: Option = None; + let mut it = args.iter(); + loop { + let Some(flag) = it.next() else { return Err(SandboxError::BadPolicy("no program after `--`".into())) }; + if flag == OsStr::new("--") { + break; + } + let value = it.next().ok_or_else(|| SandboxError::BadPolicy(format!("{} needs a path", flag.to_string_lossy())))?; + let path = canonical(Path::new(value))?; + match flag.to_str() { + Some("--read") => read.push(path), + Some("--exec") => exec.push(path), + Some("--probe") if probe.is_none() => probe = Some(path), + _ => return Err(SandboxError::BadPolicy(format!("unknown launcher option {}", flag.to_string_lossy()))), + } + if read.len() > MAX_PATHS || exec.len() > MAX_PATHS { + return Err(SandboxError::BadPolicy(format!("more than {MAX_PATHS} paths"))); + } + } + let program = it.next().ok_or_else(|| SandboxError::BadPolicy("no program after `--`".into()))?; + let program = canonical(Path::new(program))?; + if !exec.contains(&program) { + return Err(SandboxError::BadPolicy(format!("{} is not an executable path of the policy", program.display()))); + } + let probe = probe.ok_or_else(|| SandboxError::BadPolicy("no canary (`--probe`)".into()))?; + if read.iter().chain(&exec).any(|p| probe.starts_with(p)) { + return Err(SandboxError::BadPolicy(format!("the canary {} is inside an allowed path", probe.display()))); + } + // The enforcement check starts the current executable. Outside the executable paths, the file + // rules would refuse that start, and the check would prove nothing about new processes. + let exe = canonical(&std::env::current_exe()?)?; + if !exec.iter().any(|p| exe.starts_with(p)) { + return Err(SandboxError::BadPolicy(format!("the current executable {} is not an executable path of the policy", exe.display()))); + } + Ok((Checked { read, exec, program, args: it.cloned().collect() }, probe)) + } +} diff --git a/crates/sandbox/src/probe.rs b/crates/sandbox/src/probe.rs new file mode 100644 index 000000000..bf66a0abe --- /dev/null +++ b/crates/sandbox/src/probe.rs @@ -0,0 +1,248 @@ +//! The enforcement check a confined launcher runs before it starts its program, and the fuller +//! report `gridcraft-cli sandbox-check` prints. + +use std::ffi::OsString; +use std::io::ErrorKind; +use std::net::{SocketAddr, TcpStream, UdpSocket}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use crate::PROBE_CHILD_FLAG; + +/// What one attempt found. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + /// The attempt was refused by the system, as the sandbox requires. + Denied, + /// The attempt was not refused (it worked, or failed for another reason, which doesn't prove + /// the sandbox); the text says what happened. + Allowed(String), +} + +/// One attempt and its outcome. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Check { + pub name: &'static str, + pub outcome: Outcome, +} + +/// A loopback address with nothing to talk to: unconfined, connecting fails with "refused", which +/// is not a denial; confined, creating the socket or connecting is denied. +const TCP_TARGET: ([u8; 4], u16) = ([127, 0, 0, 1], 9); + +/// Every attempt that must be denied, against `canary` (a file outside the policy), `exe` (the +/// current executable, which the process check tries to start) and `target` (from +/// [`signal_target`]). Each must report [`Outcome::Denied`] before the launcher runs its program. +pub fn enforcement(canary: &Path, exe: &Path, target: u32) -> Vec { + vec![ + Check { name: "read the canary", outcome: denied(std::fs::read(canary).map(drop)) }, + Check { name: "change the canary's permissions", outcome: denied(chmod_probe(canary)) }, + Check { name: "truncate the canary", outcome: denied(truncate_probe(canary)) }, + Check { name: "write next to the canary", outcome: write_probe(canary.parent().unwrap_or(canary)) }, + Check { name: "write in the temporary directory", outcome: write_probe(&std::env::temp_dir()) }, + Check { name: "TCP connection", outcome: denied(tcp_probe()) }, + Check { name: "start a process", outcome: spawn_probe(exe) }, + Check { name: "signal another process", outcome: signal_probe(target) }, + ] +} + +/// Asks for the permissions the file has, so nothing changes even where this is allowed. +fn chmod_probe(file: &Path) -> std::io::Result<()> { + std::fs::set_permissions(file, std::fs::metadata(file)?.permissions()) +} + +/// Asks for the length the file has, so nothing changes even where this is allowed. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn truncate_probe(file: &Path) -> std::io::Result<()> { + let length = nix::libc::off_t::try_from(std::fs::metadata(file)?.len()).map_err(|e| std::io::Error::new(ErrorKind::InvalidInput, e))?; + nix::unistd::truncate(file, length).map_err(std::io::Error::from) +} + +/// Other systems have no confinement, so no launch gets this far. +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn truncate_probe(_: &Path) -> std::io::Result<()> { + Err(std::io::Error::new(ErrorKind::Unsupported, "this system has no sandbox")) +} + +/// Signal 0 to `process`, which delivers nothing: it only asks whether a signal would be allowed. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn signal_zero(process: u32) -> std::io::Result<()> { + let pid = i32::try_from(process).map_err(|e| std::io::Error::new(ErrorKind::InvalidInput, e))?; + nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None).map_err(std::io::Error::from) +} + +/// The process the check tries a signal on: this launcher's parent, if a signal to it is allowed +/// now, before any confinement. One that refuses already (PID 1, another user's process) would be +/// refused afterwards too, which would prove nothing, so the launch is refused instead. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub fn signal_target() -> std::result::Result { + signalable(std::os::unix::process::parent_id()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn signalable(process: u32) -> std::result::Result { + signal_zero(process) + .map(|()| process) + .map_err(|e| format!("a signal to process {process} fails before any confinement, so its refusal would prove nothing: {e}")) +} + +/// Whether a signal to `target` is refused now. A parent that changed since [`signal_target`] looked +/// is another process, which may refuse for its own reasons (a launcher whose parent exited has PID 1 +/// for a parent). +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn signal_probe(target: u32) -> Outcome { + if std::os::unix::process::parent_id() != target { + return Outcome::Allowed(format!("this launcher's parent is no longer process {target}")); + } + denied(signal_zero(target)) +} + +/// Other systems have no confinement, so no launch gets this far. +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub fn signal_target() -> std::result::Result { + Err("this system has no sandbox".into()) +} + +/// Other systems have no confinement, so no launch gets this far. +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn signal_probe(_: u32) -> Outcome { + Outcome::Allowed("this system has no sandbox".into()) +} + +/// `Denied` when `r` failed with "permission denied" (`EACCES` or `EPERM`), else what happened. +fn denied(r: std::io::Result<()>) -> Outcome { + match r { + Ok(()) => Outcome::Allowed("it worked".into()), + Err(e) if e.kind() == ErrorKind::PermissionDenied => Outcome::Denied, + Err(e) => Outcome::Allowed(format!("it failed for another reason: {e}")), + } +} + +fn write_probe(dir: &Path) -> Outcome { + let file = dir.join(format!("gridcraft-sandbox-write-probe-{}", std::process::id())); + let r = std::fs::OpenOptions::new().write(true).create_new(true).open(&file); + if r.is_ok() { + let _ = std::fs::remove_file(&file); + } + denied(r.map(drop)) +} + +fn tcp_probe() -> std::io::Result<()> { + TcpStream::connect_timeout(&SocketAddr::from(TCP_TARGET), Duration::from_secs(2)).map(drop) +} + +fn spawn_probe(exe: &Path) -> Outcome { + let child = Command::new(exe).arg(PROBE_CHILD_FLAG).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn(); + match child { + Ok(mut c) => { + let _ = c.wait(); + Outcome::Allowed("a process started".into()) + } + // A start refused by the sandbox: no new process (seccomp, or the macOS profile). + Err(e) if e.kind() == ErrorKind::PermissionDenied => Outcome::Denied, + Err(e) => Outcome::Allowed(format!("it failed for another reason: {e}")), + } +} + +/// `--sandbox-report [ []]`, run confined: prints one line per attempt, +/// `denied: ` or `allowed: ()`, then the attempts that must work, and exits +/// with 0 when every line is as the sandbox requires, 1 otherwise. Used by +/// `gridcraft-cli sandbox-check` and the tests. +pub fn report(args: &[OsString]) -> i32 { + let (Some(canary), readable) = (args.first(), args.get(1)) else { + eprintln!("gridcraft-sandbox: --sandbox-report needs the canary's path"); + return 2; + }; + let exe = std::env::current_exe().unwrap_or_default(); + let mut ok = true; + // The launcher looked at this process's parent before it confined itself, and `exec` keeps it. + #[cfg(any(target_os = "linux", target_os = "macos"))] + let target = std::os::unix::process::parent_id(); + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let target = 0; + let mut checks = enforcement(Path::new(canary), &exe, target); + checks.push(Check { name: "UDP socket", outcome: denied(UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).map(drop)) }); + // The person's own files: not part of the launch check, since a policy may allow a directory + // under the home directory (a Python installed there), but no policy here allows the home + // directory itself. + // The confined process inherits no `HOME` (its environment is cleared), so the directory is + // an argument. + if let Some(home) = args.get(2).filter(|h| !h.is_empty()) { + checks.push(Check { name: "list the home directory", outcome: denied(std::fs::read_dir(home).map(drop)) }); + } + for c in &checks { + match &c.outcome { + Outcome::Denied => println!("denied: {}", c.name), + Outcome::Allowed(detail) => { + ok = false; + println!("allowed: {} ({detail})", c.name); + } + } + } + // What a confined program still needs. + let thread = std::thread::Builder::new().spawn(|| 2 + 2).map(|h| h.join().is_ok()); + ok &= worked("start a thread", thread.as_ref().map(|j| *j).unwrap_or(false), thread.err().map(|e| e.to_string())); + if let Some(file) = readable { + let r = std::fs::read(file); + ok &= worked("read an allowed file", r.is_ok(), r.err().map(|e| e.to_string())); + } + i32::from(!ok) +} + +fn worked(name: &str, ok: bool, err: Option) -> bool { + if ok { + println!("works: {name}"); + } else { + println!("broken: {name} ({})", err.unwrap_or_default()); + } + ok +} + +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] +mod tests { + use super::*; + + #[test] + fn a_signal_that_fails_before_any_confinement_proves_nothing_after_it() { + // This test isn't confined, and its parent has the same user. + let parent = std::os::unix::process::parent_id(); + assert_eq!(signal_target(), Ok(parent)); + assert!(matches!(signal_probe(parent), Outcome::Allowed(_)), "allowed unconfined, so no denial"); + // No such process: there is nothing to compare a refusal with, so the launch is refused. + for process in [u32::MAX, i32::MAX as u32] { + let e = signalable(process).unwrap_err(); + assert!(e.contains("before any confinement"), "{e}"); + } + } + + #[test] + fn the_checks_on_the_canary_change_nothing_where_they_are_allowed() { + let canary = crate::Canary::create().unwrap(); + let file = canary.file(); + let state = || { + let m = std::fs::metadata(file).unwrap(); + (std::fs::read(file).unwrap(), m.permissions(), m.len()) + }; + let before = state(); + // This test isn't confined, so each works, which isn't a denial. + assert_eq!(denied(chmod_probe(file)), Outcome::Allowed("it worked".into())); + assert_eq!(denied(truncate_probe(file)), Outcome::Allowed("it worked".into())); + assert_eq!(state(), before); + } + + #[test] + fn a_missing_canary_proves_nothing() { + let canary = crate::Canary::create().unwrap(); + let gone = canary.file().with_file_name("not there"); + for outcome in [denied(chmod_probe(&gone)), denied(truncate_probe(&gone))] { + assert!(matches!(&outcome, Outcome::Allowed(detail) if detail.contains("another reason")), "{outcome:?}"); + } + } + + #[test] + fn a_parent_that_changed_is_not_taken_for_the_one_looked_at() { + let other = std::os::unix::process::parent_id().wrapping_add(1); + assert!(matches!(signal_probe(other), Outcome::Allowed(detail) if detail.contains("no longer"))); + } +} diff --git a/crates/sandbox/src/tests.rs b/crates/sandbox/src/tests.rs new file mode 100644 index 000000000..af1d8ffc5 --- /dev/null +++ b/crates/sandbox/src/tests.rs @@ -0,0 +1,160 @@ +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use crate::policy::{Checked, MAX_PATH_BYTES, MAX_PATHS}; +use crate::{Canary, PROBE_CHILD_FLAG, Policy, SandboxError, handle_args}; + +fn os(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() +} + +fn bad_policy(r: crate::Result) -> String { + match r { + Err(SandboxError::BadPolicy(why)) => why, + other => panic!("expected a bad-policy error, got {other:?}"), + } +} + +/// A directory of its own with a file in it, outside the system's temporary directory's canary. +fn scratch() -> (Canary, PathBuf) { + let canary = Canary::create().unwrap(); + let dir = + std::env::temp_dir().canonicalize().unwrap().join(format!("gridcraft-sandbox-test-{}-{:?}", std::process::id(), std::thread::current().id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("data.txt"), "data").unwrap(); + (canary, dir) +} + +#[test] +fn a_checked_policy_is_canonical_and_executes_the_launcher_and_the_program() { + let (canary, dir) = scratch(); + let exe = std::env::current_exe().unwrap().canonicalize().unwrap(); + let file = dir.join("data.txt"); + let checked = Policy::new().read(&dir).read(&dir).exec(&file).checked(&exe, &file, &canary).unwrap(); + assert_eq!(checked.read, vec![dir.clone()], "duplicates collapse"); + assert_eq!(checked.exec, vec![file.clone(), exe.clone()], "the launcher joins the program"); + assert_eq!(checked.program(), file); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn what_a_policy_refuses() { + let (canary, dir) = scratch(); + let exe = std::env::current_exe().unwrap(); + let ok = dir.join("data.txt"); + let check = |p: Policy| bad_policy(p.checked(&exe, &ok, &canary)); + assert!(check(Policy::new().read("relative/path")).contains("absolute")); + assert!(check(Policy::new().read("")).contains("absolute")); + assert!(check(Policy::new().read(dir.join("missing"))).contains("missing")); + assert!(check(Policy::new().read(PathBuf::from(format!("/{}", "a".repeat(MAX_PATH_BYTES))))).contains("longer")); + let mut many = Policy::new(); + for _ in 0..=MAX_PATHS { + many = many.read(&dir); + } + assert!(check(many).contains("more than")); + // The canary must stay outside every allowed path, or the check would read it. + let around = canary.file().parent().unwrap().to_path_buf(); + assert!(check(Policy::new().read(around)).contains("canary")); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt as _; + let line_break = PathBuf::from(std::ffi::OsStr::from_bytes(b"/tmp/a\nb")); + assert!(check(Policy::new().read(line_break)).contains("line break")); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn the_launcher_reads_its_arguments_back() { + let (canary, dir) = scratch(); + let exe = std::env::current_exe().unwrap(); + let program = dir.join("data.txt"); + let checked = Policy::new().read(&dir).checked(&exe, &program, &canary).unwrap(); + let mut args = checked.to_args(canary.file()); + args.push("--".into()); + args.push(program.clone().into()); + args.extend(os(&["-c", "print(1)"])); + let (back, probe) = Checked::from_args(&args).unwrap(); + assert_eq!(probe, canary.file().canonicalize().unwrap()); + assert_eq!((back.read.clone(), back.exec.clone()), (checked.read.clone(), checked.exec.clone())); + assert_eq!(back.args(), os(&["-c", "print(1)"]).as_slice()); + + let p = program.to_string_lossy().into_owned(); + let c = canary.file().to_string_lossy().into_owned(); + // No canary, a program the policy doesn't execute, an unknown option, a missing program. + assert!(bad_policy(Checked::from_args(&os(&["--exec", &p, "--", &p]))).contains("canary")); + assert!(bad_policy(Checked::from_args(&os(&["--probe", &c, "--", &p]))).contains("not an executable path")); + assert!(bad_policy(Checked::from_args(&os(&["--write", &p, "--probe", &c, "--", &p]))).contains("unknown")); + assert!(bad_policy(Checked::from_args(&os(&["--exec", &p, "--probe", &c, "--"]))).contains("no program")); + assert!(bad_policy(Checked::from_args(&os(&["--exec", &p, "--probe", &c]))).contains("no program")); + assert!(bad_policy(Checked::from_args(&os(&["--exec"]))).contains("needs a path")); + // A second canary isn't taken. + assert!(bad_policy(Checked::from_args(&os(&["--exec", &p, "--probe", &c, "--probe", &c, "--", &p]))).contains("unknown")); + // The enforcement check starts the current executable, so a hand-built list must let it. + assert!(bad_policy(Checked::from_args(&os(&["--exec", &p, "--probe", &c, "--", &p]))).contains("current executable")); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn only_the_sandbox_flags_are_handled() { + assert_eq!(handle_args(&[]), None); + assert_eq!(handle_args(&os(&["--control", "7979"])), None); + assert_eq!(handle_args(&os(&[PROBE_CHILD_FLAG])), Some(0)); +} + +/// A launch whose arguments are refused runs nothing and leaves the process's descriptors alone: +/// closing them here would pull files out from under the tests running beside this one. +#[test] +fn a_launch_refused_for_its_arguments_closes_nothing() { + use std::io::Read as _; + let (_canary, dir) = scratch(); + let mut held = std::fs::File::open(dir.join("data.txt")).unwrap(); + assert_eq!(handle_args(&os(&[crate::LAUNCH_FLAG, "--", "/bin/sh"])), Some(crate::REFUSED_EXIT_CODE)); + let mut text = String::new(); + held.read_to_string(&mut text).unwrap(); + assert_eq!(text, "data"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn the_canary_is_a_fresh_file_removed_on_drop() { + let a = Canary::create().unwrap(); + let b = Canary::create().unwrap(); + assert_ne!(a.file(), b.file()); + assert!(a.file().is_file()); + let dir = a.file().parent().unwrap().to_path_buf(); + drop(a); + assert!(!dir.exists()); + assert!(Path::new(b.file()).exists()); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_directory_of_programs_is_a_subpath_in_both_of_its_rules() { + let (canary, dir) = scratch(); + let exe = std::env::current_exe().unwrap(); + let checked = Policy::new().exec(&dir).checked(&exe, &dir.join("data.txt"), &canary).unwrap(); + let profile = crate::macos::profile(&checked).unwrap(); + let rule = format!("(subpath \"{}\")", dir.display()); + assert_eq!(profile.matches(&rule).count(), 2, "{profile}"); + assert!(!profile.contains(&format!("(literal \"{}\")", dir.display())), "{profile}"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn profile_strings_are_escaped() { + assert_eq!(crate::macos::quoted(Path::new("/a/b c")).unwrap(), "\"/a/b c\""); + assert_eq!(crate::macos::quoted(Path::new("/a\"b\\c")).unwrap(), "\"/a\\\"b\\\\c\""); + assert!(crate::macos::quoted(Path::new("/a\tb")).is_err(), "a control character is refused"); + let (canary, dir) = scratch(); + let exe = std::env::current_exe().unwrap(); + let checked = Policy::new().read(&dir).checked(&exe, &dir.join("data.txt"), &canary).unwrap(); + let profile = crate::macos::profile(&checked).unwrap(); + assert!(profile.starts_with("(version 1)\n(deny default)\n")); + assert!(profile.contains(&format!("(subpath \"{}\")", dir.display()))); + for absent in ["process-fork", "network", "file-write*"] { + assert!(!profile.contains(absent), "no rule allows {absent}"); + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/docs/sandbox.md b/docs/sandbox.md new file mode 100644 index 000000000..78dcf1084 --- /dev/null +++ b/docs/sandbox.md @@ -0,0 +1,186 @@ +# The sandbox for helper processes + +> **Last updated:** 2026-11-07 ยท **Change:** minor (no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`) + +The sandbox is the layer GridCraft is to run code it doesn't trust in, such as a workbook's +Python cells: a separate process that confines itself before the code starts. Here only +`gridcraft-cli sandbox-check` starts it, and Python cells don't run ([architecture.md](architecture.md), +Python cells). The crate is `crates/sandbox`, layer 0. +This page describes what the confinement denies, how it is +checked, and where it is available. + +## Threat model + +The code is a program GridCraft doesn't trust, which may come from anyone. It runs with the person's +user account. Without a sandbox it could read and change the person's files, use the network and start +other programs. The sandbox's job is that it can do none of these: + +- **Files:** it can read only the paths its policy lists and write no file except `/dev/null`. Nor can it change + a file any other way: not its length, permissions, owner, extended attributes, times or flags, even + one it can read. The exception is a standard stream the parent made a file (see below). The home + directory, documents and other workbooks are out of reach. So `tempfile` finds no folder it can use. +- **Programs:** it can execute only the programs its policy lists, and it can't start a new + process at all, so even those only replace itself. +- **Network:** no sockets, no connections and no listeners. The exception is a standard stream the + parent made a socket (see below). +- **Other processes and the system:** no debugging or reading another process, no System V shared + memory, message queues or semaphores, no POSIX message queues, no signals to another process, no typing into a terminal it inherited, no mounts or namespaces, no kernel + modules, BPF, keyrings or performance counters. On Linux it also can't change another process's + limits, priority or scheduling. + +Data reaches the code only through its command-line arguments (which the parent chooses), its +standard input and output (which the parent holds), and the files its policy lets it read. Nothing +else is open: the launcher closes every descriptor it inherited, because the confinement doesn't +revoke what is already open. The three standard streams stay as the parent made them, with the rights +they were opened with: a stream that is a file can be written and truncated, and one that is a +socket can still send, since neither is a file or socket the program opened. A parent gives the +program pipes. `sandbox-check` passes its own streams on, so its output sent to a file is such a +stream. The +program inherits no environment: the launcher clears it and keeps only the locale and time zone +(`LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`), with `PATH` set to `/usr/bin:/bin`, so tokens and credentials +in the parent's environment don't reach it. + +The sandbox doesn't limit time, memory or output size; whoever starts the program owns those limits. +The files it may read can be read, and the code can use the CPU while it runs. + +## How a launch works + +1. The parent builds a `Policy` (read paths and executable paths), creates a `Canary` (a fresh file + in the temporary directory, outside every allowed path) and spawns `launch_command`. That runs + the application's own executable again, with `--sandbox-launch` and the policy. +2. The application hands its arguments to `gridcraft_sandbox::handle_args` before anything else, + so a launcher run opens no window and writes no log. +3. The launcher closes every open descriptor besides standard input, output and error, and refuses + if one stays open. A desktop app's system libraries (Metal's shader cache on macOS) leave files + open without close-on-exec, so these reach every child. `nix` holds the system call. +4. The launcher confines itself (Linux) or runs itself again under `sandbox-exec` with the profile + (macOS, `--sandbox-confined`). On macOS the descriptors are closed first because, under a + deny-by-default profile, the system's libraries hold a close-on-exec socket of their own, which + must not be taken for an inherited one. +5. **The enforcement check.** Before the program runs, each of these must be refused by the + system with a permission error: + - reading the canary; + - changing its permissions, to those it has, and truncating it, to the length it has, so nothing + changes if the attempt works; + - creating a file next to it, and in the temporary directory; + - a TCP connection to the loopback address; + - starting a process (a copy of itself that would exit at once); + - signal 0 to the launcher's parent, which delivers nothing. Before it confines itself, the + launcher checks that the same signal is allowed, and refuses otherwise: a parent that refuses + anyone (PID 1, another user's process) would prove nothing. It refuses too if its parent has + changed since. + An attempt that works, or fails for another reason (a missing file, a refused connection), + doesn't count as a denial. +6. Only then does the launcher replace itself with the program (`exec`), which inherits the + confinement. When any step fails, the program never runs: the launcher prints + `gridcraft-sandbox: refused: ` on standard error and exits with code 97. + +## Linux + +The launcher applies two layers, then runs the check. + +- **Landlock** (ABI 3, which is Linux 6.2 and later, enabled by default on current distributions). + - **Filesystem rights:** every right Landlock handles is denied except: + - reading files and listing directories under the read paths; + - executing and reading the executable paths; + - reading and writing `/dev/null`. + - **Required and best-effort rights:** the filesystem rights up to ABI 3 are a hard requirement, + so without Landlock, or with an older one, the launch is refused. ABI 3 is the first with a right + for truncation: before it, `truncate` and `open` with `O_TRUNC` empty a file whatever the rules + say. seccomp can't take its place, since `openat2` hides its flags from a filter, and refusing + `O_TRUNC` would also refuse opening `/dev/null` to write. Newer rights are added where the kernel + has them: + - TCP bind and connect, as a second network layer (ABI 4); + - device ioctls (ABI 5); + - signals and abstract Unix sockets outside the sandbox (ABI 6). + - **`no_new_privs`** is set, so no program can gain privileges. +- **seccomp**, three filters. + - **`clone3`** fails with `ENOSYS`. The C library then uses `clone`, whose flags the filter can + read. + - **Refused with `EPERM`:** + - `clone` without `CLONE_THREAD` (a new process; threads still work), plus `fork` and + `vfork`; + - `socket` of every family (a Unix datagram socket can send without `connect`), and + `socketpair`, `connect`, `bind`, `listen`, `accept` and `accept4`; + - io_uring (it runs operations outside the filter); + - `ptrace`, `process_vm_readv`/`writev`, `pidfd_getfd`, `pidfd_open`, `pidfd_send_signal`, + `kcmp`, `get_robust_list`, `migrate_pages`, `move_pages`; + - System V shared memory, message queues and semaphores (`shmget`, `shmat`, `shmctl`, `shmdt`, + `msgget`, `msgsnd`, `msgrcv`, `msgctl`, `semget`, `semop`, `semctl`, `semtimedop`), and POSIX + message queues (`mq_open`, `mq_unlink`, `mq_timedsend`, `mq_timedreceive`, `mq_notify`, + `mq_getsetattr`), since no path rule covers them; + - signals to any process but the program itself, whose process ID `exec` keeps (`kill`, + `tkill`, `tgkill`, `rt_sigqueueinfo`, `rt_tgsigqueueinfo`), so this holds on a kernel without + Landlock's signal scope; + - changes to any process but the program itself (`prlimit64`, `setpriority`, `ioprio_set`, + `sched_setaffinity`, `sched_setscheduler`, `sched_setparam`, `sched_setattr`); + - what changes a file where Landlock has no right for it, by path or by descriptor: permissions + (`chmod`, `fchmod`, `fchmodat`, `fchmodat2`), owner (`chown`, `fchown`, `lchown`, `fchownat`), + extended attributes (`setxattr`, `lsetxattr`, `fsetxattr`, `setxattrat`, `removexattr`, + `lremovexattr`, `fremovexattr`, `removexattrat`), times (`utime`, `utimes`, `futimesat`, + `utimensat`) and flags (`file_setattr`); + - `ioctl` with `TIOCSTI` or `TIOCLINUX`, which type into a terminal the program inherited, as + when `sandbox-check` runs a program from a shell; + - any `ioctl` whose number says it hands the kernel data, such as `FS_IOC_SETFLAGS` and + `FS_IOC_FSSETXATTR`, which change a file's flags on a descriptor opened only to read, and + `FS_IOC_SET_ENCRYPTION_POLICY`, whose number says it only reads. Those that ask, such as + `isatty`'s, and those that set a descriptor's own flags, such as `FIONBIO`, still work; + - mounts, namespaces and file handles (`mount`, `umount2`, `pivot_root`, `chroot`, + `unshare`, `setns`, `open_by_handle_at`, `name_to_handle_at`, the new mount API); + - `bpf`, `perf_event_open`, `userfaultfd`, keyrings, kernel modules, `kexec`, swap, + `reboot`, `syslog`, and port I/O on x86_64. + - **x32 calls (x86_64 only)** fail with `EPERM`. They report the x86_64 architecture value with + other numbers, so the rules above wouldn't match them. + + Filters are built for x86_64 and aarch64; other architectures are refused. A filter allows what it + doesn't name, so a call a later kernel adds is allowed until it is named here; the newest named is + `file_setattr` (Linux 6.17). + + Because no socket can be made, Python's `asyncio` doesn't work in the sandbox: its event loop + needs a local socket pair. + +## macOS + +The launcher runs itself again under `sandbox-exec` with a profile built from the policy: +- `(deny default)`; +- the base system profile (`system.sb`: the system libraries, the dynamic loader's cache and a few + system services a process needs to start); +- `process-exec` for the executable paths; +- `file-read*` for those and the read paths; +- file metadata (for path resolution), `sysctl` reads, and writing `/dev/null`. + +There is no rule for `process-fork`, the network or writing any file but `/dev/null`, so those stay +denied. +A local socket pair isn't refused on macOS, so `asyncio`'s event loop starts there; it still can't +reach the network. +Paths are quoted as profile strings, and a path that can't be quoted safely is refused. + +`sandbox-exec` is deprecated by Apple; support needs `/usr/bin/sandbox-exec`, and without it +`support()` reports the system as unsupported. The check above is what proves the profile holds on a +given machine. + +A framework build of Python (Homebrew, python.org) starts its real interpreter, +`Python.app/Contents/MacOS/Python`, as a new process from `bin/python3`. Inside the sandbox that +start is refused, so callers run the interpreter itself. Homebrew's Python also loads `_ssl` and +`_sqlite3`'s libraries from `opt` and `Cellar`, so a policy for it needs those folders; Homebrew's +`etc` and `var`, which can hold a person's data, stay out of it. + +## Other systems + +Windows, FreeBSD and the web build have no confinement here: `support()` reports it and +`launch_command` refuses, so code that needs the sandbox doesn't run there (Python cells show +`#BLOCKED!`). + +## Checking a machine + +```text +gridcraft-cli sandbox-check +gridcraft-cli sandbox-check --read /usr -- /usr/bin/python3 -I -c "print(1)" +``` + +Without a program it runs a confined copy of itself that tries each denied action, plus UDP and +listing the home directory, and two that must still work (a thread, reading an allowed file). It +prints one line per attempt. With a program it runs that program confined. Exit status 0 means +everything is as required, and 1 not, including a launch the launcher refused (for example on a +Linux kernel without Landlock, or older than 6.2). 2 means GridCraft has no sandbox for this kind of system at all +(Windows, FreeBSD, an architecture other than x86_64 or aarch64, macOS without `sandbox-exec`). diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 148c80aaf..0642e234a 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -29,6 +29,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("core", Class::Layer(0)), ("numfmt", Class::Layer(0)), ("pdf", Class::Layer(0)), + // The confined launcher for helper processes; the Python worker and the apps use it. + ("sandbox", Class::Layer(0)), ("formula", Class::Layer(1)), ("functions", Class::Layer(1)), // Equation layout (LaTeX to glyphs, rules and paths) and OMML, on top of RaTeX.