ia-sandbox 0.4.0

A CLI to sandbox (jail) and collect usage of applications.
Documentation
use std::path::PathBuf;
use std::process;
use std::time::Duration;
use std::{ffi::OsString, fs};

use clap::{Args, CommandFactory, Parser};
use serde::Deserialize;

use ia_sandbox::config::{
    Aslr, ClearUsage, Config, Environment, Interactive, Limits, Mount, ShareNet, SpaceUsage,
    SwapRedirects,
};

use crate::args::{self, OutputType};

const ARGS_AFTER_HELP: &str = "
All of the trailing arguments are passed to the command to run. If you're passing
arguments to both ia-sandbox and the binary, the ones after `--` go to the command,
the ones before go to ia-sandbox.";

/// ia-sandbox sandboxes applications for secure running of executables";
///
/// ia-sandbox uses cgroups, namespaces, pivot root and other techniques to
/// guarantee security. It is designed to be used for online judges and in
/// particular infoarena.ro
#[derive(Debug, Parser, Deserialize)]
#[command(author, version, max_term_width = 100, after_help = ARGS_AFTER_HELP)]
#[serde(deny_unknown_fields)]
pub(crate) struct App {
    /// Path to config file, all options in it can be overwritten directly on the
    /// command line
    #[arg(short = 'c', long = "config")]
    #[serde(skip)]
    config_file: Option<PathBuf>,

    /// The command to be run.
    ///
    /// It is relative to the pivoted root.
    #[serde(skip)]
    command: PathBuf,

    /// Arguments passed to command.
    #[serde(skip)]
    args: Vec<OsString>,

    /// The new root of the sandbox.
    ///
    /// The jail will pivot root to this folder prior to running the command.
    #[arg(short = 'r', long)]
    new_root: Option<PathBuf>,

    /// Whether to share the network namespace or not.
    ///
    /// Not sharing is more secure but it is also slow on multiple successive runs
    /// on some versions of Linux Kernel.
    #[arg(long)]
    #[serde(default)]
    share_net: bool,

    /// From where to redirect stdin.
    ///
    /// The path must be outside the jail and is relative to current directory.
    #[arg(long)]
    stdin: Option<PathBuf>,

    /// Where to redirect stdout.
    ///
    /// The path must be outside the jail and is relative to current directory.
    #[arg(long)]
    stdout: Option<PathBuf>,

    /// Where to redirect stderr.
    ///
    /// The path must be outside the jail and is relative to current directory.
    #[arg(long)]
    stderr: Option<PathBuf>,

    /// Whether to reverse the opening of stdin and stdout.
    ///
    /// When opening a FIFO filo for reading/writing, if it's not
    /// opened for writing/reading by another process then the current one
    /// is blocked. For 2 processes to communicate using 2 FIFO files
    /// one must open the input and then the output, and the other one must
    /// open output and then input.
    #[arg(long, requires = "stdin", requires = "stdout")]
    #[serde(default)]
    swap_redirects: bool,

    /// Wall time limit.
    ///
    /// If the executable runs for more than this much time (in real time) it is killed.
    /// Given as an unsigned number followd by one of the following suffixes ns(nanoseconds),
    /// us(microseconds), ms(milliseconds) or s(seconds).
    #[arg(short = 'w', long, value_parser = humantime_serde::re::humantime::parse_duration)]
    #[serde(default)]
    #[serde(with = "humantime_serde")]
    wall_time: Option<Duration>,

    /// User time limit.
    ///
    /// If the executable uses more user than this much time it will be killed.
    /// Multiple threads running at the same time will add up their user time.
    /// Given as an unsigned number followd by one of the following suffixes ns(nanoseconds),
    /// us(microseconds), ms(milliseconds) or s(seconds).
    #[arg(short, long, value_parser = humantime_serde::re::humantime::parse_duration)]
    #[serde(default)]
    #[serde(with = "humantime_serde")]
    time: Option<Duration>,

