feat: publish Dever core with project scaffolding and versioned AI skills
This commit is contained in:
1 parent
70fa73cdc2
commit
ac458ffa7f
960 files changed
+221319
-1163
No files matched your search
@@ -0,0 +1,182 @@
|
||||
//! Validate the bootstrap dependency closure before executing any asset.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use goblin::elf::{Elf, dynamic, header, program_header};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const MAX_ASSET_BYTES: usize = 32 * 1024 * 1024;
|
||||
const MAX_ASSET_FILES: usize = 128;
|
||||
|
||||
/// The first executable must be independent of every host loader and library.
|
||||
pub fn validate_bwrap(bytes: &[u8]) -> Result<String, String> {
|
||||
validate_bwrap_for_target(bytes, host_target())
|
||||
}
|
||||
|
||||
fn host_target() -> &'static str {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"linux-aarch64"
|
||||
} else {
|
||||
"linux-x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
fn target_abi(target: &str) -> Result<(u16, &'static str, &'static str), String> {
|
||||
match target {
|
||||
"linux-x86_64" => Ok((
|
||||
header::EM_X86_64,
|
||||
"ld-linux-x86-64.so.2",
|
||||
"/lib64/ld-linux-x86-64.so.2",
|
||||
)),
|
||||
"linux-aarch64" => Ok((
|
||||
header::EM_AARCH64,
|
||||
"ld-linux-aarch64.so.1",
|
||||
"/lib/ld-linux-aarch64.so.1",
|
||||
)),
|
||||
_ => Err(format!("unsupported sandbox target '{target}'")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_bwrap_for_target(bytes: &[u8], target: &str) -> Result<String, String> {
|
||||
let elf = Elf::parse(bytes).map_err(|error| format!("invalid sandbox bwrap ELF: {error}"))?;
|
||||
let (machine, _, _) = target_abi(target)?;
|
||||
if bytes.len() > MAX_ASSET_BYTES
|
||||
|| !elf.is_64
|
||||
|| !elf.little_endian
|
||||
|| elf.header.e_machine != machine
|
||||
|| !matches!(elf.header.e_type, header::ET_DYN | header::ET_EXEC)
|
||||
|| elf
|
||||
.program_headers
|
||||
.iter()
|
||||
.any(|segment| segment.p_type == program_header::PT_INTERP)
|
||||
|| elf.dynamic.as_ref().is_some_and(|table| {
|
||||
table
|
||||
.dyns
|
||||
.iter()
|
||||
.any(|entry| entry.d_tag == dynamic::DT_NEEDED)
|
||||
})
|
||||
|| !elf.rpaths.is_empty()
|
||||
|| !elf.runpaths.is_empty()
|
||||
{
|
||||
return Err("sandbox bwrap must be a static target ELF without PT_INTERP, DT_NEEDED or library search paths".into());
|
||||
}
|
||||
Ok(format!("{:x}", Sha256::digest(bytes)))
|
||||
}
|
||||
|
||||
/// Used both before signing a pack and when launching its verified extraction.
|
||||
/// Content authentication belongs to the release/bundle owner; this validates
|
||||
/// that the loader can resolve every dependency without a host-library search.
|
||||
pub fn validate_assets(files: &[(String, Vec<u8>)]) -> Result<(), String> {
|
||||
validate_assets_for_target(files, host_target())
|
||||
}
|
||||
|
||||
pub fn validate_assets_for_target(files: &[(String, Vec<u8>)], target: &str) -> Result<(), String> {
|
||||
let (machine, loader_name, interpreter) = target_abi(target)?;
|
||||
let total = files
|
||||
.iter()
|
||||
.try_fold(0usize, |total, (_, bytes)| total.checked_add(bytes.len()))
|
||||
.ok_or("sandbox asset size overflow")?;
|
||||
if files.len() > MAX_ASSET_FILES || total > MAX_ASSET_BYTES {
|
||||
return Err("sandbox assets exceed their file or byte budget".into());
|
||||
}
|
||||
let mut supplied = BTreeMap::new();
|
||||
for (name, bytes) in files {
|
||||
let valid = matches!(name.as_str(), "bin/bwrap" | "bin/guard")
|
||||
|| name.strip_prefix("lib/").is_some_and(|name| {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
|
||||
});
|
||||
if !valid || supplied.insert(name.as_str(), bytes.as_slice()).is_some() {
|
||||
return Err("sandbox asset has an invalid or repeated path".into());
|
||||
}
|
||||
}
|
||||
let loader = format!("lib/{loader_name}");
|
||||
for required in ["bin/bwrap", "bin/guard", &loader] {
|
||||
if !supplied.contains_key(required) {
|
||||
return Err(format!("sandbox asset '{required}' is missing"));
|
||||
}
|
||||
}
|
||||
validate_bwrap_for_target(supplied["bin/bwrap"], target)?;
|
||||
let mut reached = BTreeSet::new();
|
||||
// The shared OS ABI set also serves packaged language runtimes. Validate
|
||||
// every supplied library even when the two bootstrap helpers do not use it.
|
||||
let mut remaining = supplied
|
||||
.keys()
|
||||
.map(|name| (*name).to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
while let Some(name) = remaining.pop() {
|
||||
if !reached.insert(name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let bytes = supplied
|
||||
.get(name.as_str())
|
||||
.ok_or_else(|| format!("sandbox dependency '{name}' is missing"))?;
|
||||
let elf =
|
||||
Elf::parse(bytes).map_err(|error| format!("invalid sandbox ELF asset: {error}"))?;
|
||||
if !elf.is_64
|
||||
|| !elf.little_endian
|
||||
|| elf.header.e_machine != machine
|
||||
|| !matches!(elf.header.e_type, header::ET_DYN | header::ET_EXEC)
|
||||
{
|
||||
return Err("sandbox ELF asset has an incompatible target or object type".into());
|
||||
}
|
||||
// Reject paths that can outrank the explicit bootstrap library path.
|
||||
if !elf.rpaths.is_empty() || !elf.runpaths.is_empty() {
|
||||
return Err("sandbox bootstrap assets must not declare RPATH or RUNPATH".into());
|
||||
}
|
||||
if let Some(actual) = elf.interpreter
|
||||
&& actual != interpreter
|
||||
{
|
||||
return Err("sandbox ELF interpreter differs from the packaged loader".into());
|
||||
}
|
||||
for library in elf.libraries {
|
||||
if library.is_empty() || library.contains(['/', '\\']) || matches!(library, "." | "..")
|
||||
{
|
||||
return Err("sandbox dependency is not a plain library name".into());
|
||||
}
|
||||
remaining.push(format!("lib/{library}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn verify_directory(root: &Path) -> Result<String, String> {
|
||||
let mut files = Vec::new();
|
||||
let mut remaining = MAX_ASSET_BYTES;
|
||||
for directory in ["bin", "lib"] {
|
||||
let directory_path = super::real_path(&root.join(directory))?;
|
||||
for entry in fs::read_dir(directory_path).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
let path = super::real_path(&entry.path())?;
|
||||
if !entry
|
||||
.file_type()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_file()
|
||||
|| files.len() >= MAX_ASSET_FILES
|
||||
{
|
||||
return Err("sandbox assets contain a non-regular file or too many files".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
fs::File::open(path)
|
||||
.and_then(|file| file.take(remaining as u64 + 1).read_to_end(&mut bytes))
|
||||
.map_err(|error| format!("cannot read sandbox asset: {error}"))?;
|
||||
remaining = remaining
|
||||
.checked_sub(bytes.len())
|
||||
.ok_or("sandbox assets exceed their byte budget")?;
|
||||
let name = entry
|
||||
.file_name()
|
||||
.into_string()
|
||||
.map_err(|_| "sandbox asset name is not UTF-8")?;
|
||||
files.push((format!("{directory}/{name}"), bytes));
|
||||
}
|
||||
}
|
||||
validate_assets(&files)?;
|
||||
let bytes = &files
|
||||
.iter()
|
||||
.find(|(name, _)| name == "bin/bwrap")
|
||||
.ok_or("sandbox bwrap is missing")?
|
||||
.1;
|
||||
Ok(format!("{:x}", Sha256::digest(bytes)))
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Fixed allowlist for packaged Linux runtimes. Mounts own file visibility;
|
||||
//! this filter owns syscall categories that could bypass that visibility.
|
||||
|
||||
use super::Capabilities;
|
||||
use seccompiler::{
|
||||
BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
|
||||
SeccompRule,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn condition(argument: u8, op: SeccompCmpOp, value: u64) -> Result<SeccompCondition, String> {
|
||||
SeccompCondition::new(argument, SeccompCmpArgLen::Qword, op, value)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn rule(conditions: Vec<SeccompCondition>) -> Result<SeccompRule, String> {
|
||||
SeccompRule::new(conditions).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn install(
|
||||
rules: BTreeMap<i64, Vec<SeccompRule>>,
|
||||
default: SeccompAction,
|
||||
matched: SeccompAction,
|
||||
) -> Result<(), String> {
|
||||
let architecture = std::env::consts::ARCH
|
||||
.try_into()
|
||||
.map_err(|error: seccompiler::BackendError| error.to_string())?;
|
||||
let filter = SeccompFilter::new(rules, default, matched, architecture)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let program: BpfProgram = filter
|
||||
.try_into()
|
||||
.map_err(|error: seccompiler::BackendError| error.to_string())?;
|
||||
seccompiler::apply_filter(&program)
|
||||
.map_err(|error| format!("cannot enforce Worker syscall policy: {error}"))
|
||||
}
|
||||
|
||||
pub(super) fn apply(capabilities: Capabilities) -> Result<(), String> {
|
||||
// clone3 carries flags behind a pointer. ENOSYS makes supported libc use
|
||||
// clone, whose scalar flags can be checked without user-memory races.
|
||||
install(
|
||||
BTreeMap::from([(libc::SYS_clone3, vec![])]),
|
||||
SeccompAction::Allow,
|
||||
SeccompAction::Errno(libc::ENOSYS as u32),
|
||||
)?;
|
||||
let mut rules = BTreeMap::new();
|
||||
for syscall in [
|
||||
libc::SYS_read,
|
||||
libc::SYS_write,
|
||||
libc::SYS_readv,
|
||||
libc::SYS_writev,
|
||||
libc::SYS_pread64,
|
||||
libc::SYS_pwrite64,
|
||||
libc::SYS_preadv,
|
||||
libc::SYS_pwritev,
|
||||
libc::SYS_close,
|
||||
libc::SYS_close_range,
|
||||
libc::SYS_lseek,
|
||||
libc::SYS_fcntl,
|
||||
libc::SYS_dup,
|
||||
libc::SYS_dup3,
|
||||
libc::SYS_pipe2,
|
||||
libc::SYS_openat,
|
||||
libc::SYS_fstat,
|
||||
libc::SYS_statx,
|
||||
libc::SYS_faccessat,
|
||||
libc::SYS_faccessat2,
|
||||
libc::SYS_readlinkat,
|
||||
// copy2/copytree inspect extended metadata on their visible source.
|
||||
// These calls only read mounted files; writes remain independently
|
||||
// governed by the read-only inputs and explicit writable grants.
|
||||
libc::SYS_listxattr,
|
||||
libc::SYS_llistxattr,
|
||||
libc::SYS_flistxattr,
|
||||
libc::SYS_getxattr,
|
||||
libc::SYS_lgetxattr,
|
||||
libc::SYS_fgetxattr,
|
||||
libc::SYS_getdents64,
|
||||
libc::SYS_getcwd,
|
||||
libc::SYS_chdir,
|
||||
libc::SYS_fchdir,
|
||||
libc::SYS_mkdirat,
|
||||
libc::SYS_unlinkat,
|
||||
libc::SYS_renameat,
|
||||
libc::SYS_renameat2,
|
||||
libc::SYS_linkat,
|
||||
libc::SYS_symlinkat,
|
||||
libc::SYS_ftruncate,
|
||||
libc::SYS_truncate,
|
||||
libc::SYS_fchmod,
|
||||
libc::SYS_fchmodat,
|
||||
libc::SYS_fchown,
|
||||
libc::SYS_fchownat,
|
||||
libc::SYS_flock,
|
||||
libc::SYS_fsync,
|
||||
libc::SYS_fdatasync,
|
||||
libc::SYS_sync_file_range,
|
||||
libc::SYS_fallocate,
|
||||
libc::SYS_fstatfs,
|
||||
libc::SYS_statfs,
|
||||
libc::SYS_umask,
|
||||
libc::SYS_utimensat,
|
||||
libc::SYS_sendfile,
|
||||
libc::SYS_copy_file_range,
|
||||
libc::SYS_mmap,
|
||||
libc::SYS_mprotect,
|
||||
libc::SYS_munmap,
|
||||
libc::SYS_mremap,
|
||||
libc::SYS_madvise,
|
||||
libc::SYS_brk,
|
||||
libc::SYS_msync,
|
||||
libc::SYS_rt_sigaction,
|
||||
libc::SYS_rt_sigprocmask,
|
||||
libc::SYS_rt_sigreturn,
|
||||
libc::SYS_rt_sigsuspend,
|
||||
libc::SYS_rt_sigtimedwait,
|
||||
libc::SYS_sigaltstack,
|
||||
libc::SYS_tgkill,
|
||||
libc::SYS_kill,
|
||||
libc::SYS_getpid,
|
||||
libc::SYS_getppid,
|
||||
libc::SYS_gettid,
|
||||
libc::SYS_getuid,
|
||||
libc::SYS_geteuid,
|
||||
libc::SYS_getgid,
|
||||
libc::SYS_getegid,
|
||||
libc::SYS_getgroups,
|
||||
libc::SYS_getpgid,
|
||||
libc::SYS_getsid,
|
||||
libc::SYS_setpgid,
|
||||
libc::SYS_setsid,
|
||||
libc::SYS_set_tid_address,
|
||||
libc::SYS_set_robust_list,
|
||||
libc::SYS_get_robust_list,
|
||||
libc::SYS_futex,
|
||||
libc::SYS_rseq,
|
||||
libc::SYS_sched_yield,
|
||||
libc::SYS_sched_getaffinity,
|
||||
libc::SYS_sched_getparam,
|
||||
libc::SYS_sched_getscheduler,
|
||||
libc::SYS_clock_gettime,
|
||||
libc::SYS_clock_getres,
|
||||
libc::SYS_clock_nanosleep,
|
||||
libc::SYS_nanosleep,
|
||||
libc::SYS_gettimeofday,
|
||||
libc::SYS_times,
|
||||
libc::SYS_getrusage,
|
||||
libc::SYS_getrlimit,
|
||||
libc::SYS_prlimit64,
|
||||
libc::SYS_uname,
|
||||
libc::SYS_sysinfo,
|
||||
libc::SYS_getrandom,
|
||||
libc::SYS_epoll_create1,
|
||||
libc::SYS_epoll_ctl,
|
||||
libc::SYS_epoll_pwait,
|
||||
libc::SYS_epoll_pwait2,
|
||||
libc::SYS_ppoll,
|
||||
libc::SYS_pselect6,
|
||||
libc::SYS_eventfd2,
|
||||
libc::SYS_timerfd_create,
|
||||
libc::SYS_timerfd_settime,
|
||||
libc::SYS_timerfd_gettime,
|
||||
libc::SYS_inotify_init1,
|
||||
libc::SYS_inotify_add_watch,
|
||||
libc::SYS_inotify_rm_watch,
|
||||
libc::SYS_recvfrom,
|
||||
libc::SYS_sendto,
|
||||
libc::SYS_recvmsg,
|
||||
libc::SYS_sendmsg,
|
||||
libc::SYS_recvmmsg,
|
||||
libc::SYS_sendmmsg,
|
||||
libc::SYS_shutdown,
|
||||
libc::SYS_getsockopt,
|
||||
libc::SYS_setsockopt,
|
||||
libc::SYS_getsockname,
|
||||
libc::SYS_getpeername,
|
||||
libc::SYS_execve,
|
||||
libc::SYS_exit,
|
||||
libc::SYS_exit_group,
|
||||
libc::SYS_wait4,
|
||||
libc::SYS_waitid,
|
||||
libc::SYS_clone3,
|
||||
] {
|
||||
rules.insert(syscall, vec![]);
|
||||
}
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
for syscall in [
|
||||
libc::SYS_arch_prctl,
|
||||
libc::SYS_open,
|
||||
libc::SYS_stat,
|
||||
libc::SYS_lstat,
|
||||
libc::SYS_newfstatat,
|
||||
libc::SYS_access,
|
||||
libc::SYS_readlink,
|
||||
libc::SYS_pipe,
|
||||
libc::SYS_dup2,
|
||||
libc::SYS_poll,
|
||||
libc::SYS_select,
|
||||
libc::SYS_epoll_wait,
|
||||
libc::SYS_epoll_create,
|
||||
libc::SYS_mkdir,
|
||||
libc::SYS_rmdir,
|
||||
libc::SYS_unlink,
|
||||
libc::SYS_rename,
|
||||
libc::SYS_link,
|
||||
libc::SYS_symlink,
|
||||
libc::SYS_chmod,
|
||||
libc::SYS_chown,
|
||||
libc::SYS_time,
|
||||
libc::SYS_alarm,
|
||||
libc::SYS_getpgrp,
|
||||
] {
|
||||
rules.insert(syscall, vec![]);
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
rules.insert(libc::SYS_newfstatat, vec![]);
|
||||
}
|
||||
|
||||
// New namespaces and CLONE_PARENT could escape the owned PID lifecycle.
|
||||
let forbidden_clone = (libc::CLONE_NEWCGROUP
|
||||
| libc::CLONE_NEWIPC
|
||||
| libc::CLONE_NEWNET
|
||||
| libc::CLONE_NEWNS
|
||||
| libc::CLONE_NEWPID
|
||||
| libc::CLONE_NEWUSER
|
||||
| libc::CLONE_NEWUTS
|
||||
| libc::CLONE_PARENT
|
||||
| libc::CLONE_UNTRACED) as u64;
|
||||
let mut clone = vec![condition(0, SeccompCmpOp::MaskedEq(forbidden_clone), 0)?];
|
||||
if !capabilities.process {
|
||||
let required = (libc::CLONE_THREAD | libc::CLONE_VM | libc::CLONE_SIGHAND) as u64;
|
||||
clone.push(condition(
|
||||
0,
|
||||
SeccompCmpOp::MaskedEq(required | 255),
|
||||
required,
|
||||
)?);
|
||||
}
|
||||
rules.insert(libc::SYS_clone, vec![rule(clone)?]);
|
||||
if capabilities.process {
|
||||
// POSIX_SPAWN_RESETIDS restores the caller's real identity before exec.
|
||||
// Permit that exact reset only; real/saved IDs and group membership
|
||||
// cannot change. Linux uid_t/gid_t consume the low 32 argument bits.
|
||||
for (syscall, identity) in [
|
||||
(libc::SYS_setresuid, rustix::process::getuid().as_raw()),
|
||||
(libc::SYS_setresgid, rustix::process::getgid().as_raw()),
|
||||
] {
|
||||
let conditions = [u32::MAX, identity, u32::MAX]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(argument, value)| {
|
||||
SeccompCondition::new(
|
||||
argument as u8,
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Eq,
|
||||
u64::from(value),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rules.insert(syscall, vec![rule(conditions)?]);
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
if capabilities.process {
|
||||
rules.insert(libc::SYS_fork, vec![]);
|
||||
rules.insert(libc::SYS_vfork, vec![]);
|
||||
}
|
||||
// Anonymous socket pairs cannot reach another process. They are needed by
|
||||
// language runtime event loops even without the network capability.
|
||||
rules.insert(
|
||||
libc::SYS_socketpair,
|
||||
vec![rule(vec![condition(
|
||||
0,
|
||||
SeccompCmpOp::Eq,
|
||||
libc::AF_UNIX as u64,
|
||||
)?])?],
|
||||
);
|
||||
if capabilities.network {
|
||||
let mut sockets = Vec::new();
|
||||
for domain in [libc::AF_INET, libc::AF_INET6] {
|
||||
for kind in [libc::SOCK_STREAM, libc::SOCK_DGRAM] {
|
||||
sockets.push(rule(vec![
|
||||
condition(0, SeccompCmpOp::Eq, domain as u64)?,
|
||||
condition(
|
||||
1,
|
||||
SeccompCmpOp::MaskedEq(
|
||||
!(libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC) as u32 as u64,
|
||||
),
|
||||
kind as u64,
|
||||
)?,
|
||||
])?);
|
||||
}
|
||||
}
|
||||
rules.insert(libc::SYS_socket, sockets);
|
||||
for syscall in [
|
||||
libc::SYS_connect,
|
||||
libc::SYS_bind,
|
||||
libc::SYS_listen,
|
||||
libc::SYS_accept,
|
||||
libc::SYS_accept4,
|
||||
] {
|
||||
rules.insert(syscall, vec![]);
|
||||
}
|
||||
}
|
||||
// ioctl truncates request to unsigned int. Compare that same width so a
|
||||
// caller cannot hide a forbidden request behind nonzero upper bits.
|
||||
let terminal_requests = [libc::TIOCSTI, libc::TIOCLINUX]
|
||||
.into_iter()
|
||||
.map(|request| {
|
||||
SeccompCondition::new(1, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, request)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rules.insert(libc::SYS_ioctl, vec![rule(terminal_requests)?]);
|
||||
// The allowlist excludes namespace, mount, ptrace/process_vm, io_uring,
|
||||
// device creation, kernel administration, BPF and all x32 syscall numbers.
|
||||
install(
|
||||
rules,
|
||||
SeccompAction::Errno(libc::EPERM as u32),
|
||||
SeccompAction::Allow,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! The fixed Linux execution boundary shared by Lib runtime and build owners.
|
||||
//! Callers verify signed assets and own every child; this crate never discovers tools.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
mod assets;
|
||||
mod mounts;
|
||||
mod observation;
|
||||
mod trusted_helper;
|
||||
pub use assets::{
|
||||
validate_assets, validate_assets_for_target, validate_bwrap, validate_bwrap_for_target,
|
||||
};
|
||||
pub use observation::CommandNamespace;
|
||||
pub use trusted_helper::{APPARMOR_PROFILE, TRUSTED_HELPER_ROOT, validate_host_policy};
|
||||
|
||||
/// Executable modes are part of the bootstrap resource contract. The private
|
||||
/// loader is needed by the guard and packaged language runtimes. Authoring and
|
||||
/// embedding can run on a different host; target validation belongs to assets.
|
||||
pub fn asset_executable(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"bin/bwrap" | "bin/guard" | "lib/ld-linux-x86-64.so.2" | "lib/ld-linux-aarch64.so.1"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod filter;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Capabilities {
|
||||
pub network: bool,
|
||||
pub process: bool,
|
||||
}
|
||||
|
||||
/// The source is a parent-validated deployment grant, never a Worker request.
|
||||
#[derive(Debug)]
|
||||
pub struct Grant {
|
||||
pub source: PathBuf,
|
||||
pub destination: PathBuf,
|
||||
pub writable: bool,
|
||||
}
|
||||
|
||||
pub struct Launch<'a> {
|
||||
pub assets: &'a Path,
|
||||
pub worker: &'a Path,
|
||||
pub executable: &'a Path,
|
||||
pub arguments: &'a [OsString],
|
||||
pub working_directory: &'a Path,
|
||||
pub capabilities: Capabilities,
|
||||
pub grants: &'a [Grant],
|
||||
}
|
||||
|
||||
/// Guard exec confirmation and PID-namespace lifetime, separate from user I/O.
|
||||
pub struct CommandObservation {
|
||||
pub startup: std::fs::File,
|
||||
pub namespace: std::fs::File,
|
||||
pub release: std::fs::File,
|
||||
}
|
||||
|
||||
/// A private ELF closure cannot honor additional host preload libraries.
|
||||
/// Applies to compiler launches as well as the sandbox bootstrap.
|
||||
pub fn require_no_loader_preload() -> Result<(), String> {
|
||||
match std::fs::symlink_metadata("/etc/ld.so.preload") {
|
||||
Ok(_) => {
|
||||
Err("private executable does not support host loader preload configuration".into())
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(format!(
|
||||
"cannot inspect host loader preload configuration: {error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Launch<'_> {
|
||||
pub fn command(&self) -> Result<Command, String> {
|
||||
self.command_with_tools(None, None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn observed_command(&self) -> Result<(Command, CommandObservation), String> {
|
||||
use std::os::fd::OwnedFd;
|
||||
let (startup, startup_writer) = std::io::pipe().map_err(|error| error.to_string())?;
|
||||
let (namespace, namespace_writer) = std::io::pipe().map_err(|error| error.to_string())?;
|
||||
let (blocked, release) = std::io::pipe().map_err(|error| error.to_string())?;
|
||||
let command = self.command_with_tools(
|
||||
None,
|
||||
Some([
|
||||
std::fs::File::from(OwnedFd::from(startup_writer)),
|
||||
std::fs::File::from(OwnedFd::from(namespace_writer)),
|
||||
std::fs::File::from(OwnedFd::from(blocked)),
|
||||
]),
|
||||
)?;
|
||||
Ok((
|
||||
command,
|
||||
CommandObservation {
|
||||
startup: OwnedFd::from(startup).into(),
|
||||
namespace: OwnedFd::from(namespace).into(),
|
||||
release: OwnedFd::from(release).into(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn observed_command(&self) -> Result<(Command, CommandObservation), String> {
|
||||
Err("external command sandbox is not supported on this platform".into())
|
||||
}
|
||||
|
||||
/// Only the explicit package-build owner may supply this verified signed
|
||||
/// root. Ordinary Worker launches never acquire a compiler or /usr mount.
|
||||
pub fn command_for_build(&self, tool_root: &Path) -> Result<Command, String> {
|
||||
if self.capabilities.network || !self.capabilities.process {
|
||||
return Err(
|
||||
"package builds require isolated network and child-process capability".into(),
|
||||
);
|
||||
}
|
||||
self.command_with_tools(Some(tool_root), None)
|
||||
}
|
||||
|
||||
fn command_with_tools(
|
||||
&self,
|
||||
tool_root: Option<&Path>,
|
||||
observation: Option<[std::fs::File; 3]>,
|
||||
) -> Result<Command, String> {
|
||||
if !cfg!(all(
|
||||
target_os = "linux",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
)) {
|
||||
return Err("external Worker sandbox is not supported on this platform".into());
|
||||
}
|
||||
let assets = real_path(self.assets)?;
|
||||
let bwrap_hash = assets::verify_directory(&assets)?;
|
||||
require_no_loader_preload()?;
|
||||
let worker = real_path(self.worker)?;
|
||||
let tool_root = tool_root.map(real_path).transpose()?;
|
||||
let executable = mapped_path(&worker, self.executable)?;
|
||||
let working_directory = mapped_path(&worker, self.working_directory)?;
|
||||
let loader = assets.join("lib").join(loader_name());
|
||||
for file in [
|
||||
&loader,
|
||||
&assets.join("bin/bwrap"),
|
||||
&assets.join("bin/guard"),
|
||||
] {
|
||||
if !real_path(file)?.is_file() {
|
||||
return Err("sandbox executable asset is not a regular file".into());
|
||||
}
|
||||
}
|
||||
// A direct static exec is required for the trusted AppArmor attachment.
|
||||
let bwrap = trusted_helper::executable(&assets, &bwrap_hash)?;
|
||||
let mut command = Command::new(bwrap);
|
||||
command.env_clear().args([
|
||||
"--unshare-all",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
]);
|
||||
if self.capabilities.network {
|
||||
command.arg("--share-net");
|
||||
}
|
||||
let mut mounts = mounts::Mounts::default();
|
||||
let startup_fd = if let Some([startup, namespace, blocked]) = observation {
|
||||
let startup = mounts.pass(startup)?;
|
||||
let namespace = mounts.pass(namespace)?;
|
||||
let blocked = mounts.pass(blocked)?;
|
||||
command
|
||||
.arg("--info-fd")
|
||||
.arg(namespace.to_string())
|
||||
.arg("--block-fd")
|
||||
.arg(blocked.to_string());
|
||||
Some(startup)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
mounts.bind(&mut command, &assets, Path::new("/sandbox"), false)?;
|
||||
mounts.bind(&mut command, &assets.join("lib"), Path::new("/lib"), false)?;
|
||||
command.args(["--symlink", "lib", "/lib64"]);
|
||||
mounts.bind(&mut command, &worker, Path::new("/worker"), false)?;
|
||||
if let Some(root) = &tool_root {
|
||||
mounts.bind(&mut command, &root.join("usr"), Path::new("/usr"), false)?;
|
||||
command.args(["--symlink", "usr/bin", "/bin"]);
|
||||
}
|
||||
command.args([
|
||||
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/data",
|
||||
]);
|
||||
if self.capabilities.network {
|
||||
// DNS is an OS service input, never application configuration.
|
||||
// Expose only these bounded resolver files, not the host's /etc.
|
||||
for name in ["resolv.conf", "hosts"] {
|
||||
let source = Path::new("/etc")
|
||||
.join(name)
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("cannot resolve network resolver input: {error}"))?;
|
||||
let source = real_path(&source)?;
|
||||
let metadata = std::fs::metadata(&source).map_err(|error| error.to_string())?;
|
||||
if !metadata.is_file() || metadata.len() > 65_536 {
|
||||
return Err("network resolver input is not a bounded regular file".into());
|
||||
}
|
||||
mounts.bind(&mut command, &source, &Path::new("/etc").join(name), false)?;
|
||||
}
|
||||
}
|
||||
let mut destinations = BTreeSet::new();
|
||||
for grant in self.grants {
|
||||
let source = real_path(&grant.source)?;
|
||||
if grant.writable
|
||||
&& [&assets, &worker]
|
||||
.into_iter()
|
||||
.chain(tool_root.iter())
|
||||
.any(|protected| {
|
||||
source.starts_with(protected) || protected.starts_with(&source)
|
||||
})
|
||||
{
|
||||
return Err("sandbox writable grant overlaps executable inputs".into());
|
||||
}
|
||||
let destination = &grant.destination;
|
||||
normalized_absolute(destination)?;
|
||||
if !destination.starts_with("/data") || destination == Path::new("/data") {
|
||||
return Err("sandbox file grant must have a path below /data".into());
|
||||
}
|
||||
if destinations.iter().any(|prior: &&Path| {
|
||||
prior.starts_with(destination) || destination.starts_with(prior)
|
||||
}) {
|
||||
return Err("sandbox file grants overlap".into());
|
||||
}
|
||||
destinations.insert(destination.as_path());
|
||||
mounts.bind(&mut command, &source, destination, grant.writable)?;
|
||||
}
|
||||
let flags =
|
||||
u8::from(self.capabilities.network) | (u8::from(self.capabilities.process) << 1);
|
||||
command
|
||||
.arg("--chdir")
|
||||
.arg(working_directory)
|
||||
.args(["--", "/sandbox/bin/guard"]);
|
||||
if let Some(fd) = startup_fd {
|
||||
command.arg("--observe").arg(fd.to_string());
|
||||
}
|
||||
command.arg(flags.to_string()).arg(executable);
|
||||
for argument in self.arguments {
|
||||
// Only resource arguments are absolute; fixed flags stay literal.
|
||||
// Other absolute arguments cannot silently expose a host pathname.
|
||||
let path = Path::new(argument);
|
||||
command.arg(if path.is_absolute() {
|
||||
self.argument_path(path)?.into_os_string()
|
||||
} else {
|
||||
argument.clone()
|
||||
});
|
||||
}
|
||||
mounts.attach(&mut command)?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
/// Build manifests need the same names as command arguments. Output files
|
||||
/// may not exist yet; their owning mount was already validated at launch.
|
||||
pub fn argument_path(&self, path: &Path) -> Result<PathBuf, String> {
|
||||
normalized_absolute(path)?;
|
||||
for grant in self.grants {
|
||||
if let Ok(relative) = path.strip_prefix(&grant.source) {
|
||||
return Ok(grant.destination.join(relative));
|
||||
}
|
||||
}
|
||||
let relative = path
|
||||
.strip_prefix(self.worker)
|
||||
.map_err(|_| "sandbox argument escapes its owned mounts")?;
|
||||
Ok(Path::new("/worker").join(relative))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn loader_name() -> &'static str {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"ld-linux-aarch64.so.1"
|
||||
} else {
|
||||
"ld-linux-x86-64.so.2"
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_absolute(path: &Path) -> Result<(), String> {
|
||||
if !path.is_absolute()
|
||||
|| path
|
||||
.components()
|
||||
.any(|part| !matches!(part, Component::RootDir | Component::Normal(_)))
|
||||
{
|
||||
return Err("sandbox path must be absolute and normalized".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn real_path(path: &Path) -> Result<PathBuf, String> {
|
||||
normalized_absolute(path)?;
|
||||
for ancestor in path.ancestors() {
|
||||
let metadata = std::fs::symlink_metadata(ancestor)
|
||||
.map_err(|error| format!("cannot inspect sandbox input: {error}"))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("sandbox input must not contain symbolic links".into());
|
||||
}
|
||||
}
|
||||
Ok(path.to_owned())
|
||||
}
|
||||
|
||||
fn mapped_path(worker: &Path, path: &Path) -> Result<PathBuf, String> {
|
||||
let path = real_path(path)?;
|
||||
let relative = path
|
||||
.strip_prefix(worker)
|
||||
.map_err(|_| "sandbox execution path escapes the Worker tree")?;
|
||||
Ok(Path::new("/worker").join(relative))
|
||||
}
|
||||
|
||||
/// Called only by the single-threaded guard immediately before exec. The filter
|
||||
/// is inherited by exec and every subsequently created thread or child.
|
||||
pub fn restrict(capabilities: Capabilities) -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
filter::apply(capabilities)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = capabilities;
|
||||
Err("sandbox is not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Private signed helper. It accepts a fixed capability mask, never filter code.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn execute() -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::process::CommandExt;
|
||||
let mut arguments = std::env::args_os().skip(1);
|
||||
let first = arguments.next();
|
||||
let mut observation = if first.as_deref() == Some(std::ffi::OsStr::new("--observe")) {
|
||||
let fd = arguments
|
||||
.next()
|
||||
.and_then(|value| value.to_str().and_then(|value| value.parse::<i32>().ok()))
|
||||
.filter(|fd| *fd >= 3)
|
||||
.ok_or("invalid sandbox observation descriptor")?;
|
||||
// Opening proc creates a safe owned CLOEXEC duplicate. Close the
|
||||
// inherited raw descriptor before exec so neither channel leaks.
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(format!("/proc/self/fd/{fd}"))
|
||||
.map_err(|error| format!("cannot open sandbox observation: {error}"))?;
|
||||
nix::unistd::close(fd)
|
||||
.map_err(|error| format!("cannot close inherited observation: {error}"))?;
|
||||
Some(file)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let first = if observation.is_some() {
|
||||
arguments.next()
|
||||
} else {
|
||||
first
|
||||
};
|
||||
let result = (|| {
|
||||
let flags = first
|
||||
.and_then(|value| value.to_str().and_then(|value| value.parse::<u8>().ok()))
|
||||
.filter(|value| *value <= 3)
|
||||
.ok_or("invalid sandbox capability mask")?;
|
||||
let executable = arguments.next().ok_or("missing sandbox executable")?;
|
||||
if !std::path::Path::new(&executable).starts_with("/worker") {
|
||||
return Err("sandbox executable must be inside its Worker tree".into());
|
||||
}
|
||||
let mut command = std::process::Command::new(executable);
|
||||
command.args(arguments).env_clear();
|
||||
dever_sandbox::restrict(dever_sandbox::Capabilities {
|
||||
network: flags & 1 != 0,
|
||||
process: flags & 2 != 0,
|
||||
})?;
|
||||
if let Some(observation) = &mut observation {
|
||||
observation
|
||||
.write_all(b"R")
|
||||
.map_err(|error| format!("cannot confirm sandbox guard: {error}"))?;
|
||||
}
|
||||
Err(format!(
|
||||
"cannot execute sandboxed Worker: {}",
|
||||
command.exec()
|
||||
))
|
||||
})();
|
||||
if let (Some(observation), Err(error)) = (&mut observation, &result) {
|
||||
observation
|
||||
.write_all(b"E")
|
||||
.and_then(|_| observation.write_all(error.as_bytes()))
|
||||
.map_err(|write| format!("{error}; cannot report sandbox startup: {write}"))?;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn execute() -> Result<(), String> {
|
||||
Err("sandbox is not supported on this platform".into())
|
||||
}
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
match execute() {
|
||||
Ok(()) => std::process::ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
std::process::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Pin deployment grants until bwrap has mounted the exact checked inode.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Mounts {
|
||||
#[cfg(target_os = "linux")]
|
||||
descriptors: Vec<command_fds::FdMapping>,
|
||||
}
|
||||
|
||||
impl Mounts {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn pass(&mut self, file: std::fs::File) -> Result<i32, String> {
|
||||
let child_fd =
|
||||
i32::try_from(self.descriptors.len()).map_err(|_| "too many sandbox descriptors")? + 3;
|
||||
self.descriptors.push(command_fds::FdMapping {
|
||||
parent_fd: file.into(),
|
||||
child_fd,
|
||||
});
|
||||
Ok(child_fd)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) fn pass(&mut self, _: std::fs::File) -> Result<i32, String> {
|
||||
Err("sandbox descriptors are not supported on this platform".into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn bind(
|
||||
&mut self,
|
||||
command: &mut Command,
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
writable: bool,
|
||||
) -> Result<(), String> {
|
||||
use rustix::fs::{Mode, OFlags, ResolveFlags, openat2};
|
||||
let descriptor = openat2(
|
||||
rustix::fs::CWD,
|
||||
source,
|
||||
OFlags::PATH | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
|
||||
)
|
||||
.map_err(|error| format!("cannot pin sandbox mount without following links: {error}"))?;
|
||||
let child_fd =
|
||||
i32::try_from(self.descriptors.len()).map_err(|_| "too many sandbox mounts")? + 3;
|
||||
command
|
||||
.arg(if writable {
|
||||
"--bind-fd"
|
||||
} else {
|
||||
"--ro-bind-fd"
|
||||
})
|
||||
.arg(child_fd.to_string())
|
||||
.arg(destination);
|
||||
self.descriptors.push(command_fds::FdMapping {
|
||||
parent_fd: descriptor,
|
||||
child_fd,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn attach(self, command: &mut Command) -> Result<(), String> {
|
||||
use command_fds::CommandFdExt;
|
||||
command
|
||||
.fd_mappings(self.descriptors)
|
||||
.map_err(|error| format!("cannot pass sandbox mount descriptors: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) fn bind(
|
||||
&mut self,
|
||||
_: &mut Command,
|
||||
_: &Path,
|
||||
_: &Path,
|
||||
_: bool,
|
||||
) -> Result<(), String> {
|
||||
Err("sandbox mounts are not supported on this platform".into())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) fn attach(self, _: &mut Command) -> Result<(), String> {
|
||||
Err("sandbox mounts are not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! A pinned namespace init is the completion boundary for all descendants.
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::fd::{AsRawFd, RawFd};
|
||||
|
||||
pub struct CommandNamespace {
|
||||
#[cfg(target_os = "linux")]
|
||||
descriptor: std::fs::File,
|
||||
}
|
||||
|
||||
impl CommandNamespace {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn pin(pid: i32, wrapper: u32) -> Result<Self, String> {
|
||||
use rustix::process::{Pid, PidfdFlags, pidfd_open};
|
||||
let pid = Pid::from_raw(pid).ok_or("invalid sandbox namespace PID")?;
|
||||
let descriptor = pidfd_open(pid, PidfdFlags::NONBLOCK)
|
||||
.map_err(|error| format!("cannot pin sandbox namespace: {error}"))?;
|
||||
// --block-fd holds the namespace before guard/target execution. If
|
||||
// setup failed and its PID was reused, do not acquire authority over
|
||||
// that process: only this bwrap's direct child belongs to this launch.
|
||||
let status = std::fs::read_to_string(format!("/proc/{}/status", pid.as_raw_pid()))
|
||||
.map_err(|error| format!("cannot verify sandbox namespace parent: {error}"))?;
|
||||
let parent = status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("PPid:"))
|
||||
.and_then(|value| value.trim().parse::<u32>().ok());
|
||||
if parent != Some(wrapper) {
|
||||
return Err("sandbox namespace no longer belongs to its launcher".into());
|
||||
}
|
||||
Ok(Self {
|
||||
descriptor: descriptor.into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn terminate(&self) -> Result<(), String> {
|
||||
match rustix::process::pidfd_send_signal(&self.descriptor, rustix::process::Signal::KILL) {
|
||||
Ok(()) | Err(rustix::io::Errno::SRCH) => Ok(()),
|
||||
Err(error) => Err(format!("cannot stop owned command namespace: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl AsRawFd for CommandNamespace {
|
||||
fn as_raw_fd(&self) -> RawFd {
|
||||
self.descriptor.as_raw_fd()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! The only host integration is a signed, administrator-installed static helper.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const TRUSTED_HELPER_ROOT: &str = "/opt/dever/sandbox";
|
||||
pub const APPARMOR_PROFILE: &str = include_str!("../../../sdk/apparmor/dever-sandbox");
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
#[path = "../../../test/dever-sandbox-tests/trusted_helper.rs"]
|
||||
mod tests;
|
||||
|
||||
pub(super) fn executable(assets: &Path, expected: &str) -> Result<PathBuf, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if rustix::process::geteuid().as_raw() != 0 && validate_host_policy()? {
|
||||
let path = Path::new(TRUSTED_HELPER_ROOT).join(expected).join("bwrap");
|
||||
verify_helper(&path, expected)?;
|
||||
require_profiles()?;
|
||||
return Ok(path);
|
||||
}
|
||||
let _ = expected;
|
||||
Ok(assets.join("bin/bwrap"))
|
||||
}
|
||||
|
||||
/// Validate the host prerequisite shared by non-root launch and real-host
|
||||
/// policy installation. This only reads kernel state; administrators own it.
|
||||
pub fn validate_host_policy() -> Result<bool, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
host_policy(|path| std::fs::read_to_string(path))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn host_policy(read: impl Fn(&str) -> std::io::Result<String>) -> Result<bool, String> {
|
||||
let restricted = policy_flag(&read, "apparmor_restrict_unprivileged_userns")?.unwrap_or(false);
|
||||
if !restricted {
|
||||
return Ok(false);
|
||||
}
|
||||
if policy_flag(&read, "apparmor_restrict_unprivileged_unconfined")? != Some(true) {
|
||||
return Err("Dever AppArmor deployment requires kernel.apparmor_restrict_unprivileged_unconfined=1 while user namespace restriction is enabled; an administrator must approve strict profile transitions before installing or loading the Dever policy".into());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn policy_flag(
|
||||
read: &impl Fn(&str) -> std::io::Result<String>,
|
||||
name: &str,
|
||||
) -> Result<Option<bool>, String> {
|
||||
match read(&format!("/proc/sys/kernel/{name}")) {
|
||||
Ok(value) => match value.trim() {
|
||||
"0" => Ok(Some(false)),
|
||||
"1" => Ok(Some(true)),
|
||||
_ => Err(format!(
|
||||
"invalid AppArmor kernel flag '{name}': expected 0 or 1"
|
||||
)),
|
||||
},
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(format!(
|
||||
"cannot inspect AppArmor kernel flag '{name}': {error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn verify_helper(path: &Path, expected: &str) -> Result<(), String> {
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
for (index, ancestor) in path.ancestors().enumerate() {
|
||||
let metadata = std::fs::symlink_metadata(ancestor).map_err(|error| format!(
|
||||
"trusted sandbox helper is missing or inaccessible at '{}': {error}; install the matching signed Dever sandbox assets", path.display()))?;
|
||||
if metadata.file_type().is_symlink()
|
||||
|| metadata.uid() != 0
|
||||
|| metadata.mode() & 0o022 != 0
|
||||
|| (index == 0
|
||||
&& (!metadata.is_file()
|
||||
|| metadata.mode() & 0o6111 != 0o111
|
||||
|| metadata.nlink() != 1))
|
||||
|| (index != 0 && !metadata.is_dir())
|
||||
{
|
||||
return Err("trusted sandbox helper requires real root-owned paths without shared writes, setuid or setgid".into());
|
||||
}
|
||||
}
|
||||
match rustix::fs::lgetxattr(path, "security.capability", &mut [0u8; 64]) {
|
||||
Err(rustix::io::Errno::NODATA | rustix::io::Errno::OPNOTSUPP) => {}
|
||||
Ok(_) => return Err("trusted sandbox helper must not have file capabilities".into()),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"cannot inspect sandbox helper capabilities: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(path)
|
||||
.and_then(|file| file.take(32 * 1024 * 1024 + 1).read_to_end(&mut bytes))
|
||||
.map_err(|error| format!("cannot read trusted sandbox helper: {error}"))?;
|
||||
if super::validate_bwrap(&bytes)? != expected {
|
||||
return Err("trusted sandbox helper differs from the embedded signed asset".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn require_profiles() -> Result<(), String> {
|
||||
// The profiles list requires policy-admin privilege; the policy filesystem
|
||||
// exposes each profile's name/mode to ordinary users without changing state.
|
||||
let entries = std::fs::read_dir("/sys/kernel/security/apparmor/policy/profiles")
|
||||
.map_err(|error| format!("cannot verify the Dever AppArmor profiles: {error}; an administrator must load /etc/apparmor.d/dever-sandbox"))?;
|
||||
let mut required = std::collections::BTreeSet::from(["dever-bwrap", "dever-bwrap-child"]);
|
||||
for entry in entries.take(4096) {
|
||||
let entry = entry.map_err(|error| format!("cannot inspect AppArmor profiles: {error}"))?;
|
||||
let name = std::fs::read_to_string(entry.path().join("name"))
|
||||
.map_err(|error| format!("cannot read AppArmor profile name: {error}"))?;
|
||||
if required.contains(name.trim()) {
|
||||
let mode = std::fs::read_to_string(entry.path().join("mode"))
|
||||
.map_err(|error| format!("cannot read AppArmor profile mode: {error}"))?;
|
||||
if mode.trim() != "enforce" {
|
||||
return Err("Dever AppArmor profiles must be loaded in enforce mode".into());
|
||||
}
|
||||
required.remove(name.trim());
|
||||
}
|
||||
if required.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("Dever AppArmor profile is not loaded; an administrator must load /etc/apparmor.d/dever-sandbox".into())
|
||||
}
|
||||
Reference in new issue
Block a user