#[cfg(target_os = "linux")]
pub const SYSTEM_ROOTS: &[&str] = &[
"/bin", "/sbin", "/lib", "/lib64", "/usr", "/etc", "/dev", "/proc",
];
pub const DEFAULT_RLIMIT_AS_BYTES: u64 = 256 * 1024 * 1024;
pub const DEFAULT_RLIMIT_NPROC: u64 = 128;
pub const DEFAULT_RLIMIT_CPU_SECS: u64 = 5;
pub const DEFAULT_RLIMIT_FSIZE_BYTES: u64 = 64 * 1024 * 1024;
pub const SWEEP_BUDGET_MS: u64 = 2_000;
pub fn apply_child_plan(
plan: &super::sandbox_backend::ChildEnforcementPlan,
) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
apply_child_plan_linux(plan)
}
#[cfg(not(target_os = "linux"))]
{
let _ = plan;
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"linux enforcement plan requires Linux",
))
}
}
#[cfg(target_os = "linux")]
pub(crate) fn is_child_subreaper() -> bool {
let mut flag: libc::c_int = 0;
let rc = unsafe {
libc::prctl(
libc::PR_GET_CHILD_SUBREAPER,
&mut flag as *mut libc::c_int as libc::c_ulong,
0,
0,
0,
)
};
rc == 0 && flag == 1
}
pub fn verify_child_host(pid: u32) -> super::sandbox_backend::HostVerification {
#[cfg(target_os = "linux")]
{
verify_child_host_linux(pid)
}
#[cfg(not(target_os = "linux"))]
{
let _ = pid;
super::sandbox_backend::HostVerification::none()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SweepOutcome {
pub clean: bool,
pub killed: usize,
pub residual: Vec<i32>,
pub subreaper: bool,
pub blind: bool,
}
pub fn sweep_tree_by_nonce(nonce: &str, root_pid: u32) -> Option<SweepOutcome> {
#[cfg(target_os = "linux")]
{
Some(sweep_tree_by_nonce_linux(nonce, root_pid))
}
#[cfg(not(target_os = "linux"))]
{
let _ = (nonce, root_pid);
None
}
}
#[cfg(target_os = "linux")]
fn apply_child_plan_linux(
plan: &super::sandbox_backend::ChildEnforcementPlan,
) -> std::io::Result<()> {
if plan.new_pgroup {
if unsafe { libc::setpgid(0, 0) } != 0 {
return Err(std::io::Error::last_os_error());
}
}
set_rlimit_if_some(libc::RLIMIT_AS, plan.rlimit_as)?;
set_rlimit_if_some(libc::RLIMIT_NPROC, plan.rlimit_nproc)?;
set_rlimit_if_some(libc::RLIMIT_CPU, plan.rlimit_cpu)?;
set_rlimit_if_some(libc::RLIMIT_FSIZE, plan.rlimit_fsize)?;
if plan.landlock {
let mut write_roots = vec![plan.exec_root.clone()];
write_roots.extend(plan.extra_rw.iter().cloned());
let read_roots: Vec<std::path::PathBuf> = plan
.system_ro
.iter()
.map(std::path::PathBuf::from)
.collect();
crate::sandbox::linux::landlock::apply_policy(&write_roots, &read_roots, false)
.map_err(|e| std::io::Error::other(format!("{e:?}")))?;
} else {
if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
return Err(std::io::Error::last_os_error());
}
}
if plan.harden_syscalls {
let socket_policy = if plan.net_deny {
crate::sandbox::linux::seccomp_netblock::SocketPolicy::UnixOnly
} else {
crate::sandbox::linux::seccomp_netblock::SocketPolicy::UnixAndIp
};
crate::sandbox::linux::seccomp_netblock::install_for_profile(
socket_policy,
crate::policy::SeccompProfile::AgentMin,
)
.map_err(|e| std::io::Error::other(format!("{e:?}")))?;
}
Ok(())
}
#[cfg(target_os = "linux")]
fn set_rlimit_if_some(
resource: libc::__rlimit_resource_t,
value: Option<u64>,
) -> std::io::Result<()> {
let Some(value) = value else {
return Ok(());
};
let limit = libc::rlimit {
rlim_cur: value as libc::rlim_t,
rlim_max: value as libc::rlim_t,
};
if unsafe { libc::setrlimit(resource, &limit) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
#[cfg(target_os = "linux")]
fn verify_child_host_linux(pid: u32) -> super::sandbox_backend::HostVerification {
use super::sandbox_backend::HostVerification;
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(2);
let mut out = HostVerification::none();
loop {
let status = read_proc_file(pid, "status");
if let Some(body) = status.as_deref() {
if proc_field_is(body, "Seccomp:", "2") {
out.seccomp_filter = true;
}
if proc_field_is(body, "NoNewPrivs:", "1") {
out.no_new_privs = true;
}
}
let pgid = unsafe { libc::getpgid(pid as libc::pid_t) };
if pgid == pid as libc::pid_t {
out.pgroup_separate = true;
}
if let Some(limits) = read_proc_file(pid, "limits").as_deref() {
if limits_field_is(limits, "Max address space", DEFAULT_RLIMIT_AS_BYTES) {
out.rlimit_as_ok = true;
}
if limits_field_is(limits, "Max processes", DEFAULT_RLIMIT_NPROC) {
out.rlimit_nproc_ok = true;
}
if limits_field_is(limits, "Max cpu time", DEFAULT_RLIMIT_CPU_SECS) {
out.rlimit_cpu_ok = true;
}
if limits_field_is(limits, "Max file size", DEFAULT_RLIMIT_FSIZE_BYTES) {
out.rlimit_fsize_ok = true;
}
}
out.subreaper_ok = is_child_subreaper();
let zombie = match status.as_deref() {
Some(body) => pid_is_zombie(body),
None => false,
};
if out.all_observed() || Instant::now() >= deadline || !pid_alive(pid) || zombie {
return out;
}
std::thread::sleep(Duration::from_millis(25));
}
}
#[cfg(target_os = "linux")]
fn sweep_tree_by_nonce_linux(nonce: &str, root_pid: u32) -> SweepOutcome {
use std::time::{Duration, Instant};
let subreaper = is_child_subreaper();
let mut outcome = SweepOutcome {
clean: false,
killed: 0,
residual: Vec::new(),
subreaper,
blind: false,
};
if !subreaper {
outcome.blind = true;
return outcome;
}
let me = unsafe { libc::getpid() } as u32;
let me_uid = unsafe { libc::geteuid() };
let needle = nonce.as_bytes();
let deadline = Instant::now() + Duration::from_millis(SWEEP_BUDGET_MS);
loop {
let (matched, blind) = scan_nonce_pids(needle, root_pid, me, me_uid);
if blind {
outcome.blind = true;
outcome.residual = last_nonce_pids(nonce, root_pid, me);
return outcome;
}
if matched.is_empty() {
outcome.clean = true;
return outcome;
}
for pid in &matched {
if unsafe { libc::kill(*pid, libc::SIGKILL) } == 0 {
outcome.killed += 1;
}
let mut status = 0i32;
unsafe { libc::waitpid(*pid, &mut status, libc::WNOHANG) };
}
if Instant::now() >= deadline {
outcome.residual = last_nonce_pids(nonce, root_pid, me);
return outcome;
}
std::thread::sleep(Duration::from_millis(10));
}
}
#[cfg(target_os = "linux")]
fn scan_nonce_pids(needle: &[u8], root_pid: u32, me: u32, me_uid: libc::uid_t) -> (Vec<i32>, bool) {
let mut matched = Vec::new();
let mut blind = false;
let Ok(entries) = std::fs::read_dir("/proc") else {
return (matched, true);
};
for entry in entries.flatten() {
let Ok(name) = entry.file_name().into_string() else {
continue;
};
let Ok(pid) = name.parse::<i32>() else {
continue;
};
if pid <= 0 || pid as u32 == root_pid || pid as u32 == me {
continue;
}
let status = match std::fs::read_to_string(format!("/proc/{pid}/status")) {
Ok(s) => s,
Err(e)
if e.raw_os_error() == Some(libc::ENOENT)
|| e.raw_os_error() == Some(libc::ESRCH) =>
{
continue;
}
Err(_) => continue,
};
if let Some(uid) = status_uid(&status) {
if uid != me_uid {
continue;
}
}
let env = match std::fs::read(format!("/proc/{pid}/environ")) {
Ok(env) => env,
Err(e)
if e.raw_os_error() == Some(libc::ENOENT)
|| e.raw_os_error() == Some(libc::ESRCH) =>
{
continue;
}
Err(_) => {
if pid_is_zombie(&status) {
continue;
}
if crate::sandbox::linux::proctrack::ppid_from_status(&status) == Some(me) {
blind = true;
}
continue;
}
};
if contains_slice(&env, needle) {
matched.push(pid);
}
}
(matched, blind)
}
#[cfg(target_os = "linux")]
fn last_nonce_pids(nonce: &str, root_pid: u32, me: u32) -> Vec<i32> {
let mut out = Vec::new();
let needle = nonce.as_bytes();
if let Ok(entries) = std::fs::read_dir("/proc") {
for entry in entries.flatten() {
let Ok(name) = entry.file_name().into_string() else {
continue;
};
let Ok(pid) = name.parse::<i32>() else {
continue;
};
if pid <= 0 || pid as u32 == root_pid || pid as u32 == me {
continue;
}
let Ok(env) = std::fs::read(format!("/proc/{pid}/environ")) else {
continue;
};
if contains_slice(&env, needle) {
out.push(pid);
}
}
}
out.sort_unstable();
out.truncate(8);
out
}
#[cfg(target_os = "linux")]
fn pid_is_zombie(status: &str) -> bool {
for line in status.lines() {
if let Some(rest) = line.trim_start().strip_prefix("State:") {
return rest.trim_start().starts_with('Z');
}
}
false
}
#[cfg(target_os = "linux")]
fn pid_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
#[cfg(target_os = "linux")]
fn read_proc_file(pid: u32, file: &str) -> Option<String> {
std::fs::read_to_string(format!("/proc/{pid}/{file}")).ok()
}
pub fn proc_field_is(status_body: &str, field: &str, expected: &str) -> bool {
for line in status_body.lines() {
if let Some(rest) = line.trim_start().strip_prefix(field) {
return rest.trim() == expected;
}
}
false
}
pub fn limits_field_is(limits_body: &str, row: &str, expected: u64) -> bool {
for line in limits_body.lines() {
if let Some(idx) = line.find(row) {
let after = line[idx + row.len()..].trim_start();
let mut cols = after.split_whitespace();
let soft = cols.next().unwrap_or("");
let hard = cols.next().unwrap_or("");
let want = expected.to_string();
return soft == want && hard == want;
}
}
false
}
pub fn contains_slice(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
pub fn status_uid(status_body: &str) -> Option<u32> {
for line in status_body.lines() {
if let Some(rest) = line.trim_start().strip_prefix("Uid:") {
let first = rest.split_whitespace().next().unwrap_or("");
return first.parse::<u32>().ok();
}
}
None
}
#[cfg(test)]
mod linux_enforce_tests {
use super::*;
#[test]
fn proc_field_matches_status_shapes() {
let body = "Name:\tsh\nState:\tS (sleeping)\nSeccomp:\t2\nNoNewPrivs:\t1\n";
assert!(proc_field_is(body, "Seccomp:", "2"));
assert!(proc_field_is(body, "NoNewPrivs:", "1"));
assert!(!proc_field_is(body, "Seccomp:", "0"));
assert!(!proc_field_is(body, "Missing:", "2"));
assert!(!proc_field_is("", "Seccomp:", "2"));
}
#[test]
fn limits_field_matches_both_columns() {
let body = "Limit Soft Limit Hard Limit Units\n\
Max cpu time 5 5 seconds\n\
Max file size 67108864 67108864 bytes\n\
Max processes 128 128 processes\n\
Max address space 268435456 268435456 bytes\n\
Max open files 1024 1024 files\n";
assert!(limits_field_is(body, "Max cpu time", 5));
assert!(limits_field_is(body, "Max file size", 67_108_864));
assert!(limits_field_is(body, "Max processes", 128));
assert!(limits_field_is(body, "Max address space", 268_435_456));
assert!(!limits_field_is(body, "Max cpu time", 6));
assert!(!limits_field_is(body, "Max open files", 512));
assert!(!limits_field_is(body, "No such row", 0));
}
#[test]
fn limits_field_rejects_unlimited_and_split() {
let body = "Max cpu time unlimited unlimited seconds\n\
Max processes 128 64 processes\n";
assert!(!limits_field_is(body, "Max cpu time", 5));
assert!(!limits_field_is(body, "Max processes", 128));
}
#[test]
fn contains_slice_finds_nonce_in_environ() {
let env = b"PATH=/bin\x00VETTO_VNG_NONCE=abc123\x00HOME=/x\x00";
assert!(contains_slice(env, b"abc123"));
assert!(contains_slice(env, b"VETTO_VNG_NONCE"));
assert!(!contains_slice(env, b"other"));
assert!(!contains_slice(env, b""));
assert!(!contains_slice(b"short", b"much-longer-needle"));
}
#[test]
fn status_uid_parses_first_column() {
let body = "Name:\tsleep\nState:\tS (sleeping)\nUid:\t1000\t1000\t1000\t1000\n";
assert_eq!(status_uid(body), Some(1000));
assert_eq!(status_uid("Uid:\t0\t0\t0\t0\n"), Some(0));
assert_eq!(status_uid("Name:\tx\nState:\tR (running)\n"), None);
assert_eq!(status_uid(""), None);
assert_eq!(status_uid("Uid:\tnot-a-number\n"), None);
}
#[cfg(target_os = "linux")]
#[test]
fn subreaper_query_is_deterministic() {
let a = is_child_subreaper();
let b = is_child_subreaper();
assert_eq!(a, b);
}
}