Compare commits
4 commits
93e0dfc34e
...
8a9b610994
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a9b610994 | ||
|
|
ab083b07e4 | ||
|
|
5766b0cdfe | ||
|
|
d315bc5663 |
6 changed files with 287 additions and 87 deletions
20
Cargo.toml
20
Cargo.toml
|
|
@ -2,19 +2,31 @@
|
|||
name = "yoke"
|
||||
version = "0.1.0"
|
||||
authors = [ "atagen" ]
|
||||
description = "A simple sandboxing tool, similar to bwrap"
|
||||
description = "CLI sandboxing tool (similar to landrun or bwrap)"
|
||||
repository = "https://git.atagen.co/atagen/yoke"
|
||||
license = "GPL-3.0-or-later"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
default = [ "abi-6", "cli" ]
|
||||
cli = ["dep:elb-dl", "dep:which"]
|
||||
nix = []
|
||||
abi-1 = []
|
||||
abi-2 = []
|
||||
abi-3 = []
|
||||
abi-4 = []
|
||||
abi-5 = []
|
||||
abi-6 = []
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "s"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
elb-dl = { version = "0.3.2", features = ["glibc"], default-features = false }
|
||||
exec = "0.3.1"
|
||||
landlock = "0.4.3"
|
||||
which = "8.0.0"
|
||||
exec = "0.3.1"
|
||||
elb-dl = { version = "0.3.2", features = ["glibc"], default-features = false, optional = true }
|
||||
which = { version = "8.0.0", optional = true }
|
||||
|
|
|
|||
32
flake.nix
32
flake.nix
|
|
@ -26,26 +26,18 @@
|
|||
});
|
||||
packages = forAllSystems (pkgs: {
|
||||
default = self.packages.${pkgs.system}.yoke;
|
||||
yoke =
|
||||
let
|
||||
details = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package;
|
||||
lib = pkgs.lib;
|
||||
in
|
||||
pkgs.rustPlatform.buildRustPackage (finalAttrs: {
|
||||
pname = details.name;
|
||||
inherit (details) version;
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
RUSTFLAGS = "-C prefer-dynamic=yes";
|
||||
|
||||
meta = {
|
||||
description = details.description;
|
||||
homepage = details.repository;
|
||||
license = lib.licenses.gpl3Plus;
|
||||
maintainers = [ lib.maintainers.atagen ];
|
||||
mainProgram = details.name;
|
||||
};
|
||||
});
|
||||
yoke = pkgs.rustPlatform.callPackage ./nix/package.nix {
|
||||
features = [
|
||||
"cli"
|
||||
];
|
||||
};
|
||||
yoke-lite = pkgs.rustPlatform.callPackage ./nix/package.nix { };
|
||||
});
|
||||
nixosModules.default =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
imports = [ ./nix/module.nix ];
|
||||
programs.yoke.package = self.packages.${pkgs.system}.yoke;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
45
nix/package.nix
Normal file
45
nix/package.nix
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
lib,
|
||||
rustPlatform,
|
||||
features ? [ "cli" ],
|
||||
abi ? 6,
|
||||
...
|
||||
}:
|
||||
let
|
||||
details = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package;
|
||||
in
|
||||
rustPlatform.buildRustPackage (finalAttrs: {
|
||||
pname = details.name;
|
||||
inherit (details) version;
|
||||
src = lib.cleanSourceWith {
|
||||
src = lib.cleanSource ../.;
|
||||
filter =
|
||||
path_t: type:
|
||||
let
|
||||
path = toString path_t;
|
||||
baseName = baseNameOf path;
|
||||
parentDir = baseNameOf (dirOf path);
|
||||
matchesSuffix = lib.any (suffix: lib.hasSuffix suffix baseName) [
|
||||
".rs"
|
||||
".toml"
|
||||
];
|
||||
isCargoFile = baseName == "Cargo.lock";
|
||||
isCargoConf = parentDir == ".cargo" && baseName == "config";
|
||||
in
|
||||
type == "directory" || matchesSuffix || isCargoFile || isCargoConf;
|
||||
};
|
||||
cargoLock.lockFile = ../Cargo.lock;
|
||||
buildNoDefaultFeatures = true;
|
||||
buildFeatures = features ++ [
|
||||
"nix"
|
||||
"abi-${toString abi}"
|
||||
];
|
||||
|
||||
meta = {
|
||||
description = details.description;
|
||||
homepage = details.repository;
|
||||
license = lib.licenses.gpl3Plus;
|
||||
maintainers = [ lib.maintainers.atagen ];
|
||||
mainProgram = details.name;
|
||||
};
|
||||
})
|
||||
204
src/main.rs
204
src/main.rs
|
|
@ -1,26 +1,41 @@
|
|||
mod parse;
|
||||
mod types;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use elb_dl::{DependencyTree, DynamicLoader, glibc};
|
||||
use landlock::{
|
||||
ABI, Access, AccessFs, AccessNet, BitFlags, Compatible, NetPort, Ruleset, RulesetAttr,
|
||||
RulesetCreatedAttr, Scope, make_bitflags, path_beneath_rules,
|
||||
};
|
||||
|
||||
use crate::types::BasePermission;
|
||||
#[cfg(feature = "cli")]
|
||||
use elb_dl::{DependencyTree, DynamicLoader, glibc};
|
||||
#[cfg(feature = "cli")]
|
||||
use std::{collections::VecDeque, path::PathBuf, str::FromStr};
|
||||
use std::{fs::File, io::Read, os::fd::FromRawFd};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let opts = parse::parse_args()?;
|
||||
let args = std::env::args().skip(1);
|
||||
let mut opts = parse::parse_args(args)?;
|
||||
if opts.fd_args {
|
||||
let mut fd = unsafe { File::from_raw_fd(3) };
|
||||
let mut raw_args = String::new();
|
||||
fd.read_to_string(&mut raw_args)?;
|
||||
let args = raw_args
|
||||
.split_ascii_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
let fd_opts = parse::parse_args(args.into_iter())?;
|
||||
opts.merge(fd_opts);
|
||||
}
|
||||
if opts.exec.is_empty() {
|
||||
// print help
|
||||
eprintln!(
|
||||
"
|
||||
yoke -- simple command sandboxer
|
||||
yoke -- simple sandboxer
|
||||
|
||||
use: yoke [ruletype] [space separated rules] -- [command]
|
||||
|
||||
rule types
|
||||
rules
|
||||
------------
|
||||
filesystem:
|
||||
--fs | -f [access]=/path:/another/path
|
||||
|
|
@ -40,16 +55,25 @@ rule types
|
|||
allow sending signals to other processes:
|
||||
--signals | -k
|
||||
|
||||
resolve process dependencies and add to sandbox:
|
||||
resolve process dependencies and add to sandbox
|
||||
(with `ldd` feature):
|
||||
--ldd | -l
|
||||
|
||||
unsandbox:
|
||||
--no-fs | -nf
|
||||
--no-tcp | -nt
|
||||
|
||||
specifiers
|
||||
accept additional rules from fd 3:
|
||||
--fd-args | -fd
|
||||
|
||||
|
||||
access specifiers
|
||||
------------
|
||||
fs:
|
||||
r - read (implies execute)
|
||||
w - write (implies read+execute)
|
||||
i - allow ioctls (implies nothing - may require read or write)
|
||||
r - read
|
||||
w - write
|
||||
x - execute
|
||||
i - ioctl
|
||||
|
||||
tcp:
|
||||
i - in/bind
|
||||
|
|
@ -65,27 +89,46 @@ examples
|
|||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// set up scope of our intial ruleset
|
||||
let mut preempt = Ruleset::default();
|
||||
// TODO FIXME set up for lesser ABI versions
|
||||
|
||||
preempt = preempt.set_compatibility(landlock::CompatLevel::HardRequirement);
|
||||
if !opts.signals {
|
||||
|
||||
#[cfg(feature = "abi-2")]
|
||||
let abi = ABI::V2;
|
||||
#[cfg(feature = "abi-3")]
|
||||
let abi = ABI::V3;
|
||||
#[cfg(feature = "abi-4")]
|
||||
let abi = ABI::V4;
|
||||
#[cfg(feature = "abi-5")]
|
||||
let abi = ABI::V5;
|
||||
#[cfg(feature = "abi-6")]
|
||||
let abi = ABI::V6;
|
||||
|
||||
// disallow signals to other processes
|
||||
if !opts.signals && abi >= ABI::V6 {
|
||||
preempt = preempt.scope(Scope::Signal).context("scoping signals")?;
|
||||
}
|
||||
if !opts.sockets {
|
||||
|
||||
// disallow connections to unix domain sockets
|
||||
if !opts.sockets && abi >= ABI::V6 {
|
||||
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 {
|
||||
|
||||
// lock down fs access
|
||||
preempt = if !opts.unsandbox.fs {
|
||||
preempt
|
||||
.handle_access(AccessFs::from_all(ABI::V6))
|
||||
.handle_access(AccessFs::from_all(abi))
|
||||
.context("handling fs access")?
|
||||
} else {
|
||||
preempt
|
||||
};
|
||||
preempt = if is_tcp {
|
||||
|
||||
// lock down tcp access
|
||||
preempt = if !(opts.unsandbox.tcp || abi < ABI::V3) {
|
||||
preempt
|
||||
.handle_access(AccessNet::BindTcp)
|
||||
.context("handling tcp bind access")?
|
||||
|
|
@ -94,46 +137,111 @@ examples
|
|||
} else {
|
||||
preempt
|
||||
};
|
||||
|
||||
// create ruleset and begin inserting rules
|
||||
let mut ruleset = preempt.create().context("creating ruleset")?;
|
||||
|
||||
// allow each path specified, grouped by access specifier
|
||||
for (perms, paths) in opts.fs {
|
||||
let mut access = match perms.base {
|
||||
BasePermission::Unset => BitFlags::empty(),
|
||||
BasePermission::Read => AccessFs::from_read(ABI::V6),
|
||||
BasePermission::Write => AccessFs::from_write(ABI::V6),
|
||||
};
|
||||
let mut access = BitFlags::empty();
|
||||
if perms.read {
|
||||
access.insert(make_bitflags!(AccessFs::{ReadFile | ReadDir}));
|
||||
}
|
||||
if perms.write {
|
||||
let mut flags = make_bitflags!(
|
||||
AccessFs::{
|
||||
WriteFile
|
||||
| RemoveDir
|
||||
| RemoveFile
|
||||
| MakeChar
|
||||
| MakeDir
|
||||
| MakeReg
|
||||
| MakeSock
|
||||
| MakeFifo
|
||||
| MakeBlock
|
||||
| MakeSym
|
||||
}
|
||||
);
|
||||
match abi {
|
||||
ABI::V2 => {
|
||||
flags.insert(AccessFs::Refer);
|
||||
}
|
||||
_ if abi >= ABI::V3 => {
|
||||
flags.insert(AccessFs::Refer | AccessFs::Truncate);
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
access.insert(flags);
|
||||
}
|
||||
if perms.execute {
|
||||
access.insert(AccessFs::Execute);
|
||||
}
|
||||
if perms.ioctl {
|
||||
access.insert(make_bitflags!(AccessFs::IoctlDev));
|
||||
if abi >= ABI::V6 {
|
||||
access.insert(AccessFs::IoctlDev);
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"ioctl is only available on Landlock ABI 6 or higher"
|
||||
));
|
||||
}
|
||||
}
|
||||
if access == BitFlags::empty() {
|
||||
return Err(anyhow!("invalid filesystem permissions requested"));
|
||||
return Err(anyhow!("invalid/empty filesystem permissions requested"));
|
||||
}
|
||||
ruleset = ruleset
|
||||
.add_rules(path_beneath_rules(paths, access))
|
||||
.context("adding fs rule")?;
|
||||
}
|
||||
for (dir, ports) in opts.tcp {
|
||||
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")?;
|
||||
|
||||
// allow each tcp action specified, grouped by access specifier
|
||||
if abi >= ABI::V3 {
|
||||
for (dir, ports) in opts.tcp {
|
||||
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")?;
|
||||
}
|
||||
}
|
||||
} else if !opts.tcp.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"tcp controls are only supported with Landlock ABI 3 or higher"
|
||||
));
|
||||
}
|
||||
|
||||
// locate our executable
|
||||
#[cfg(feature = "cli")]
|
||||
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),
|
||||
))?;
|
||||
#[cfg(not(feature = "cli"))]
|
||||
let fullpath = &opts.exec[0];
|
||||
|
||||
// add executeable as read+execute
|
||||
if !opts.unsandbox.fs {
|
||||
ruleset = ruleset.add_rules(path_beneath_rules(
|
||||
std::slice::from_ref(&fullpath),
|
||||
AccessFs::from_read(abi),
|
||||
))?;
|
||||
}
|
||||
|
||||
// if requested, trace dependencies and add as read+execute
|
||||
#[cfg(feature = "cli")]
|
||||
if opts.ldd {
|
||||
#[cfg(not(feature = "nix"))]
|
||||
let loader = DynamicLoader::options()
|
||||
.search_dirs(glibc::get_search_dirs(PathBuf::from_str("/")?)?)
|
||||
.new_loader();
|
||||
|
||||
#[cfg(feature = "nix")]
|
||||
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());
|
||||
|
|
@ -147,15 +255,21 @@ examples
|
|||
acc.extend(deps);
|
||||
acc
|
||||
}),
|
||||
AccessFs::from_read(ABI::V6),
|
||||
AccessFs::from_read(abi),
|
||||
))
|
||||
.context("tracking dependencies")?;
|
||||
}
|
||||
|
||||
// enforce the ruleset on ourselves
|
||||
ruleset.restrict_self().context("enforcing ruleset")?;
|
||||
|
||||
// construct a command for the target program
|
||||
let mut cmd = exec::Command::new(fullpath);
|
||||
if opts.exec.len() > 1 {
|
||||
cmd.args(&opts.exec[1..]);
|
||||
}
|
||||
|
||||
// clear env unless retention is requested
|
||||
if !opts.retain_env {
|
||||
for (k, _) in std::env::vars() {
|
||||
unsafe {
|
||||
|
|
@ -163,6 +277,8 @@ examples
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add specified env vars
|
||||
if !opts.env.is_empty() {
|
||||
for (k, v) in opts.env {
|
||||
unsafe {
|
||||
|
|
@ -170,6 +286,8 @@ examples
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// execute and hopefully never return
|
||||
let err = cmd.exec();
|
||||
eprintln!("failed to run process: {}", err);
|
||||
Ok(())
|
||||
|
|
|
|||
44
src/parse.rs
44
src/parse.rs
|
|
@ -1,4 +1,4 @@
|
|||
use crate::types::{BasePermission, Direction, Permissions, Yoke};
|
||||
use crate::types::{Direction, Permissions, Yoke};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use std::{collections::HashMap, path::PathBuf, str::FromStr};
|
||||
|
||||
|
|
@ -32,17 +32,18 @@ fn fs_parse(pairs: &[String]) -> Result<HashMap<Permissions, Vec<PathBuf>>> {
|
|||
.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;
|
||||
'r' => {
|
||||
perms.read = true;
|
||||
}
|
||||
'w' | 'W' => {
|
||||
perms.base = Write;
|
||||
'w' => {
|
||||
perms.write = true;
|
||||
}
|
||||
'i' | 'I' => {
|
||||
'x' => {
|
||||
perms.execute = true;
|
||||
}
|
||||
'i' => {
|
||||
perms.ioctl = true;
|
||||
}
|
||||
s => return Err(anyhow!("invalid access specifier {}", s)),
|
||||
|
|
@ -93,7 +94,7 @@ fn tcp_parse(pairs: &[String]) -> Result<HashMap<Direction, Vec<u16>>> {
|
|||
Ok(rules)
|
||||
}
|
||||
|
||||
pub fn parse_args() -> Result<Yoke> {
|
||||
pub fn parse_args(args: impl Iterator<Item = String>) -> Result<Yoke> {
|
||||
let mut yoke = Yoke::default();
|
||||
let mut collector = Vec::new();
|
||||
let mut cur_arg = Unset;
|
||||
|
|
@ -108,7 +109,7 @@ pub fn parse_args() -> Result<Yoke> {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
for mut arg in std::env::args().skip(1) {
|
||||
for mut arg in args {
|
||||
arg.make_ascii_lowercase();
|
||||
match arg.as_str() {
|
||||
"--fs" | "-f" => {
|
||||
|
|
@ -144,19 +145,40 @@ pub fn parse_args() -> Result<Yoke> {
|
|||
cur_arg = Unset;
|
||||
yoke.retain_env = true;
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
"--ldd" | "-l" => {
|
||||
collect_args(&mut yoke, &collector, &cur_arg)?;
|
||||
collector.clear();
|
||||
cur_arg = Unset;
|
||||
yoke.ldd = true;
|
||||
}
|
||||
|
||||
"--no-fs" | "-nf" => {
|
||||
collect_args(&mut yoke, &collector, &cur_arg)?;
|
||||
collector.clear();
|
||||
cur_arg = Unset;
|
||||
yoke.unsandbox.fs = true;
|
||||
}
|
||||
"--no-tcp" | "-nt" => {
|
||||
collect_args(&mut yoke, &collector, &cur_arg)?;
|
||||
collector.clear();
|
||||
cur_arg = Unset;
|
||||
yoke.unsandbox.tcp = true;
|
||||
}
|
||||
"--fd-args" | "-fd" => {
|
||||
collect_args(&mut yoke, &collector, &cur_arg)?;
|
||||
collector.clear();
|
||||
cur_arg = Unset;
|
||||
yoke.fd_args = true;
|
||||
}
|
||||
"--" => {
|
||||
collect_args(&mut yoke, &collector, &cur_arg)?;
|
||||
collector.clear();
|
||||
cur_arg = Exec;
|
||||
}
|
||||
_ if cur_arg != Unset => {
|
||||
collector.push(arg.clone());
|
||||
collector.push(arg.to_string());
|
||||
}
|
||||
a => {
|
||||
return Err(anyhow!("invalid argument: {}", a));
|
||||
|
|
|
|||
29
src/types.rs
29
src/types.rs
|
|
@ -1,5 +1,12 @@
|
|||
use anyhow::Result;
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Unsandbox {
|
||||
pub fs: bool,
|
||||
pub tcp: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Yoke {
|
||||
pub fs: HashMap<Permissions, Vec<PathBuf>>,
|
||||
|
|
@ -8,21 +15,17 @@ pub struct Yoke {
|
|||
pub retain_env: bool,
|
||||
pub signals: bool,
|
||||
pub sockets: bool,
|
||||
pub unsandbox: Unsandbox,
|
||||
pub ldd: bool,
|
||||
pub fd_args: bool,
|
||||
pub exec: Vec<String>,
|
||||
}
|
||||
|
||||
#[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 read: bool,
|
||||
pub write: bool,
|
||||
pub execute: bool,
|
||||
pub ioctl: bool,
|
||||
}
|
||||
|
||||
|
|
@ -31,3 +34,11 @@ pub struct Direction {
|
|||
pub inbound: bool,
|
||||
pub outbound: bool,
|
||||
}
|
||||
|
||||
impl Yoke {
|
||||
pub fn merge(&mut self, other: Yoke) {
|
||||
self.fs.extend(other.fs);
|
||||
self.tcp.extend(other.tcp);
|
||||
self.env.extend(other.env);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue