mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 13:23:54 +00:00
Sandbox: a crate that runs a helper program confined (Landlock and seccomp on Linux, sandbox-exec on macOS)
## Stack The Python cells work is split by layer. It merges in this order, each with a merge commit rather than a squash. Nobody can switch Python on before #3481. 1. #3475 depends on `main` 2. #3476 depends on `main` **(this pull request)** 3. #3477 depends on [#3475](https://github.com/storytold/gridcraft/pull/3475) and [#3476](https://github.com/storytold/gridcraft/pull/3476) 4. #3478 depends on [#3477](https://github.com/storytold/gridcraft/pull/3477) 5. #3479 depends on [#3478](https://github.com/storytold/gridcraft/pull/3478) 6. #3480 depends on [#3479](https://github.com/storytold/gridcraft/pull/3479) 7. #3481 depends on [#3480](https://github.com/storytold/gridcraft/pull/3480) ## Summary `crates/sandbox` runs a program that can read only the paths its policy lists, write nothing except `/dev/null`, start no process, and use no network. An application re-executes itself with a launcher flag, closes every inherited descriptor above standard error, applies the confinement, checks that it holds, and only then runs the program. If any of that fails, the program does not run. ## Changes - **`crates/sandbox`:** the policy, the Linux backend (Landlock, and seccomp that denies all sockets, with an x32 filter), the macOS backend (`/usr/bin/sandbox-exec` with a deny-default profile), the probes and a canary. - **`apps/gridcraft`, `apps/gridcraft-cli`:** the launcher flag comes first in `main`, before any window or log. - **`gridcraft-cli sandbox-check`:** says whether this system confines programs, and can run a confined self-test. - **`docs/sandbox.md`:** what is refused and how. - **`nix`:** a new dependency on Linux and macOS, for closing descriptors without `unsafe`. ## Compatibility Windows and FreeBSD have no backend here. There the crate reports that it can't confine, and nothing that needs it runs. On Linux a launch needs Landlock ABI 3, which is Linux 6.2 and later, so an older kernel is treated the same way: this excludes, for example, Debian 12's kernel (6.1) and Ubuntu 22.04's original kernel (5.15). Nothing changes for a process started without the launcher flag. ## Review fixes - **File metadata on Linux.** Landlock has no right for a file's permissions, owner, extended attributes, times or flags, and the seccomp filter allows what it doesn't name, so a confined program could change them on any file its user owns, outside the policy too. The filter now refuses the `chmod`, `chown`, `setxattr`, `removexattr` and timestamp families with their `*at` variants, by path and by descriptor, and any `ioctl` whose number says it hands the kernel data, as `FS_IOC_SETFLAGS` and `FS_IOC_FSSETXATTR` do on a descriptor opened only to read. A filter allows what it doesn't name, so a call a later kernel adds is allowed until it is named; the newest named is `file_setattr` (Linux 6.17). - **Truncation on Linux.** Landlock covers truncation only from ABI 3, so on ABI 1 or 2 `os.truncate(path, 0)` emptied any file its user can write, and `open` with `O_RDONLY | O_TRUNC` emptied one it may read. A launch now requires that right. 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. - **Standard streams.** They stay open with the rights they were opened with, so a stream that is a file can be written and truncated, and one that is a socket can still send. The page and `launch_command` say so, and that a parent gives the program pipes. - **The enforcement check, again.** It now also tries to change the canary's permissions and to truncate it, asking for what it already has, so nothing changes if one works. - **Other processes on Linux.** On a kernel without Landlock's signal scope, a confined program could signal any process of the same user, and `kill(-1, 9)` would reach them all. The seccomp filter now refuses signals to any process but the program itself, whose ID `exec` keeps; changes to the limits, priority or scheduling of any other process; `pidfd_open`, `pidfd_send_signal`, `kcmp`, `get_robust_list`, `migrate_pages` and `move_pages`; every System V shared memory, message queue and semaphore call and every POSIX message queue call, which no path rule covers; and `syslog`. - **The terminal on Linux.** A program started from a shell could type into its terminal. `TIOCSTI` and `TIOCLINUX` are refused, compared in 32 bits as the kernel reads them, so high bits set on the argument don't get past the rule. - **The enforcement check.** It now also tries signal 0 on the launcher's parent, which delivers nothing, so `sandbox-check` reports a gap rather than assuming none. The launcher tries the same signal before it confines itself and refuses if that fails, and the check refuses if its parent has changed since, since a parent that refuses anyone would prove nothing. A launcher's arguments must include the current executable, which that check starts; otherwise the file rules refused the start and the check proved nothing about new processes. - **A refused launch.** The launcher read its arguments only after closing every inherited descriptor, so a refused launch had already closed them. It reads them first. - **A kernel without Landlock.** The refusal says so in that case too, which the tests that skip on such a machine look for. Only the first ABI's rights being refused, or `ENOSYS` or `EOPNOTSUPP` from creating the ruleset, says so. Any other failure, such as `EINVAL` or too many layers, is reported as what it is and doesn't skip the tests. - **Folders in the exec list on macOS.** A folder got a rule that matches only the folder, so a launcher under it failed to start where the same policy works on Linux. It now covers what is in it, in both rules. - **The sandbox page** names only what this layer has: only `gridcraft-cli sandbox-check` starts the launcher here. ## Testing All 10 steps of `cargo xtask ci` pass on macOS (arm64), and `cargo clippy` passes for this crate for both Linux architectures. The gate on ubuntu-24.04 is the hosted one on this pull request, and it executes the Linux backend through the real binary, including `a_confined_program_leaves_other_processes_alone` and `a_confined_program_changes_no_file`, in which a confined Python tries each refused call on a file inside the policy, one outside it and a descriptor, and the test then compares contents, permissions and modification time. A separate run on the fork, on Linux 7.0 with Landlock ABI 8, covered the same commit's code with parts of the fix taken out. Without the metadata rules, the launch check refuses with `change the canary's permissions: it worked`. Without those rules or the new checks, `a_confined_program_changes_no_file` fails with `permissions of ...: it worked`. With the Landlock rights limited to ABI 2, as on an older kernel, it fails with `truncate ...: it worked`. A kernel that is actually older than ABI 3, or that has no Landlock at all, has not been run. On macOS, terminal input was checked by hand on a pseudo-terminal: `TIOCSTI` is refused when confined and works when not. The commits group the change by area for reading. Only the last commit of the branch is expected to build. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Added sandbox support for running commands with policy-limited file access and restrictions on network and process operations. The sandbox is available on supported Linux and macOS systems; launches are refused if confinement cannot be established or verified. * Added the `sandbox-check` command to check sandbox support and enforcement, or run a specified program in the sandbox. It reports when sandboxing is unsupported. * **Documentation** * Added guidance on sandbox behavior, platform support, access restrictions, launch checks, and `sandbox-check` usage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
6a08240eb6
commit
0fed26a14f
19 files changed
+2269
No files matched your search
Generated
+33
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! gridcraft-cli functions [--json] [--search X] [--category C]
|
||||
//! gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
|
||||
//! gridcraft-cli send <port> <method> [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 <port> <method> [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::ffi::OsString> = 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<String> = 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),
|
||||
|
||||
@@ -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<ExitCode, String> {
|
||||
let mut policy = Policy::new().with_system_libraries();
|
||||
let mut it = args.iter();
|
||||
let mut program: Option<(PathBuf, Vec<OsString>)> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<bool> = 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::<Vec<_>>());
|
||||
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::<Vec<_>>());
|
||||
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::<Vec<_>>());
|
||||
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();
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -241,6 +241,12 @@ fn icon() -> Option<egui::IconData> {
|
||||
}
|
||||
|
||||
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::ffi::OsString> = 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
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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<Canary> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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: <reason>`, 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<T> = std::result::Result<T, SandboxError>;
|
||||
|
||||
/// 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<Command> {
|
||||
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<Command> {
|
||||
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<i32> {
|
||||
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<std::convert::Infallible> {
|
||||
// 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<std::convert::Infallible> {
|
||||
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<std::convert::Infallible> {
|
||||
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<Command> {
|
||||
Err(SandboxError::Unsupported(WHY.into()))
|
||||
}
|
||||
|
||||
pub fn confine(_: &Checked, _: u32, _: &[OsString]) -> Result<()> {
|
||||
Err(SandboxError::Unsupported(WHY.into()))
|
||||
}
|
||||
|
||||
pub fn exec(_: &Path, _: &[OsString]) -> Result<std::convert::Infallible> {
|
||||
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<Vec<i32>> {
|
||||
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::<i32>().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<std::convert::Infallible> {
|
||||
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;
|
||||
@@ -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<Command> {
|
||||
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<std::convert::Infallible> {
|
||||
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<i32>) -> 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<TargetArch> {
|
||||
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<libc::c_long> {
|
||||
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<BTreeMap<i64, Vec<SeccompRule>>> {
|
||||
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<SeccompCondition>| SeccompRule::new(conditions).map_err(not_enforced);
|
||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = 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<i64, Vec<SeccompRule>>, errno: i32| -> Result<BpfProgram> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Command> {
|
||||
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<std::convert::Infallible> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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)
|
||||
}
|
||||
@@ -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<PathBuf>,
|
||||
exec: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
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<PathBuf>) -> Policy {
|
||||
self.read.push(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Lets the program execute (and read) the file `path`.
|
||||
pub fn exec(mut self, path: impl Into<PathBuf>) -> 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<Checked> {
|
||||
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<Vec<PathBuf>> {
|
||||
if paths.len() > MAX_PATHS {
|
||||
return Err(SandboxError::BadPolicy(format!("more than {MAX_PATHS} paths")));
|
||||
}
|
||||
let mut out: Vec<PathBuf> = 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<PathBuf> {
|
||||
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<PathBuf>,
|
||||
pub exec: Vec<PathBuf>,
|
||||
program: PathBuf,
|
||||
args: Vec<OsString>,
|
||||
}
|
||||
|
||||
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<OsString> {
|
||||
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<PathBuf> = 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))
|
||||
}
|
||||
}
|
||||
@@ -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<Check> {
|
||||
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<u32, String> {
|
||||
signalable(std::os::unix::process::parent_id())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn signalable(process: u32) -> std::result::Result<u32, String> {
|
||||
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<u32, String> {
|
||||
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 <canary> [<readable file> [<home directory>]]`, run confined: prints one line per attempt,
|
||||
/// `denied: <name>` or `allowed: <name> (<detail>)`, 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<String>) -> 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")));
|
||||
}
|
||||
}
|
||||
@@ -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<OsString> {
|
||||
args.iter().map(OsString::from).collect()
|
||||
}
|
||||
|
||||
fn bad_policy<T: std::fmt::Debug>(r: crate::Result<T>) -> 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();
|
||||
}
|
||||
+186
@@ -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: <reason>` 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`).
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user