    /// Memory limit.
    ///
    /// The maximum amount of memory (heap, data, swap) this program is allowed to use.
    /// Given as an unsigned number followed by one of the usual suffixes
    /// b, kb, mb, gb, kib, mib, gib.
    #[arg(short, long, value_parser = args::parse_space_usage)]
    #[serde(default, deserialize_with = "args::parse_space_usage_serde")]
    memory: Option<SpaceUsage>,

    /// Stack memory limit
    ///
    /// The main thread initial stack maximum memory limit.
    /// Given as an unsigned number followed by one of the usual suffixes
    /// b, kb, mb, gb, kib, mib, gib.

    #[arg(short, long, value_parser = args::parse_space_usage)]
    #[serde(default, deserialize_with = "args::parse_space_usage_serde")]
    stack: Option<SpaceUsage>,

    /// Number of pids limit.
    ///
    /// The maximum amount of tasks (processes / threads) this program is allowed to
    /// create (including the command running).
    /// Defaults to 50 to protect against fork bombs.
    #[arg(short, long)]
    pids: Option<usize>,

    /// Whether to not clear usage (time/memory/pids) from cgroups.
    ///
    /// For multi-run tasks cpu usage might be added for all run of the task.
    /// Because usage is not cleared, it does not make sense to change limits
    /// so this option conflicts with memory/pids limits.
    #[arg(long, conflicts_with = "memory", conflicts_with = "pids")]
    #[serde(default)]
    no_clear_usage: bool,

    /// Explicitly not use cgroups
    #[arg(long, conflicts_with = "hierarchy_path")]
    #[serde(default)]
    no_cgroups: bool,

    #[clap(flatten)]
    #[serde(default)]
    cgroups: CgroupsArgs,

    // How to output the run information.
    #[arg(short, long, value_enum)]
    output: Option<OutputType>,

    /// Which files/folders to mount inside the new root.
    ///
    /// Given in any of the following 3 forms:
    ///
    /// - `source:destination:mount_options`
    /// - `source:destination` (equivalent to `source:destination:ro,noexec`)
    /// - `source` (equivalent to source:source)
    ///
    /// Mount options are given as a comma separated list of the following:
    ///
    /// - rw, mount as read-write default is to mount read-only
    /// - exec, mount as executable, default is to mount without exec permissions
    /// - dev, default is to mount with no access to devices
    #[arg(long, requires = "new_root", value_parser = args::parse_mount, verbatim_doc_comment)]
    #[serde(default, deserialize_with = "args::parse_mounts_serde")]
    mount: Vec<Mount>,

    /// Whether to run in interactive mode.
    ///
    /// This is necessary if you would rather supply the standard input (instead
    /// of redirecting it from a file), like for example to run a bash shell.
    #[arg(long)]
    #[serde(default)]
    interactive: bool,

    /// Whether to disable aslr.
    ///
    /// ASLR is very useful for security but when consistency in user time usage is needed
    /// disabling it might help. When running simple untrusted binaries there is not much
    /// point in protecting the binary against other attacks.
    #[arg(long)]
    #[serde(default)]
    no_aslr: bool,

    /// Whether to retain all the capabilities that are currently available to the user.
    ///
    /// If not enabled even though the sandboxed process runs as root it won't have any
    /// capability.
    #[arg(long)]
    #[serde(default)]
    privileged: bool,
    /// Whether to forward all environment variables.
    ///
    /// If starting a shell inside a sandbox this is useful for setting up proper
    /// functionality. Be careful as this might expose sensitive information.
    #[arg(long, conflicts_with = "env")]
    #[serde(default)]
    forward_env: bool,

    /// An environment variable to pass to the process inside the sandbox.
    ///
    /// Given as KEY=VALUE.
    #[arg(short, long, value_parser = args::parse_environment)]
    #[serde(default, deserialize_with = "args::parse_environment_serde")]
    env: Vec<(String, String)>,
}

