diff --git a/Cargo.lock b/Cargo.lock index ff658e6..eebd05b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "base32-fs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c4af4a7fd0833dfb250b9d16d4fc864bf4dd739c74f8de3f8a6b786521221d" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + [[package]] name = "cc" version = "1.2.43" @@ -18,6 +30,30 @@ dependencies = [ "shlex", ] +[[package]] +name = "elb" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98416d8e962d579e6cb4152f1cdba89f9e6a8696c40b7cc2f26ec681787ac8b7" +dependencies = [ + "bitflags", + "log", + "thiserror", +] + +[[package]] +name = "elb-dl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ad6759740c3273c4fb58a9fb9c689a1d40634a18656872c3580ba1f0d44b8c" +dependencies = [ + "base32-fs", + "elb", + "glob", + "log", + "thiserror", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -35,9 +71,15 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "errno" version = "0.2.8" @@ -49,6 +91,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "errno-dragonfly" version = "0.1.2" @@ -65,7 +117,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "886b70328cba8871bfc025858e1de4be16b1d5088f2ba50b57816f4210672615" dependencies = [ - "errno", + "errno 0.2.8", "libc", ] @@ -76,24 +128,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" [[package]] -name = "gumdrop" -version = "0.8.1" +name = "glob" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bc700f989d2f6f0248546222d9b4258f5b02a171a431f8285a81c08142629e3" -dependencies = [ - "gumdrop_derive", -] - -[[package]] -name = "gumdrop_derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "729f9bd3449d77e7831a18abfb7ba2f99ee813dfd15b8c2167c9a54ba20aa99d" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "landlock" @@ -112,6 +150,18 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + [[package]] name = "proc-macro2" version = "1.0.103" @@ -130,23 +180,25 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno 0.3.14", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.108" @@ -175,7 +227,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn", ] [[package]] @@ -184,6 +236,17 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -206,12 +269,34 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "yoke" version = "0.1.0" dependencies = [ "anyhow", + "elb-dl", "exec", - "gumdrop", "landlock", + "which", ] diff --git a/Cargo.toml b/Cargo.toml index 5b0ec77..f5124b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ codegen-units = 1 [dependencies] anyhow = "1.0.100" +elb-dl = { version = "0.3.2", features = ["glibc"], default-features = false } exec = "0.3.1" -gumdrop = "0.8.1" landlock = "0.4.3" - +which = "8.0.0" diff --git a/src/main.rs b/src/main.rs index b81bfc6..e1a6828 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,117 +1,81 @@ -use std::{collections::HashMap, path::PathBuf, str::FromStr}; +mod parse; +mod types; +use std::collections::VecDeque; use anyhow::{Context, Result, anyhow}; -use gumdrop::Options; +use elb_dl::{DependencyTree, DynamicLoader, glibc}; use landlock::{ - ABI, Access, AccessFs, AccessNet, Compatible, NetPort, Ruleset, RulesetAttr, - RulesetCreatedAttr, Scope, path_beneath_rules, + ABI, Access, AccessFs, AccessNet, BitFlags, Compatible, NetPort, Ruleset, RulesetAttr, + RulesetCreatedAttr, Scope, make_bitflags, path_beneath_rules, }; -#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)] -enum Direction { - In, - Out, -} - -#[derive(Debug, Options)] -struct Chas { - #[options(no_multi, parse(try_from_str = "fs_parse"))] - fs: HashMap>, - #[options(no_multi, parse(try_from_str = "tcp_parse"))] - tcp: HashMap>, - #[options(no_multi, parse(try_from_str = "env_parse"))] - env: HashMap, - clear_env: bool, - #[options(free)] - exec: Vec, -} - -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] -enum Permissions { - Read, - Write, -} - -fn env_parse(s: &str) -> Result> { - let pairs = s.split(','); - let mut envs = HashMap::new(); - for pair in pairs { - let (k, v) = pair.split_once('=').ok_or(anyhow!("invalid env var"))?; - envs.entry(k.to_string()) - .and_modify(|iv: &mut String| { - *iv = v.to_string(); - }) - .or_insert(v.to_string()); - } - Ok(envs) -} - -// TODO handle ioctl perms -fn fs_parse(s: &str) -> Result>> { - let pairs = s.split(','); - let mut rules = HashMap::new(); - for pair in pairs { - let (s_perm, s_path) = pair - .split_once('=') - .ok_or(anyhow!("invalid filesystem pair"))?; - if s_perm.len() > 1 { - return Err(anyhow!("permission specifier too long")); - } - let perm = unsafe { - match s_perm.get_unchecked(0..1) { - "r" | "R" => Permissions::Read, - "w" | "W" => Permissions::Write, - _ => { - return Err(anyhow!("invalid permission specifier")); - } - } - }; - for sub in s_path.split(':') { - let path = PathBuf::from_str(sub).context("invalid path specifier")?; - rules - .entry(perm) - .and_modify(|v: &mut Vec| { - v.push(path.clone()); - }) - .or_insert(vec![path]); - } - } - Ok(rules) -} - -fn tcp_parse(s: &str) -> Result>> { - let pairs = s.split(','); - let mut rules = HashMap::new(); - for pair in pairs { - let (s_io, s_port) = pair.split_once('=').context("invalid tcp pair")?; - let dir = match s_io { - "i" => Direction::In, - "o" => Direction::Out, - _ => { - return Err(anyhow!("invalid tcp specifier")); - } - }; - for sub in s_port.split(':') { - let port = sub.parse().context("invalid port")?; - rules - .entry(dir) - .and_modify(|v: &mut Vec| { - v.push(port); - }) - .or_insert(vec![port]); - } - } - Ok(rules) -} +use crate::types::BasePermission; fn main() -> Result<()> { - let opts = Chas::parse_args_or_exit(gumdrop::ParsingStyle::StopAtFirstFree); + let opts = parse::parse_args()?; + if opts.exec.is_empty() { + eprintln!( + " +yoke -- simple command sandboxer + +use: yoke [ruletype] [space separated rules] -- [command] + +rule types +------------ + filesystem: + --fs | -f [access]=/path:/another/path + + tcp port: + --tcp | -t [access]=1234:5678 + + env vars: + --env | -e [key]=[value] + + clear inherited env vars: + --clear-env | -c + + allow use of external unix domain sockets: + --sockets | -s + + allow sending signals to other processes: + --signals | -k + + resolve process dependencies and add to sandbox: + --ldd | -l + + +specifiers +------------ + fs: + r - read (implies execute) + w - write (implies read+execute) + i - allow ioctls (implies nothing - may require read or write) + + tcp: + i - in/bind + o - out/connect + +examples +------------ + yoke --fs r=/etc -l -- ls /etc + + yoke --tcp io=80:443 --fs r=/srv/web --clear-env --env SERVE_FROM=/srv/web \ + -- myhttpserver + " + ); + std::process::exit(1); + } let mut preempt = Ruleset::default(); + // TODO FIXME set up for lesser ABI versions preempt = preempt.set_compatibility(landlock::CompatLevel::HardRequirement); - preempt = preempt.scope(Scope::Signal).context("scoping signals")?; - preempt = preempt - .scope(Scope::AbstractUnixSocket) - .context("scoping sockets")?; + if !opts.signals { + preempt = preempt.scope(Scope::Signal).context("scoping signals")?; + } + if !opts.sockets { + preempt = preempt + .scope(Scope::AbstractUnixSocket) + .context("scoping sockets")?; + } let is_fs = !opts.fs.is_empty(); let is_tcp = !opts.tcp.is_empty(); preempt = if is_fs { @@ -132,80 +96,81 @@ fn main() -> Result<()> { }; let mut ruleset = preempt.create().context("creating ruleset")?; for (perms, paths) in opts.fs { - let access = match perms { - Permissions::Read => AccessFs::from_read(ABI::V6), - Permissions::Write => AccessFs::from_write(ABI::V6), + let mut access = match perms.base { + BasePermission::Unset => BitFlags::empty(), + BasePermission::Read => AccessFs::from_read(ABI::V6), + BasePermission::Write => AccessFs::from_write(ABI::V6), }; + if perms.ioctl { + access.insert(make_bitflags!(AccessFs::IoctlDev)); + } + if access == BitFlags::empty() { + return Err(anyhow!("invalid filesystem permissions requested")); + } ruleset = ruleset .add_rules(path_beneath_rules(paths, access)) .context("adding fs rule")?; } for (dir, ports) in opts.tcp { - let access = match dir { - Direction::In => AccessNet::BindTcp, - Direction::Out => AccessNet::ConnectTcp, - }; + let mut access = BitFlags::empty(); + if dir.inbound { + access.insert(make_bitflags!(AccessNet::BindTcp)); + } + if dir.outbound { + access.insert(make_bitflags!(AccessNet::ConnectTcp)) + } for port in ports { ruleset = ruleset .add_rule(NetPort::new(port, access)) .context("adding tcp rule")?; } } - ruleset.restrict_self().context("enforcing ruleset")?; - if !opts.exec.is_empty() { - let mut cmd = exec::Command::new(&opts.exec[0]); - if opts.exec.len() > 1 { - cmd.args(&opts.exec[1..]); + let fullpath = which::which(&opts.exec[0]).context("finding executable")?; + ruleset = ruleset.add_rules(path_beneath_rules( + std::slice::from_ref(&fullpath), + AccessFs::from_read(ABI::V6), + ))?; + if opts.ldd { + let loader = DynamicLoader::options() + .search_dirs(glibc::get_hard_coded_search_dirs(None)?) + .new_loader(); + let mut tree = DependencyTree::new(); + let mut queue = VecDeque::new(); + queue.push_back(fullpath.clone()); + while let Some(path) = queue.pop_front() { + let deps = loader.resolve_dependencies(&path, &mut tree)?; + queue.extend(deps); } - if opts.clear_env { - for (k, _) in std::env::vars() { - unsafe { - std::env::remove_var(k); - } - } - } - if !opts.env.is_empty() { - for (k, v) in opts.env { - unsafe { - std::env::set_var(k, v); - } - } - } - let err = cmd.exec(); - eprintln!("failed to run process: {}", err); - } else { - eprintln!( - " -yoke -- simple command sandboxer - -use: yoke [rules] [command] - -rules: - filesystem rules: - --fs [access]=/path:/another/path,[access]=/more/path - - tcp port rules: - --tcp [access]=1234:5678,[access]=91011 - - env vars: - --env [key]=[value] - - clear inherited env vars: - --clear-env - -access specifiers (only one may be used per entry): - fs: - r - read (implies execute) - w - write (implies read+execute) - tcp: - i - in/bind - o - out/connect - -example: - yoke --fs r=$(which ls):/etc ls /etc - yoke --tcp i=80:443,o=80:443 --fs r=/srv/web --clear-env --env SERVE_FROM=/srv/web myhttpserver - " - ); + ruleset = ruleset + .add_rules(path_beneath_rules( + tree.into_iter().fold(Vec::new(), |mut acc, (_, deps)| { + acc.extend(deps); + acc + }), + AccessFs::from_read(ABI::V6), + )) + .context("tracking dependencies")?; } + ruleset.restrict_self().context("enforcing ruleset")?; + let mut cmd = exec::Command::new(fullpath); + if opts.exec.len() > 1 { + cmd.args(&opts.exec[1..]); + } + if opts.clear_env { + for (k, _) in std::env::vars() { + unsafe { + std::env::remove_var(k); + } + } + } + if !opts.env.is_empty() { + for (k, v) in opts.env { + unsafe { + std::env::set_var(k, v); + } + } + } + let err = cmd.exec(); + eprintln!("failed to run process: {}", err); Ok(()) } diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 0000000..50feb40 --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,168 @@ +use crate::types::{BasePermission, Direction, Permissions, Yoke}; +use anyhow::{Context, Result, anyhow}; +use std::{collections::HashMap, path::PathBuf, str::FromStr}; + +#[derive(PartialEq)] +enum Arg { + Unset, + Filesystem, + Tcp, + Env, + Exec, +} + +fn env_parse(pairs: &[String]) -> Result> { + let mut envs = HashMap::new(); + for pair in pairs { + let (k, v) = pair.split_once('=').ok_or(anyhow!("invalid env var"))?; + envs.entry(k.to_string()) + .and_modify(|iv: &mut String| { + *iv = v.to_string(); + }) + .or_insert(v.to_string()); + } + Ok(envs) +} + +fn fs_parse(pairs: &[String]) -> Result>> { + let mut rules = HashMap::new(); + for pair in pairs { + let (s_perm, s_path) = pair + .split_once('=') + .ok_or(anyhow!("invalid filesystem pair"))?; + + let mut perms = Permissions::default(); + + use BasePermission::*; + for c in s_perm.chars() { + match c { + 'r' | 'R' if perms.base == Unset => { + perms.base = Read; + } + 'w' | 'W' => { + perms.base = Write; + } + 'i' | 'I' => { + perms.ioctl = true; + } + s => return Err(anyhow!("invalid access specifier {}", s)), + } + } + + for sub in s_path.split(':') { + let path = PathBuf::from_str(sub).context("invalid path specifier")?; + rules + .entry(perms) + .and_modify(|v: &mut Vec| { + v.push(path.clone()); + }) + .or_insert(vec![path]); + } + } + Ok(rules) +} + +fn tcp_parse(pairs: &[String]) -> Result>> { + let mut rules = HashMap::new(); + for pair in pairs { + let (s_io, s_port) = pair.split_once('=').context("invalid tcp pair")?; + let mut dir = Direction::default(); + for c in s_io.chars() { + match c { + 'i' | 'I' => { + dir.inbound = true; + } + 'o' | 'O' => { + dir.outbound = true; + } + _ => { + return Err(anyhow!("invalid tcp specifier")); + } + } + } + for sub in s_port.split(':') { + let port = sub.parse().context("invalid port")?; + rules + .entry(dir) + .and_modify(|v: &mut Vec| { + v.push(port); + }) + .or_insert(vec![port]); + } + } + Ok(rules) +} + +pub fn parse_args() -> Result { + let mut yoke = Yoke::default(); + let mut collector = Vec::new(); + let mut cur_arg = Unset; + use Arg::*; + fn collect_args(yoke: &mut Yoke, collector: &[String], cur_arg: &Arg) -> Result<()> { + match cur_arg { + Unset => (), + Filesystem => yoke.fs.extend(fs_parse(collector)?), + Tcp => yoke.tcp.extend(tcp_parse(collector)?), + Env => yoke.env.extend(env_parse(collector)?), + Exec => yoke.exec.extend(collector.iter().cloned()), + } + Ok(()) + } + for mut arg in std::env::args().skip(1) { + arg.make_ascii_lowercase(); + match arg.as_str() { + "--fs" | "-f" => { + collect_args(&mut yoke, &collector, &cur_arg)?; + collector.clear(); + cur_arg = Filesystem; + } + "--tcp" | "-t" => { + collect_args(&mut yoke, &collector, &cur_arg)?; + collector.clear(); + cur_arg = Tcp; + } + "--env" | "-e" => { + collect_args(&mut yoke, &collector, &cur_arg)?; + collector.clear(); + cur_arg = Env; + } + "--sockets" | "-s" => { + collect_args(&mut yoke, &collector, &cur_arg)?; + collector.clear(); + cur_arg = Unset; + yoke.sockets = true; + } + "--signals" | "-k" => { + collect_args(&mut yoke, &collector, &cur_arg)?; + collector.clear(); + cur_arg = Unset; + yoke.signals = true; + } + "--clear-env" | "-c" => { + collect_args(&mut yoke, &collector, &cur_arg)?; + collector.clear(); + cur_arg = Unset; + yoke.clear_env = true; + } + "--ldd" | "-l" => { + collect_args(&mut yoke, &collector, &cur_arg)?; + collector.clear(); + cur_arg = Unset; + yoke.ldd = true; + } + "--" => { + collect_args(&mut yoke, &collector, &cur_arg)?; + collector.clear(); + cur_arg = Exec; + } + _ if cur_arg != Unset => { + collector.push(arg.clone()); + } + a => { + return Err(anyhow!("invalid argument: {}", a)); + } + } + } + collect_args(&mut yoke, &collector, &cur_arg)?; + Ok(yoke) +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..8c87c67 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,33 @@ +use std::{collections::HashMap, path::PathBuf}; + +#[derive(Debug, Default)] +pub struct Yoke { + pub fs: HashMap>, + pub tcp: HashMap>, + pub env: HashMap, + pub clear_env: bool, + pub signals: bool, + pub sockets: bool, + pub ldd: bool, + pub exec: Vec, +} + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Default)] +pub enum BasePermission { + #[default] + Unset, + Read, + Write, +} + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Default)] +pub struct Permissions { + pub base: BasePermission, + pub ioctl: bool, +} + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Default)] +pub struct Direction { + pub inbound: bool, + pub outbound: bool, +}