mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-15 14:52:26 +00:00
## 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 -->
264 lines
12 KiB
Rust
264 lines
12 KiB
Rust
//! 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;
|