impl App {
    pub(crate) fn load_config_file(mut self) -> anyhow::Result<Self> {
        let Some(config_file) = &self.config_file else {
            return Ok(self);
        };

        let data = fs::read_to_string(config_file)?;
        let Self {
            config_file: _,
            command: _,
            args: _,
            new_root,
            share_net,
            stdin,
            stdout,
            stderr,
            swap_redirects,
            wall_time,
            time,
            memory,
            stack,
            pids,
            no_clear_usage,
            no_cgroups,
            cgroups:
                CgroupsArgs {
                    instance_name,
                    hierarchy_path,
                },
            output,
            mount,
            interactive,
            no_aslr,
            privileged,
            forward_env,
            env,
        } = toml::from_str(&data)?;

        self.new_root = self.new_root.or(new_root);
        self.share_net = self.share_net || share_net;
        self.stdin = self.stdin.or(stdin);
        self.stdout = self.stdout.or(stdout);
        self.stderr = self.stderr.or(stderr);
        self.swap_redirects = self.swap_redirects || swap_redirects;
        self.wall_time = self.wall_time.or(wall_time);
        self.time = self.time.or(time);
        self.memory = self.memory.or(memory);
        self.stack = self.stack.or(stack);
        self.pids = self.pids.or(pids);
        self.no_clear_usage = self.no_clear_usage || no_clear_usage;
        self.no_cgroups = self.no_cgroups || no_cgroups;
        if self.cgroups.instance_name == "default" {
            self.cgroups.instance_name = instance_name;
        }
        self.cgroups.hierarchy_path = self.cgroups.hierarchy_path.or(hierarchy_path);
        self.output = self.output.or(output);
        if self.mount.is_empty() {
            self.mount = mount;
        }
        self.interactive = self.interactive || interactive;
        self.no_aslr = self.no_aslr || no_aslr;
        self.privileged = self.privileged || privileged;
        self.forward_env = self.forward_env || forward_env;
        if self.env.is_empty() {
            self.env = env;
        }
        Ok(self)
    }

    pub(crate) fn into_config_and_output(self) -> (Config, OutputType) {
        if self.cgroups.hierarchy_path.is_none()
            && (self.time.is_some() || self.memory.is_some() || self.pids.is_some())
        {
            if !self.no_cgroups {
                Self::command().print_help().unwrap();
                process::exit(1);
            }
            eprintln!("WARNING: Cpu/Memory/Pids memory limits() are in place but no cgroup hierarchy was given");
            eprintln!("         Limits may be bypassed and accounting will be imprecise");
        }

        let limits = Limits {
            wall_time: self.wall_time,
            user_time: self.time,
            memory: self.memory,
            stack: self.stack,
            pids: self.pids,
        };

        let environment = if self.forward_env {
            Environment::Forward
        } else {
            Environment::EnvList(self.env)
        };

        let config = Config {
            command: self.command,
            args: self.args,
            new_root: self.new_root,
            share_net: if self.share_net {
                ShareNet::Share
            } else {
                ShareNet::Unshare
            },
            redirect_stdin: self.stdin,
            redirect_stdout: self.stdout,
            redirect_stderr: self.stderr,
            limits,
            instance_name: self.cgroups.instance_name,
            hierarchy_path: self.cgroups.hierarchy_path,
            mounts: self.mount,
            swap_redirects: if self.swap_redirects {
                SwapRedirects::Yes
            } else {
                SwapRedirects::No
            },
            clear_usage: if self.no_clear_usage {
                ClearUsage::No
            } else {
                ClearUsage::Yes
            },
            interactive: if self.interactive {
                Interactive::Yes
            } else {
                Interactive::No
            },
            aslr: if self.no_aslr {
                Aslr::NoRandomize
            } else {
                Aslr::Randomize
            },
            privileged: self.privileged,
            environment,
        };

        (config, self.output.unwrap_or_default())
    }
}

#[derive(Debug, Args, Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct CgroupsArgs {
    /// Instance name for cgroups.
    ///
    /// If you plan on running multiple sandboxes at the same time they should
    /// be given different instance names, otherwise their user times and/or
    /// memory usages will be added.
    #[arg(short, long, default_value = "default")]
    #[serde(default = "default_instance_name")]
    instance_name: OsString,

    /// Path for the cgroups hierarchy used for accounting.
    ///
    /// Must have write permission with the user running the sandbox.
    #[arg(long)]
    hierarchy_path: Option<PathBuf>,
}

fn default_instance_name() -> OsString {
    "default".into()
}