use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use murmur_artifact::security_warnings::{
security_warning_link, W_SEC_001, W_SEC_002, W_SEC_005, W_SEC_010,
};
use murmur_artifact::InterpreterRuntimeGrant;
use crate::types::CapabilityPolicy;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EnforcementTier {
KernelSealed,
KernelFull,
KernelSeccompOnly,
EnvironmentOnly,
Unconfined,
}
pub(crate) fn tier_from_probe(
is_linux: bool,
landlock_fully_enforced: Option<bool>,
namespaces: &crate::namespace_probe::NamespaceReading,
) -> EnforcementTier {
if !is_linux {
return EnforcementTier::EnvironmentOnly;
}
match landlock_fully_enforced {
Some(true) if namespaces.sealed_root_created() => EnforcementTier::KernelSealed,
Some(true) => EnforcementTier::KernelFull,
Some(false) | None => EnforcementTier::KernelSeccompOnly,
}
}
pub(crate) fn applied_tier(
host_tier: EnforcementTier,
declared: murmur_artifact::ContainmentClass,
) -> EnforcementTier {
if declared == murmur_artifact::ContainmentClass::Unconfined {
return EnforcementTier::Unconfined;
}
match host_tier {
EnforcementTier::KernelSealed if declared < murmur_artifact::ContainmentClass::Sealed => {
EnforcementTier::KernelFull
}
other => other,
}
}
#[derive(Debug, Clone)]
pub struct HostProbe {
landlock_fully_enforced: Option<bool>,
landlock_kernel_abi: Option<u8>,
namespaces: crate::namespace_probe::NamespaceReading,
}
static PROBES_TAKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
impl HostProbe {
pub fn probe() -> HostProbe {
PROBES_TAKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
#[cfg(target_os = "linux")]
let probe = HostProbe {
landlock_fully_enforced: linux_enforce::probe_landlock_full_access(),
landlock_kernel_abi: linux_enforce::kernel_landlock_abi(),
namespaces: crate::namespace_probe::probe_namespaces(),
};
#[cfg(not(target_os = "linux"))]
let probe = HostProbe {
landlock_fully_enforced: None,
landlock_kernel_abi: None,
namespaces: crate::namespace_probe::probe_namespaces(),
};
probe
}
#[cfg(test)]
pub(crate) fn from_parts(
landlock_fully_enforced: Option<bool>,
namespaces: crate::namespace_probe::NamespaceReading,
) -> HostProbe {
HostProbe {
landlock_fully_enforced,
landlock_kernel_abi: None,
namespaces,
}
}
#[cfg(test)]
pub(crate) fn with_landlock_kernel_abi(self, abi: Option<u8>) -> HostProbe {
HostProbe {
landlock_kernel_abi: abi,
..self
}
}
pub(crate) fn tier(&self) -> EnforcementTier {
tier_from_probe(
cfg!(target_os = "linux"),
self.landlock_fully_enforced,
&self.namespaces,
)
}
pub fn namespaces(&self) -> &crate::namespace_probe::NamespaceReading {
&self.namespaces
}
pub fn egress_namespace_failure(
&self,
) -> Option<crate::network_namespace::EgressNamespaceFailure> {
self.namespaces.egress_namespace_failure()
}
pub(crate) fn mount_namespace_usable(&self) -> bool {
cfg!(target_os = "linux") && self.namespaces.mount_namespace_created()
}
pub fn sealed_shortfall(&self) -> Option<crate::sealed::SealedShortfall> {
crate::sealed::sealed_shortfall(
cfg!(target_os = "linux"),
self.landlock_fully_enforced == Some(true),
&self.namespaces,
)
}
pub fn userns_grant(&self) -> Option<crate::sealed::UsernsGrant> {
if cfg!(target_os = "linux") {
Some(self.namespaces.userns_grant)
} else {
None
}
}
pub fn landlock_kernel_abi(&self) -> Option<u8> {
self.landlock_kernel_abi
}
pub fn probes_taken() -> u64 {
PROBES_TAKEN.load(std::sync::atomic::Ordering::Relaxed)
}
}
#[cfg(all(test, target_os = "linux"))]
pub(crate) fn detect_enforcement_tier() -> EnforcementTier {
HostProbe::probe().tier()
}
pub fn detect_sealed_shortfall() -> Option<crate::sealed::SealedShortfall> {
HostProbe::probe().sealed_shortfall()
}
pub fn detect_userns_grant() -> Option<crate::sealed::UsernsGrant> {
HostProbe::probe().userns_grant()
}
pub fn detect_landlock_abi() -> Option<u8> {
HostProbe::probe().landlock_kernel_abi()
}
pub(crate) const LANDLOCK_TARGET_ABI: u8 = 3;
pub(crate) fn ruleset_landlock_abi(kernel_abi: Option<u8>, tier: EnforcementTier) -> Option<u8> {
match tier {
EnforcementTier::KernelFull | EnforcementTier::KernelSealed => kernel_abi
.filter(|abi| *abi >= 1)
.map(|abi| abi.min(LANDLOCK_TARGET_ABI)),
EnforcementTier::KernelSeccompOnly
| EnforcementTier::EnvironmentOnly
| EnforcementTier::Unconfined => None,
}
}
pub fn landlock_doctor_lines(kernel_abi: Option<u8>) -> Vec<String> {
if !cfg!(target_os = "linux") {
return vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()];
}
let Some(kernel) = kernel_abi.filter(|abi| *abi >= 1) else {
return vec![
"kernel ABI: none (no Landlock on this host)".to_string(),
"ruleset ABI: n/a (no Landlock domain is installed)".to_string(),
];
};
let built = kernel.min(LANDLOCK_TARGET_ABI);
let mut lines = vec![
format!("kernel ABI: {kernel}"),
format!("ruleset ABI: {built} (the kernel's, capped at {LANDLOCK_TARGET_ABI})"),
];
if built < 2 {
lines.push(
"moving or hard-linking a file between folders inside the work folder fails with \
EXDEV (Invalid cross-device link) on this host; Linux 5.19 (Landlock ABI v2) \
permits it. Tools that fall back to copy-and-delete (mv, pip, git) still work; uv \
and other tools that rename atomically between folders do not."
.to_string(),
);
}
if built < 3 {
lines.push(
"truncate(2) on a file outside the work folder is not mediated below Landlock ABI v3 \
(Linux 6.2)."
.to_string(),
);
}
lines
}
pub(crate) fn resolve_network_allowlist_ips(
network_allow: &[String],
) -> Result<Vec<IpAddr>, String> {
let rules = crate::network_policy::parse_network_allow_rules(network_allow)
.map_err(|error| error.to_string())?;
let mut ips = std::collections::BTreeSet::new();
for rule in &rules {
let host = rule.host.as_str();
match crate::dns_resolver::resolve(host) {
crate::dns_resolver::Resolution::Resolved(addresses) => ips.extend(addresses),
crate::dns_resolver::Resolution::DoesNotExist => {}
crate::dns_resolver::Resolution::DidNotAnswer(reason) => crate::runtime_err!(
"[capsule-runtime] warning: the network allowlist host '{host}' could not be \
resolved at launch: {reason} (a subprocess reaching it by literal address is \
denied for this run)"
),
}
}
Ok(ips.into_iter().collect())
}
pub(crate) fn resolve_exec_allowlist(shell_allow: &[String]) -> Vec<PathBuf> {
let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
resolve_exec_allowlist_in(shell_allow, &path_dirs)
}
pub(crate) fn resolve_exec_allowlist_in(
shell_allow: &[String],
path_dirs: &[PathBuf],
) -> Vec<PathBuf> {
let mut resolved = std::collections::BTreeSet::new();
for entry in shell_allow {
if entry.is_empty() {
continue;
}
for dir in path_dirs {
let candidate = dir.join(entry);
if is_executable_file(&candidate) {
if let Ok(canonical) = std::fs::canonicalize(&candidate) {
resolved.insert(canonical);
}
}
}
}
resolved.into_iter().collect()
}
pub(crate) fn resolve_invoked_binary_path(binary: &str) -> String {
let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
resolve_invoked_binary_path_in(binary, &path_dirs)
}
pub(crate) fn resolve_invoked_binary_path_in(binary: &str, path_dirs: &[PathBuf]) -> String {
let fallback = || binary.to_string();
if binary.is_empty() {
return fallback();
}
if binary.contains('/') {
return match std::fs::canonicalize(binary) {
Ok(canonical) => canonical.to_string_lossy().into_owned(),
Err(_) => fallback(),
};
}
for dir in path_dirs {
let candidate = dir.join(binary);
if is_executable_file(&candidate) {
if let Ok(canonical) = std::fs::canonicalize(&candidate) {
return canonical.to_string_lossy().into_owned();
}
}
}
fallback()
}
pub fn find_on_path(binary: &str) -> Option<PathBuf> {
std::env::split_paths(&std::env::var_os("PATH")?)
.map(|dir| dir.join(binary))
.find(|candidate| is_executable_file(candidate))
}
pub(crate) fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
pub(crate) const DEFAULT_LIBRARY_SEARCH_DIRS: &[&str] = &[
"/lib",
"/lib64",
"/usr/lib",
"/usr/lib64",
"/lib/x86_64-linux-gnu",
"/usr/lib/x86_64-linux-gnu",
"/lib/aarch64-linux-gnu",
"/usr/lib/aarch64-linux-gnu",
];
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ElfDependencies {
pub(crate) interp: Option<String>,
pub(crate) needed: Vec<String>,
pub(crate) runpaths: Vec<String>,
}
pub(crate) trait ElfSource {
fn size(&self) -> u64;
fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>>;
}
impl ElfSource for [u8] {
fn size(&self) -> u64 {
self.len() as u64
}
fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>> {
let start = usize::try_from(offset).ok()?;
let end = start.checked_add(usize::try_from(len).ok()?)?;
self.get(start..end).map(std::borrow::Cow::Borrowed)
}
}
pub(crate) struct ElfFile {
file: std::fs::File,
metadata: std::fs::Metadata,
}
impl ElfFile {
pub(crate) fn open_host(path: &Path) -> std::io::Result<Self> {
let file = std::fs::File::open(path)?;
let metadata = file.metadata()?;
Ok(Self { file, metadata })
}
#[cfg(target_os = "linux")]
pub(crate) fn metadata(&self) -> &std::fs::Metadata {
&self.metadata
}
}
impl ElfSource for ElfFile {
fn size(&self) -> u64 {
self.metadata.len()
}
fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>> {
use std::os::unix::fs::FileExt;
if offset.checked_add(len)? > self.size() {
return None;
}
let mut bytes = vec![0; usize::try_from(len).ok()?];
self.file.read_exact_at(&mut bytes, offset).ok()?;
Some(std::borrow::Cow::Owned(bytes))
}
}
fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option<u16> {
let raw: [u8; 2] = bytes.get(off..off + 2)?.try_into().ok()?;
Some(if le {
u16::from_le_bytes(raw)
} else {
u16::from_be_bytes(raw)
})
}
fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option<u32> {
let raw: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?;
Some(if le {
u32::from_le_bytes(raw)
} else {
u32::from_be_bytes(raw)
})
}
fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option<u64> {
let raw: [u8; 8] = bytes.get(off..off + 8)?.try_into().ok()?;
Some(if le {
u64::from_le_bytes(raw)
} else {
u64::from_be_bytes(raw)
})
}
fn elf64_little_endian(header: &[u8]) -> Option<bool> {
if header.get(0..4)? != b"\x7fELF" || *header.get(4)? != 2 {
return None;
}
match header.get(5)? {
1 => Some(true),
2 => Some(false),
_ => None,
}
}
fn read_clamped<S: ElfSource + ?Sized>(
image: &S,
offset: u64,
len: u64,
) -> Option<std::borrow::Cow<'_, [u8]>> {
let available = image.size().saturating_sub(offset).min(len);
if available == 0 {
return Some(std::borrow::Cow::Borrowed(&[]));
}
image.read_at(offset, available)
}
fn cstr_at_start(region: &[u8]) -> String {
let nul = region.iter().position(|&b| b == 0).unwrap_or(region.len());
String::from_utf8_lossy(®ion[..nul]).into_owned()
}
fn vaddr_to_file_offset(vaddr: u64, loads: &[(u64, u64, u64)]) -> Option<u64> {
for &(seg_vaddr, seg_off, seg_filesz) in loads {
if vaddr >= seg_vaddr && vaddr < seg_vaddr.checked_add(seg_filesz)? {
return Some(seg_off + (vaddr - seg_vaddr));
}
}
None
}
pub(crate) fn parse_elf_dependencies<S: ElfSource + ?Sized>(image: &S) -> Option<ElfDependencies> {
let header = image.read_at(0, 64)?;
let le = elf64_little_endian(&header)?;
let e_phoff = read_u64(&header, 32, le)?;
let e_phentsize = u64::from(read_u16(&header, 54, le)?);
let e_phnum = u64::from(read_u16(&header, 56, le)?);
if e_phentsize < 56 {
return None;
}
let mut interp: Option<String> = None;
let mut dynamic_region: Option<(u64, u64)> = None; let mut loads: Vec<(u64, u64, u64)> = Vec::new();
let table = match e_phnum.checked_sub(1) {
None => std::borrow::Cow::Borrowed(&[][..]),
Some(last) => image.read_at(e_phoff, last.checked_mul(e_phentsize)?.checked_add(40)?)?,
};
for i in 0..e_phnum {
let base = usize::try_from(i * e_phentsize).ok()?;
let p_type = read_u32(&table, base, le)?;
let p_offset = read_u64(&table, base + 8, le)?;
let p_vaddr = read_u64(&table, base + 16, le)?;
let p_filesz = read_u64(&table, base + 32, le)?;
match p_type {
1 => loads.push((p_vaddr, p_offset, p_filesz)), 2 => dynamic_region = Some((p_offset, p_filesz)), 3 => {
interp = Some(cstr_at_start(&image.read_at(p_offset, p_filesz)?));
}
_ => {}
}
}
let Some((dyn_off, dyn_size)) = dynamic_region else {
return Some(ElfDependencies {
interp,
needed: Vec::new(),
runpaths: Vec::new(),
});
};
let mut needed_offsets: Vec<u64> = Vec::new();
let mut runpath_offsets: Vec<u64> = Vec::new();
let mut strtab_vaddr: Option<u64> = None;
let mut strsz: Option<u64> = None;
dyn_off.checked_add(dyn_size)?;
let dynamic = read_clamped(image, dyn_off, dyn_size)?;
let mut off = 0u64;
while off + 16 <= dyn_size {
let entry = dynamic.get(usize::try_from(off).ok()?..usize::try_from(off + 16).ok()?)?;
let d_tag = read_u64(entry, 0, le)?;
let d_val = read_u64(entry, 8, le)?;
match d_tag {
0 => break, 1 => needed_offsets.push(d_val), 5 => strtab_vaddr = Some(d_val), 10 => strsz = Some(d_val), 15 | 29 => runpath_offsets.push(d_val), _ => {}
}
off += 16;
}
let mut needed = Vec::new();
let mut runpaths = Vec::new();
if let (Some(strtab_vaddr), Some(strsz)) = (strtab_vaddr, strsz) {
if let Some(strtab_off) = vaddr_to_file_offset(strtab_vaddr, &loads) {
let strtab = (strtab_off <= image.size())
.then(|| read_clamped(image, strtab_off, strsz))
.flatten();
if let Some(strtab) = strtab {
for offset in needed_offsets {
if let Some(region) = strtab.get(offset as usize..) {
needed.push(cstr_at_start(region));
}
}
for offset in runpath_offsets {
if let Some(region) = strtab.get(offset as usize..) {
for part in cstr_at_start(region).split(':') {
if !part.is_empty() {
runpaths.push(part.to_string());
}
}
}
}
}
}
}
Some(ElfDependencies {
interp,
needed,
runpaths,
})
}
#[cfg(target_os = "linux")]
pub(crate) fn elf_is_shared_object<S: ElfSource + ?Sized>(image: &S) -> bool {
let Some(head) = image.read_at(0, 18) else {
return false;
};
elf64_little_endian(&head).is_some_and(|le| read_u16(&head, 16, le) == Some(3))
}
#[cfg(target_os = "linux")]
pub(crate) fn elf_dynamic_symbol_names<S: ElfSource + ?Sized>(image: &S) -> Option<Vec<String>> {
let header = image.read_at(0, 64)?;
let le = elf64_little_endian(&header)?;
let e_shoff = read_u64(&header, 40, le)?;
let e_shentsize = u64::from(read_u16(&header, 58, le)?);
let e_shnum = u64::from(read_u16(&header, 60, le)?);
if e_shentsize < 64 {
return None;
}
let section = |index: u64| -> Option<(u32, u64, u64, u64, u64)> {
let base = e_shoff.checked_add(index.checked_mul(e_shentsize)?)?;
let entry = image.read_at(base, 64)?;
Some((
read_u32(&entry, 4, le)?,
read_u64(&entry, 24, le)?,
read_u64(&entry, 32, le)?,
u64::from(read_u32(&entry, 40, le)?),
read_u64(&entry, 56, le)?,
))
};
let (_, sym_off, sym_size, strtab_index, sym_entsize) = (0..e_shnum)
.filter_map(section)
.find(|(sh_type, ..)| *sh_type == 11)?;
if sym_entsize < 24 {
return None;
}
let (_, str_off, str_size, _, _) = section(strtab_index)?;
let strtab = image.read_at(str_off, str_size)?;
let symbols = image.read_at(sym_off, sym_size)?;
let mut names = Vec::new();
for symbol in symbols.chunks_exact(usize::try_from(sym_entsize).ok()?) {
let st_name = read_u32(symbol, 0, le)? as usize;
if st_name == 0 {
continue;
}
if let Some(region) = strtab.get(st_name..) {
names.push(cstr_at_start(region));
}
}
Some(names)
}
fn expand_origin(rpath: &str, origin_dir: &Path) -> PathBuf {
if rpath.contains("$ORIGIN") {
let origin = origin_dir.to_string_lossy();
PathBuf::from(
rpath
.replace("${ORIGIN}", &origin)
.replace("$ORIGIN", &origin),
)
} else {
PathBuf::from(rpath)
}
}
pub(crate) fn resolve_soname(
soname: &str,
origin_dir: &Path,
runpaths: &[String],
search_dirs: &[PathBuf],
) -> Option<PathBuf> {
if soname.is_empty() {
return None;
}
if soname.contains('/') {
let candidate = if Path::new(soname).is_absolute() {
PathBuf::from(soname)
} else {
origin_dir.join(soname)
};
return std::fs::canonicalize(&candidate).ok();
}
for rpath in runpaths {
let candidate = expand_origin(rpath, origin_dir).join(soname);
if candidate.is_file() {
if let Ok(canonical) = std::fs::canonicalize(&candidate) {
return Some(canonical);
}
}
}
for dir in search_dirs {
let candidate = dir.join(soname);
if candidate.is_file() {
if let Ok(canonical) = std::fs::canonicalize(&candidate) {
return Some(canonical);
}
}
}
None
}
pub(crate) fn resolve_landlock_grants(exec_allow_paths: &[PathBuf]) -> Vec<PathBuf> {
let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
.iter()
.map(PathBuf::from)
.collect();
resolve_landlock_grants_in(exec_allow_paths, &search_dirs)
}
fn resolve_landlock_grants_in(
exec_allow_paths: &[PathBuf],
search_dirs: &[PathBuf],
) -> Vec<PathBuf> {
let Ok(grants) = walk_dependency_closure(
exec_allow_paths,
search_dirs,
&[],
|path| -> Result<_, std::convert::Infallible> { Ok(read_elf_dependencies(path)) },
);
grants
}
pub(crate) fn read_elf_dependencies(path: &Path) -> Option<ElfDependencies> {
parse_elf_dependencies(&ElfFile::open_host(path).ok()?)
}
pub(crate) fn walk_dependency_closure<E>(
exec_allow_paths: &[PathBuf],
search_dirs: &[PathBuf],
excluded: &[PathBuf],
mut dependencies: impl FnMut(&Path) -> Result<Option<ElfDependencies>, E>,
) -> Result<Vec<PathBuf>, E> {
let mut grants: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
let mut visited: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
let mut queue: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
for path in exec_allow_paths {
if let Ok(canonical) = std::fs::canonicalize(path) {
queue.push_back(canonical);
}
}
while let Some(path) = queue.pop_front() {
if excluded.iter().any(|root| path.starts_with(root)) {
continue;
}
if !visited.insert(path.clone()) {
continue;
}
grants.insert(path.clone());
let Some(deps) = dependencies(&path)? else {
continue;
};
let origin_dir = path.parent().unwrap_or_else(|| Path::new("/"));
if let Some(interp) = &deps.interp {
if let Ok(canonical) = std::fs::canonicalize(interp) {
queue.push_back(canonical);
}
}
for soname in &deps.needed {
if let Some(resolved) = resolve_soname(soname, origin_dir, &deps.runpaths, search_dirs)
{
queue.push_back(resolved);
}
}
}
Ok(grants.into_iter().collect())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LandlockGrant {
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) path: PathBuf,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) list_dir: bool,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) executable: bool,
}
impl LandlockGrant {
fn non_listable_files(paths: Vec<PathBuf>) -> Vec<LandlockGrant> {
paths
.into_iter()
.map(|path| LandlockGrant {
path,
list_dir: false,
executable: true,
})
.collect()
}
}
pub(crate) fn resolve_interpreter_runtime_grants(
grants: &[InterpreterRuntimeGrant],
) -> Vec<LandlockGrant> {
grants
.iter()
.flat_map(|grant| {
grant.dirs.iter().map(|dir| LandlockGrant {
path: PathBuf::from(&dir.path),
list_dir: dir.list_dir,
executable: true,
})
})
.collect()
}
pub(crate) fn resolve_derived_interpreter_grants(
derived: &[crate::interpreter_runtime::DerivedInterpreterRuntime],
) -> Vec<LandlockGrant> {
let read_only = |path: &Path, list_dir: bool| LandlockGrant {
path: path.to_path_buf(),
list_dir,
executable: false,
};
derived
.iter()
.flat_map(|runtime| {
runtime
.entries()
.map(|(path, is_dir)| read_only(path, is_dir))
.chain(runtime.libraries.iter().map(|path| read_only(path, false)))
.collect::<Vec<_>>()
})
.collect()
}
pub(crate) fn resolve_trust_store_grants(
paths: &[crate::trust_store::TrustStorePath],
) -> Vec<LandlockGrant> {
paths
.iter()
.map(|entry| LandlockGrant {
path: entry.path.clone(),
list_dir: entry.directory,
executable: false,
})
.collect()
}
pub(crate) fn resolve_declared_need_grants(
needs: &[crate::declared_needs::ResolvedProgramNeeds],
) -> Vec<LandlockGrant> {
needs
.iter()
.flat_map(|program| program.granted.iter())
.flat_map(|need| need.paths.iter())
.map(|entry| LandlockGrant {
path: entry.path.clone(),
list_dir: entry.directory,
executable: false,
})
.collect()
}
pub(crate) fn resolve_sealed_need_mirrors(
needs: &[crate::declared_needs::ResolvedProgramNeeds],
bind_dirs: &[PathBuf],
) -> Vec<PathBuf> {
let mut mirrors: Vec<PathBuf> = Vec::new();
for path in needs
.iter()
.flat_map(|program| program.granted.iter())
.flat_map(|need| need.paths.iter())
.map(|entry| &entry.path)
{
let carried = crate::sealed::SEALED_RUNTIME_PATHS
.iter()
.chain(
crate::sealed::SEALED_ETC_PATHS
.iter()
.map(|entry| &entry.path),
)
.any(|root| path.starts_with(root))
|| bind_dirs.iter().any(|dir| path.starts_with(dir));
if path.is_absolute() && !carried && !mirrors.contains(path) {
mirrors.push(path.clone());
}
}
mirrors
}
pub(crate) fn resolve_sealed_bind_dirs(
exec_allow_paths: &[PathBuf],
policy: &CapabilityPolicy,
) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
let candidates = exec_allow_paths
.iter()
.filter_map(|binary| binary.parent().map(Path::to_path_buf))
.chain(
policy
.shell_interpreter_runtime
.iter()
.flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
)
.chain(
policy
.shell_derived_interpreter_runtime
.iter()
.flat_map(|runtime| {
runtime
.entries()
.filter(|(_, is_dir)| *is_dir)
.map(|(path, _)| path.to_path_buf())
.chain(
runtime
.libraries
.iter()
.filter_map(|library| library.parent().map(Path::to_path_buf)),
)
.collect::<Vec<_>>()
}),
);
for candidate in candidates {
push_sealed_bind_dir(&mut dirs, candidate);
}
dirs
}
pub(crate) fn host_path_execute_roots(
policy: &CapabilityPolicy,
) -> Vec<crate::host_paths::ExecuteRoot> {
let exec_allow_paths = resolve_policy_exec_allowlist(policy);
execute_roots_from(&resolve_landlock_grants(&exec_allow_paths), policy)
}
fn execute_roots_from(
closure: &[PathBuf],
policy: &CapabilityPolicy,
) -> Vec<crate::host_paths::ExecuteRoot> {
let mut roots: Vec<crate::host_paths::ExecuteRoot> = Vec::new();
let mut push = |dir: PathBuf, because: PathBuf| {
let dir = std::fs::canonicalize(&dir).unwrap_or(dir);
if dir.is_absolute() && !roots.iter().any(|root| root.dir == dir) {
roots.push(crate::host_paths::ExecuteRoot { dir, because });
}
};
for file in closure {
if let Some(parent) = file.parent() {
push(parent.to_path_buf(), file.clone());
}
}
for grant in &policy.shell_interpreter_runtime {
for dir in &grant.dirs {
push(PathBuf::from(&dir.path), PathBuf::from(&dir.path));
}
}
for dir in resolve_staged_runtime_dirs(policy) {
push(dir.clone(), dir);
}
for dir in resolve_pinned_program_dirs(policy) {
push(dir.clone(), dir);
}
roots
}
pub(crate) fn resolve_policy_exec_allowlist(policy: &CapabilityPolicy) -> Vec<PathBuf> {
let mut resolved: std::collections::BTreeSet<PathBuf> =
resolve_exec_allowlist(&policy.shell_allow)
.into_iter()
.collect();
resolved.extend(resolve_exec_allowlist_in(
&policy.shell_allow,
&crate::pinned_programs::pinned_bin_dirs(&policy.shell_pinned_programs),
));
resolved.into_iter().collect()
}
pub(crate) fn resolve_pinned_program_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
crate::pinned_programs::pinned_trees(&policy.shell_pinned_programs)
.into_iter()
.filter(|tree| tree.is_absolute())
.collect()
}
pub(crate) fn resolve_pinned_program_landlock_grants(
policy: &CapabilityPolicy,
) -> Vec<LandlockGrant> {
resolve_pinned_program_dirs(policy)
.into_iter()
.map(|path| LandlockGrant {
path,
list_dir: true,
executable: true,
})
.collect()
}
pub(crate) fn host_path_mechanism(tier: EnforcementTier) -> crate::host_paths::HostPathMechanism {
match tier {
EnforcementTier::KernelSealed => crate::host_paths::HostPathMechanism::Bind,
EnforcementTier::KernelFull => crate::host_paths::HostPathMechanism::Landlock,
EnforcementTier::KernelSeccompOnly
| EnforcementTier::EnvironmentOnly
| EnforcementTier::Unconfined => crate::host_paths::HostPathMechanism::None,
}
}
fn push_sealed_bind_dir(dirs: &mut Vec<PathBuf>, candidate: PathBuf) {
if !candidate.is_absolute() {
return;
}
if crate::sealed::SEALED_RUNTIME_PATHS
.iter()
.any(|root| candidate.starts_with(root))
{
return;
}
if dirs.contains(&candidate) {
return;
}
dirs.push(candidate);
}
pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
for grant in &policy.shell_staged_runtime {
let path = PathBuf::from(&grant.source_path);
if !path.is_absolute() || dirs.contains(&path) {
continue;
}
dirs.push(path);
}
dirs
}
pub(crate) fn resolve_staged_runtime_landlock_grants(
policy: &CapabilityPolicy,
) -> Vec<LandlockGrant> {
resolve_staged_runtime_dirs(policy)
.into_iter()
.map(|path| LandlockGrant {
path,
list_dir: true,
executable: true,
})
.collect()
}
pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
if tier != EnforcementTier::KernelSealed {
return Vec::new();
}
crate::sealed::SEALED_RUNTIME_PATHS
.iter()
.map(|path| LandlockGrant {
path: PathBuf::from(path),
list_dir: true,
executable: false,
})
.collect()
}
pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
if tier != EnforcementTier::KernelSealed {
return Vec::new();
}
crate::sealed::SEALED_ETC_PATHS
.iter()
.map(|entry| LandlockGrant {
path: PathBuf::from(entry.path),
list_dir: entry.directory,
executable: false,
})
.collect()
}
pub(crate) const SCOPED_NAME_RESOLUTION_PATHS: &[&str] = &["/etc/hosts", "/etc/nsswitch.conf"];
pub(crate) fn resolve_scoped_name_resolution_landlock_grants(
tier: EnforcementTier,
) -> Vec<LandlockGrant> {
if tier != EnforcementTier::KernelFull {
return Vec::new();
}
SCOPED_NAME_RESOLUTION_PATHS
.iter()
.map(|path| LandlockGrant {
path: PathBuf::from(path),
list_dir: false,
executable: false,
})
.collect()
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CapsuleDeviceGrant {
pub(crate) path: &'static str,
pub(crate) writable: bool,
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
CapsuleDeviceGrant {
path: "/dev/null",
writable: true,
},
CapsuleDeviceGrant {
path: "/dev/zero",
writable: false,
},
CapsuleDeviceGrant {
path: "/dev/urandom",
writable: false,
},
];
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
network_allow_ips.contains(&ip)
}
const LINUX_AF_UNIX: i32 = 1;
const LINUX_AF_INET: i32 = 2;
const LINUX_AF_INET6: i32 = 10;
const LINUX_AF_NETLINK: i32 = 16;
const LINUX_AF_PACKET: i32 = 17;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
if !unix_sockets_allowed {
denied.push(LINUX_AF_UNIX);
}
denied
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
if unix_sockets_allowed {
allowed.push(LINUX_AF_UNIX);
}
allowed
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
"execve",
"execveat",
"clone",
"clone3",
"fork",
"vfork",
"exit",
"exit_group",
"wait4",
"waitid",
"kill",
"tkill",
"tgkill",
"rt_sigaction",
"rt_sigprocmask",
"rt_sigreturn",
"rt_sigsuspend",
"rt_sigpending",
"rt_sigtimedwait",
"rt_sigqueueinfo",
"rt_tgsigqueueinfo",
"sigaltstack",
"restart_syscall",
"pause",
"set_tid_address",
"set_robust_list",
"get_robust_list",
"rseq",
"prctl",
"arch_prctl",
"getpid",
"getppid",
"gettid",
"getpgrp",
"getpgid",
"setpgid",
"getsid",
"setsid",
"getpriority",
"setpriority",
"ioprio_get",
"ioprio_set",
"sched_yield",
"sched_getaffinity",
"sched_setaffinity",
"sched_getparam",
"sched_setparam",
"sched_getscheduler",
"sched_setscheduler",
"sched_getattr",
"sched_setattr",
"sched_get_priority_max",
"sched_get_priority_min",
"sched_rr_get_interval",
"getcpu",
"capget",
"capset",
"prlimit64",
"getrlimit",
"setrlimit",
"getrusage",
"getitimer",
"setitimer",
"times",
"uname",
"sysinfo",
"pidfd_open",
"pidfd_send_signal",
"mmap",
"munmap",
"mprotect",
"mremap",
"madvise",
"brk",
"mlock",
"mlock2",
"munlock",
"mlockall",
"munlockall",
"membarrier",
"msync",
"mincore",
"memfd_create",
"pkey_alloc",
"pkey_free",
"pkey_mprotect",
"shmget",
"shmat",
"shmdt",
"shmctl",
"open",
"openat",
"openat2",
"creat",
"close",
"close_range",
"read",
"write",
"pread64",
"pwrite64",
"readv",
"writev",
"preadv",
"pwritev",
"preadv2",
"pwritev2",
"lseek",
"dup",
"dup2",
"dup3",
"fcntl",
"flock",
"fsync",
"fdatasync",
"sync",
"syncfs",
"sync_file_range",
"fadvise64",
"readahead",
"truncate",
"ftruncate",
"fallocate",
"stat",
"fstat",
"lstat",
"newfstatat",
"statx",
"access",
"faccessat",
"faccessat2",
"getdents",
"getdents64",
"getcwd",
"chdir",
"fchdir",
"mkdir",
"mkdirat",
"rmdir",
"unlink",
"unlinkat",
"rename",
"renameat",
"renameat2",
"link",
"linkat",
"symlink",
"symlinkat",
"readlink",
"readlinkat",
"chmod",
"fchmod",
"fchmodat",
"fchmodat2",
"chown",
"fchown",
"fchownat",
"lchown",
"umask",
"utime",
"utimes",
"utimensat",
"futimesat",
"mknod",
"mknodat",
"sendfile",
"copy_file_range",
"splice",
"tee",
"vmsplice",
"ioctl",
"poll",
"ppoll",
"select",
"pselect6",
"epoll_create",
"epoll_create1",
"epoll_ctl",
"epoll_wait",
"epoll_pwait",
"epoll_pwait2",
"eventfd",
"eventfd2",
"pipe",
"pipe2",
"inotify_init",
"inotify_init1",
"inotify_add_watch",
"inotify_rm_watch",
"signalfd",
"signalfd4",
"timerfd_create",
"timerfd_settime",
"timerfd_gettime",
"getxattr",
"lgetxattr",
"fgetxattr",
"setxattr",
"lsetxattr",
"fsetxattr",
"listxattr",
"llistxattr",
"flistxattr",
"removexattr",
"lremovexattr",
"fremovexattr",
"statfs",
"fstatfs",
"name_to_handle_at",
"io_setup",
"io_destroy",
"io_submit",
"io_cancel",
"io_getevents",
"connect",
"sendto",
"socketpair",
"bind",
"listen",
"accept",
"accept4",
"getsockname",
"getpeername",
"getsockopt",
"setsockopt",
"shutdown",
"sendmsg",
"sendmmsg",
"recvfrom",
"recvmsg",
"recvmmsg",
"clock_gettime",
"clock_getres",
"clock_nanosleep",
"nanosleep",
"gettimeofday",
"time",
"timer_create",
"timer_settime",
"timer_gettime",
"timer_getoverrun",
"timer_delete",
"alarm",
"msgget",
"msgsnd",
"msgrcv",
"msgctl",
"semget",
"semop",
"semctl",
"semtimedop",
"mq_open",
"mq_unlink",
"mq_timedsend",
"mq_timedreceive",
"mq_notify",
"mq_getsetattr",
"getrandom",
"futex",
"futex_waitv",
"getuid",
"geteuid",
"getgid",
"getegid",
"setuid",
"setgid",
"setreuid",
"setregid",
"setresuid",
"setresgid",
"getresuid",
"getresgid",
"getgroups",
"setgroups",
"setfsuid",
"setfsgid",
];
#[allow(dead_code)]
pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
"io_uring_setup",
"io_uring_enter",
"io_uring_register",
"bpf",
"userfaultfd",
"perf_event_open",
"process_vm_readv",
"process_vm_writev",
"open_by_handle_at",
"keyctl",
"add_key",
"request_key",
"ptrace",
"mount",
"umount2",
"pivot_root",
"chroot",
"unshare",
"setns",
"init_module",
"finit_module",
"delete_module",
"kexec_load",
"kexec_file_load",
"reboot",
"swapon",
"swapoff",
"acct",
"quotactl",
"nfsservctl",
"iopl",
"ioperm",
"vm86",
"vm86old",
"_sysctl",
"sysfs",
"uselib",
"create_module",
"get_kernel_syms",
"query_module",
"lookup_dcookie",
"clock_adjtime",
"clock_settime",
"settimeofday",
"stime",
"adjtimex",
"set_mempolicy",
"get_mempolicy",
"mbind",
"migrate_pages",
"move_pages",
"fanotify_init",
"fanotify_mark",
"seccomp",
"personality",
"kcmp",
];
#[allow(dead_code)]
pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
#[allow(dead_code)]
pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
#[allow(dead_code)]
pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
#[derive(Debug, Clone)]
pub(crate) struct ShellEnforcement {
pub(crate) tier: EnforcementTier,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) network_allow_ips: Vec<IpAddr>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) egress_tcp_ports: Vec<u16>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) bind_observation: Option<crate::held_ports::HeldPorts>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) unix_sockets_allowed: bool,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) workdir_exec: bool,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) landlock_abi: Option<u8>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) landlock_grants: Vec<LandlockGrant>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) sealed_bind_dirs: Vec<PathBuf>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) sealed_need_mirrors: Vec<PathBuf>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) staged_runtime_dirs: Vec<PathBuf>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) pinned_program_dirs: Vec<PathBuf>,
pub(crate) resource_limits: crate::resources::HostResourceLimits,
pub(crate) declared_resources: murmur_artifact::ResourceCapabilities,
pub(crate) cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
pub(crate) workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
pub(crate) session_netns: Arc<crate::network_namespace::SessionNetns>,
pub(crate) runtime_dir: Option<crate::runtime_dir::RuntimeDirRoots>,
pub(crate) runtime_dir_owner: Option<crate::runtime_dir::SharedRuntimeDir>,
pub(crate) temp_mapping: crate::temp_dir::TempMapping,
pub(crate) proc_view: crate::proc_view::ProcView,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) host_paths: Vec<crate::host_paths::HostPath>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) host_path_read_only_binds: Vec<PathBuf>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fd_hygiene_fallback: bool,
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[derive(Debug, Clone)]
pub(crate) struct HarnessEnforcementGrants {
pub(crate) granted_paths: Vec<crate::host_paths::HostPath>,
pub(crate) exec_files: Vec<PathBuf>,
}
impl ShellEnforcement {
pub(crate) fn resolve(
policy: &CapabilityPolicy,
decision: &crate::containment::ContainmentDecision,
probe: HostProbe,
) -> Result<Self, String> {
let tier = decision.applied_tier();
let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
let network_allow_rules =
crate::network_policy::parse_network_allow_rules(&policy.network_allow)
.map_err(|error| error.to_string())?;
let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
let bind_observation = (observes_binds_on(tier) && bind_notification_supported())
.then(|| {
crate::held_ports::HeldPorts::from_allowlist(
&policy.network_allow,
&egress_tcp_ports,
)
})
.filter(|held| !held.is_empty());
let exec_allow_paths = resolve_policy_exec_allowlist(policy);
let closure = resolve_landlock_grants(&exec_allow_paths);
let host_path_read_only_binds = crate::host_paths::check_execute_roots(
&policy.host_paths,
&execute_roots_from(&closure, policy),
host_path_mechanism(tier),
)
.map_err(|refusal| refusal.to_string())?;
let mut landlock_grants = LandlockGrant::non_listable_files(closure);
landlock_grants.extend(resolve_interpreter_runtime_grants(
&policy.shell_interpreter_runtime,
));
landlock_grants.extend(resolve_derived_interpreter_grants(
&policy.shell_derived_interpreter_runtime,
));
landlock_grants.extend(resolve_trust_store_grants(&policy.shell_trust_store));
landlock_grants.extend(resolve_declared_need_grants(&policy.shell_declared_needs));
landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
landlock_grants.extend(resolve_pinned_program_landlock_grants(policy));
landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
landlock_grants.extend(resolve_scoped_name_resolution_landlock_grants(tier));
let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
let sealed_need_mirrors =
resolve_sealed_need_mirrors(&policy.shell_declared_needs, &sealed_bind_dirs);
let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
let pinned_program_dirs = resolve_pinned_program_dirs(policy);
Ok(Self {
tier,
network_allow_ips,
network_allow_rules,
egress_tcp_ports,
bind_observation,
unix_sockets_allowed: policy.unix_sockets_allowed,
workdir_exec: policy.workdir_exec_allowed,
landlock_abi: ruleset_landlock_abi(probe.landlock_kernel_abi(), tier),
landlock_grants,
sealed_bind_dirs,
sealed_need_mirrors,
staged_runtime_dirs,
pinned_program_dirs,
resource_limits: policy.resources,
declared_resources: policy.declared_resources,
cgroup_scope: None,
workdir_guard: None,
session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
runtime_dir: None,
runtime_dir_owner: None,
temp_mapping: crate::temp_dir::tier_temp_mapping(tier),
proc_view: crate::proc_view::proc_view(tier, &probe.namespaces),
host_paths: policy.host_paths.clone(),
host_path_read_only_binds,
fd_hygiene_fallback: false,
})
}
#[cfg(test)]
pub(crate) fn resolve_declared(
policy: &CapabilityPolicy,
declared: murmur_artifact::ContainmentClass,
probe: HostProbe,
) -> Result<Self, String> {
let decision = crate::containment::ContainmentDecision::decide(
probe.tier(),
declared,
policy.workdir_exec_allowed,
false,
);
Self::resolve(policy, &decision, probe)
}
pub(crate) fn with_runtime_dir(
mut self,
roots: crate::runtime_dir::RuntimeDirRoots,
owner: Option<crate::runtime_dir::SharedRuntimeDir>,
) -> Self {
self.runtime_dir = Some(roots);
self.runtime_dir_owner = owner;
self
}
pub(crate) fn runtime_dir_path(&self) -> Option<&Path> {
self.runtime_dir.as_ref().map(|roots| roots.runtime.path())
}
pub(crate) fn bridge_netns(&self) -> Option<Arc<crate::network_namespace::SessionNetns>> {
matches!(
self.tier,
EnforcementTier::KernelSealed
| EnforcementTier::KernelFull
| EnforcementTier::KernelSeccompOnly
)
.then(|| Arc::clone(&self.session_netns))
}
pub(crate) fn records_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
self.runtime_dir
.as_ref()
.and_then(|roots| roots.records.as_ref())
}
pub(crate) fn records_dir_path(&self) -> Option<&Path> {
self.records_root()
.map(crate::workdir_fs::WorkdirRoot::path)
}
pub(crate) fn temp_dir_path(&self) -> Option<&Path> {
self.runtime_dir
.as_ref()
.and_then(|roots| roots.temp.as_ref())
.map(crate::workdir_fs::WorkdirRoot::path)
}
pub(crate) fn home_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
self.runtime_dir
.as_ref()
.and_then(|roots| roots.home.as_ref())
}
pub(crate) fn home_dir_path(&self) -> Option<&Path> {
self.home_root().map(crate::workdir_fs::WorkdirRoot::path)
}
pub(crate) fn with_temp_mapping(mut self, mapping: crate::temp_dir::TempMapping) -> Self {
self.temp_mapping = mapping;
self
}
pub(crate) fn installed_temp_mapping(&self) -> crate::temp_dir::TempMapping {
use crate::temp_dir::TempMapping;
match (self.tier, self.temp_mapping) {
(
EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
TempMapping::MountNamespace,
) => TempMapping::MountNamespace,
(
EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
TempMapping::NotMapped(reason),
) => TempMapping::NotMapped(reason),
(tier, _) => crate::temp_dir::tier_temp_mapping(tier),
}
}
#[cfg(all(test, target_os = "linux"))]
pub(crate) fn with_proc_view(mut self, view: crate::proc_view::ProcView) -> Self {
self.proc_view = view;
self
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn installed_proc_view(&self) -> crate::proc_view::ProcView {
match (self.tier, &self.proc_view) {
(
EnforcementTier::KernelFull
| EnforcementTier::KernelSealed
| EnforcementTier::KernelSeccompOnly,
view,
) => view.clone(),
(tier, _) => crate::proc_view::tier_proc_view(tier),
}
}
pub(crate) fn tmpdir_value(&self) -> Option<String> {
self.temp_dir_path()
.map(|folder| self.installed_temp_mapping().tmpdir_value(folder))
}
pub(crate) fn remove_runtime_dir(&self) {
if let Some(owner) = &self.runtime_dir_owner {
owner.remove();
}
}
pub(crate) fn with_host_bounding(
mut self,
cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
) -> Self {
self.cgroup_scope = cgroup_scope;
self.workdir_guard = workdir_guard;
self
}
pub(crate) fn with_harness_grants(
&self,
grants: &HarnessEnforcementGrants,
) -> Result<Self, String> {
let mut enforcement = self.clone();
if matches!(
self.tier,
EnforcementTier::Unconfined | EnforcementTier::EnvironmentOnly
) {
return Ok(enforcement);
}
enforcement
.host_paths
.extend(grants.granted_paths.iter().cloned());
let exec_paths = &grants.exec_files;
let closure = resolve_landlock_grants(exec_paths);
for file in exec_paths {
if let Some(dir) = file.parent() {
push_sealed_bind_dir(&mut enforcement.sealed_bind_dirs, dir.to_path_buf());
}
}
let harness_exec_roots: Vec<crate::host_paths::ExecuteRoot> = closure
.iter()
.filter_map(|file| {
file.parent().map(|dir| crate::host_paths::ExecuteRoot {
dir: dir.to_path_buf(),
because: file.clone(),
})
})
.collect();
let extra_binds = crate::host_paths::check_execute_roots(
&enforcement.host_paths,
&harness_exec_roots,
host_path_mechanism(self.tier),
)
.map_err(|refusal| refusal.to_string())?;
enforcement.host_path_read_only_binds.extend(extra_binds);
enforcement
.landlock_grants
.extend(LandlockGrant::non_listable_files(closure));
Ok(enforcement)
}
pub(crate) fn nproc_bound(&self) -> crate::resources::NprocBound {
if self
.cgroup_scope
.as_ref()
.is_some_and(|scope| scope.places_subprocesses())
{
crate::resources::NprocBound::CgroupPidsMax
} else {
crate::resources::NprocBound::Headroom {
baseline: crate::resources::uid_task_count().unwrap_or(0),
}
}
}
pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
self.workdir_guard.as_ref().and_then(|guard| guard.breach())
}
pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
match self.workdir_breach() {
Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
None => Ok(()),
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn environment_only() -> Self {
Self {
tier: EnforcementTier::EnvironmentOnly,
network_allow_ips: Vec::new(),
network_allow_rules: Vec::new(),
egress_tcp_ports: Vec::new(),
bind_observation: None,
unix_sockets_allowed: false,
workdir_exec: false,
landlock_abi: None,
landlock_grants: Vec::new(),
sealed_bind_dirs: Vec::new(),
sealed_need_mirrors: Vec::new(),
staged_runtime_dirs: Vec::new(),
pinned_program_dirs: Vec::new(),
resource_limits: crate::resources::HostResourceLimits::default(),
declared_resources: murmur_artifact::ResourceCapabilities::default(),
cgroup_scope: None,
workdir_guard: None,
session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
runtime_dir: None,
runtime_dir_owner: None,
temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::EnvironmentOnly),
proc_view: crate::proc_view::tier_proc_view(EnforcementTier::EnvironmentOnly),
host_paths: Vec::new(),
host_path_read_only_binds: Vec::new(),
fd_hygiene_fallback: false,
}
}
#[cfg(test)]
pub(crate) fn with_test_session_dirs(self) -> Self {
let base = tempfile::tempdir().unwrap().keep();
self.with_runtime_dir(test_runtime_dir(&base), None)
}
pub(crate) fn with_session_netns(
mut self,
session_netns: Arc<crate::network_namespace::SessionNetns>,
) -> Self {
self.session_netns = session_netns;
self
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn observed_held_ports(&self) -> Option<&crate::held_ports::HeldPorts> {
self.bind_observation
.as_ref()
.filter(|_| observes_binds_on(self.tier))
}
pub(crate) fn take_held_port_lines(&self) -> Vec<String> {
self.session_netns.take_held_port_lines()
}
pub(crate) fn end_session(&self) {
self.kill_session_processes();
self.session_netns.end();
}
pub(crate) fn kill_session_processes(&self) {
let Some(scope) = self.cgroup_scope.as_ref() else {
return;
};
if let Err(error) = scope.kill_all() {
crate::runtime_err!(
"[capsule-runtime] warning: could not kill the processes in cgroup scope {}: \
{error}",
scope.path().display()
);
}
if !scope.wait_until_empty(SESSION_END_KILL_WAIT) {
crate::runtime_err!(
"[capsule-runtime] warning: cgroup scope {} still held processes {} ms after \
they were killed",
scope.path().display(),
SESSION_END_KILL_WAIT.as_millis()
);
}
}
}
fn observes_binds_on(tier: EnforcementTier) -> bool {
matches!(
tier,
EnforcementTier::KernelFull | EnforcementTier::KernelSealed
)
}
#[cfg(target_os = "linux")]
fn bind_notification_supported() -> bool {
libseccomp::check_api(6, libseccomp::ScmpVersion::from((2, 5, 0))).unwrap_or(false)
}
#[cfg(not(target_os = "linux"))]
fn bind_notification_supported() -> bool {
false
}
pub(crate) const SESSION_END_KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
pub(crate) struct EndSessionOnDrop(ShellEnforcement);
impl EndSessionOnDrop {
pub(crate) fn new(enforcement: ShellEnforcement) -> Self {
Self(enforcement)
}
}
impl Drop for EndSessionOnDrop {
fn drop(&mut self) {
self.0.end_session();
}
}
const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
shared libraries — nothing writable), read-only access to the directories an allowlisted \
CPython or Perl interpreter reports on its own import path, read-only access to the host's TLS \
trust store when capabilities.network.allow is also non-empty, each host_paths entry in \
~/.murmur/config.yaml at its own access, never executable, read-only access to /etc/hosts and \
/etc/nsswitch.conf so localhost resolves, read and list access to the session's private /proc \
(commands run in the session's own PID namespace and its procfs lists only the session's processes \
and the kernel's world-readable files — no host process; where this host cannot mount one, /proc \
is the host's and every open beneath it is denied), and no other directory granted \
wholesale, plus a fixed device set \
every capsule gets unconditionally: /dev/null read+write (with the session's temporary and home \
folders and any read-write host_paths entry, the only writable paths outside the workdir), \
/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
raw block devices stay denied. The workdir's own grant also \
withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
and the forked shell child \
drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
denied, not absent: stat, access and readlink on an ungranted path still succeed.";
const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir, \
its temporary folder at /tmp, its home folder at $HOME and any read-write host_paths entry the only \
writable paths, each host_paths entry in ~/.murmur/config.yaml bound at its own path, a private \
/dev tmpfs carrying the OCI default device set), with Landlock \
and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
rather than denied. Commands run in the session's own PID namespace, and /proc is a bind of the \
session's private procfs: it lists only the session's own processes and the kernel's \
world-readable files, readable and listable, and no host process appears in it or can be \
signalled. Where this host cannot mount a private procfs (mur doctor's private proc line), /proc \
is a bind of the host's instead, Landlock refuses every open beneath it, and stat(2) on \
/proc/<pid> still shows that a host process exists, its owner and its start time.";
const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
rule needs no Landlock and so applies identically on this tier, but it has not been verified \
either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
tier too. The forked shell child still drops every Linux capability and \
sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
verified either. Treat shell subprocess isolation as experimental on this host.";
const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
permanent on this platform.";
pub(crate) fn tier_warning(
tier: EnforcementTier,
shell_allow_is_empty: bool,
) -> Option<(&'static str, &'static str)> {
if shell_allow_is_empty {
return None;
}
match tier {
EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
EnforcementTier::Unconfined => None,
}
}
const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn subprocesses, but \
this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
This is permanent on this platform, not a placeholder for a future slice.";
pub(crate) fn aggregate_bounding_warning(
is_linux: bool,
requires_bounding: bool,
has_scope: bool,
) -> Option<(&'static str, &'static str)> {
if is_linux || !requires_bounding || has_scope {
return None;
}
Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
}
pub(crate) fn warn_for_missing_aggregate_bounding(
workdir: &crate::workdir_fs::WorkdirRoot,
requires_bounding: bool,
has_scope: bool,
) {
let is_linux = cfg!(target_os = "linux");
if let Some((code, message)) =
aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
{
let link = security_warning_link(code);
crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
crate::agent::append_bootstrap_log(
workdir,
&format!("[capability-policy] warning[{code}]: {message} ({link})"),
);
}
}
pub(crate) fn warn_for_enforcement_tier(
tier: EnforcementTier,
workdir: &crate::workdir_fs::WorkdirRoot,
policy: &CapabilityPolicy,
) {
if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
let link = security_warning_link(code);
crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
crate::agent::append_bootstrap_log(
workdir,
&format!("[capability-policy] warning[{code}]: {message} ({link})"),
);
}
if tier == EnforcementTier::EnvironmentOnly {
crate::runtime::warn_if_bash_network_bypass(workdir, policy);
}
}
#[derive(Debug)]
pub(crate) enum SupervisorHandle {
Noop,
#[cfg(target_os = "linux")]
Linux {
diag_read: std::os::fd::OwnedFd,
bind_listener: Option<std::os::fd::OwnedFd>,
},
}
impl SupervisorHandle {
pub(crate) fn read_diagnostic(&self) -> Option<String> {
match self {
SupervisorHandle::Noop => None,
#[cfg(target_os = "linux")]
SupervisorHandle::Linux { diag_read, .. } => {
use std::os::fd::AsRawFd;
linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
}
}
}
pub(crate) fn hand_off_bind_listener(&self, enforcement: &ShellEnforcement) {
#[cfg(target_os = "linux")]
if let SupervisorHandle::Linux {
bind_listener: Some(socket),
..
} = self
{
match linux_enforce::receive_bind_listener(socket) {
Ok(listener) => enforcement.session_netns.observe_bind_listener(listener),
Err(error) => crate::runtime_err!(
"[capsule-runtime] warning: could not receive a command's bind listener: \
{error}; a bind in that command fails with ENOSYS"
),
}
}
#[cfg(not(target_os = "linux"))]
let _ = (self, enforcement);
}
}
#[cfg(test)]
thread_local! {
pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
}
#[cfg(test)]
fn forced_prepare_failure() -> Result<(), String> {
if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
}
Ok(())
}
#[cfg(all(test, target_os = "linux"))]
thread_local! {
pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
pub(crate) static FORCE_TMP_MAP_FAILURE: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
pub(crate) static FORCE_PRIVATE_PROC_FAILURE: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
}
#[cfg(all(test, target_os = "linux"))]
pub(crate) struct ForcePrivateProcFailureGuard;
#[cfg(all(test, target_os = "linux"))]
impl ForcePrivateProcFailureGuard {
pub(crate) fn new() -> Self {
FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.set(true));
Self
}
}
#[cfg(all(test, target_os = "linux"))]
impl Drop for ForcePrivateProcFailureGuard {
fn drop(&mut self) {
FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.set(false));
}
}
#[cfg(all(test, target_os = "linux"))]
pub(crate) struct ForceTmpMapFailureGuard;
#[cfg(all(test, target_os = "linux"))]
impl ForceTmpMapFailureGuard {
pub(crate) fn new() -> Self {
FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(true));
Self
}
}
#[cfg(all(test, target_os = "linux"))]
impl Drop for ForceTmpMapFailureGuard {
fn drop(&mut self) {
FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(false));
}
}
#[cfg(all(test, target_os = "linux"))]
pub(crate) struct ForceSealedRootFailureGuard;
#[cfg(all(test, target_os = "linux"))]
impl ForceSealedRootFailureGuard {
pub(crate) fn new() -> Self {
FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
Self
}
}
#[cfg(all(test, target_os = "linux"))]
impl Drop for ForceSealedRootFailureGuard {
fn drop(&mut self) {
FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
}
}
#[cfg(all(test, target_os = "linux"))]
pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
let base = tempfile::tempdir().unwrap().keep();
ShellEnforcement {
tier: EnforcementTier::KernelSealed,
..ShellEnforcement::environment_only().with_test_session_dirs()
}
.with_runtime_dir(test_runtime_dir(&base), None)
}
#[cfg(test)]
pub(crate) fn test_runtime_dir(base: &Path) -> crate::runtime_dir::RuntimeDirRoots {
let open = |sub: &str| {
let path = base.join(sub);
std::fs::create_dir_all(&path).unwrap();
crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
};
crate::runtime_dir::RuntimeDirRoots::new(
open(crate::runtime_dir::RUNTIME_SUBDIR),
open(crate::runtime_dir::SEALED_ETC_SUBDIR),
open(crate::runtime_dir::HARNESS_SUBDIR),
)
.with_records(open(crate::records_dir::RECORDS_DIR_NAME))
.with_temp(open(&format!(
"{}/{}",
crate::records_dir::RECORDS_DIR_NAME,
crate::temp_dir::SESSION_TEMP_DIR_NAME
)))
.with_home(open(&format!(
"{}/{}",
crate::records_dir::RECORDS_DIR_NAME,
crate::records_dir::HOME_SUBDIR
)))
}
#[cfg(unix)]
pub(crate) fn attach_process_limits(
command: &mut std::process::Command,
enforcement: &ShellEnforcement,
) {
use std::os::unix::process::CommandExt;
if enforcement.tier == EnforcementTier::Unconfined {
attach_unconfined_limits(command, enforcement);
return;
}
let limits = enforcement.resource_limits;
let nproc = enforcement.nproc_bound();
let cgroup_scope = enforcement.cgroup_scope.clone();
#[allow(unsafe_code)]
unsafe {
command.pre_exec(move || {
crate::resources::apply_hard_rlimits(&limits, nproc)?;
if let Some(scope) = cgroup_scope.as_ref() {
scope.join_current_process()?;
}
Ok(())
});
}
}
#[cfg(unix)]
fn attach_unconfined_limits(command: &mut std::process::Command, enforcement: &ShellEnforcement) {
use std::os::unix::process::CommandExt;
let declared = enforcement.declared_resources;
let nproc = enforcement.nproc_bound();
let cgroup_scope = enforcement.cgroup_scope.clone();
#[allow(unsafe_code)]
unsafe {
command.pre_exec(move || {
crate::resources::apply_declared_rlimits(&declared, nproc)?;
if let Some(scope) = cgroup_scope.as_ref() {
scope.join_current_process()?;
}
#[cfg(target_os = "linux")]
linux_enforce::mark_inherited_fds_cloexec()?;
Ok(())
});
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct WorkerGrantClosure {
pub(crate) read_write: Vec<PathBuf>,
pub(crate) runtime_dir: bool,
pub(crate) host_paths: bool,
}
impl ShellEnforcement {
pub(crate) fn for_guest_worker(
&self,
closure: &WorkerGrantClosure,
worker_image: &Path,
) -> ShellEnforcement {
let mut worker = self.clone();
worker.landlock_grants = LandlockGrant::non_listable_files(resolve_landlock_grants(&[
worker_image.to_path_buf(),
]));
worker.sealed_bind_dirs = Vec::new();
worker.sealed_need_mirrors = Vec::new();
worker.staged_runtime_dirs = Vec::new();
worker.pinned_program_dirs = Vec::new();
worker.host_path_read_only_binds = Vec::new();
worker.fd_hygiene_fallback = true;
worker.workdir_exec = false;
worker.bind_observation = None;
worker.cgroup_scope = None;
worker.workdir_guard = None;
worker.runtime_dir_owner = None;
worker.resource_limits.memory_bytes = u64::MAX;
worker.resource_limits.max_processes = u64::MAX;
worker.resource_limits.cpu_seconds = None;
worker.declared_resources.memory_bytes = None;
worker.declared_resources.max_processes = None;
worker.declared_resources.cpu_seconds = None;
worker.runtime_dir = self.runtime_dir.clone().and_then(|mut roots| {
roots.records = None;
if self.tier == EnforcementTier::KernelSealed {
Some(roots)
} else if closure.runtime_dir {
roots.temp = None;
roots.home = None;
Some(roots)
} else {
None
}
});
let mut host_paths = if closure.host_paths {
self.host_paths.clone()
} else {
Vec::new()
};
host_paths.extend(
closure
.read_write
.iter()
.map(|path| crate::host_paths::HostPath {
declared: path.display().to_string(),
path: path.clone(),
access: crate::host_paths::HostPathAccess::ReadWrite,
credential_locations: Vec::new(),
}),
);
worker.host_paths = host_paths;
worker
}
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
#[cfg(not(target_os = "linux"))]
pub(crate) fn prepare_enforcement(
command: &mut std::process::Command,
enforcement: &ShellEnforcement,
_accessible_workdir: &Path,
) -> Result<SupervisorHandle, String> {
#[cfg(test)]
forced_prepare_failure()?;
debug_assert!(
matches!(
enforcement.tier,
EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
),
"non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly or Unconfined"
);
attach_process_limits(command, enforcement);
Ok(SupervisorHandle::Noop)
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
pub(crate) fn prepare_enforcement(
command: &mut std::process::Command,
enforcement: &ShellEnforcement,
accessible_workdir: &Path,
) -> Result<SupervisorHandle, String> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::process::CommandExt;
#[cfg(test)]
forced_prepare_failure()?;
if matches!(
enforcement.tier,
EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
) {
attach_process_limits(command, enforcement);
return Ok(SupervisorHandle::Noop);
}
let temp_dir_fd = enforcement
.runtime_dir
.as_ref()
.and_then(|roots| roots.temp.as_ref())
.map(|temp| temp.path_fd().map(Arc::new))
.transpose()
.map_err(|error| {
format!("sandbox: failed to open the session's temporary folder: {error}")
})?;
let home_dir_fd = enforcement
.home_root()
.map(|home| home.path_fd().map(Arc::new))
.transpose()
.map_err(|error| format!("sandbox: failed to open the session's home folder: {error}"))?;
let host_path_fds: Vec<HostPathFd> = if matches!(
enforcement.tier,
EnforcementTier::KernelFull | EnforcementTier::KernelSealed
) {
enforcement
.host_paths
.iter()
.map(|host_path| {
linux_enforce::open_host_path(&host_path.path).map(|fd| HostPathFd {
fd: Arc::new(fd),
path: host_path.path.clone(),
read_write: host_path.access == crate::host_paths::HostPathAccess::ReadWrite,
})
})
.collect::<Result<_, _>>()?
} else {
Vec::new()
};
let sealed_sources = if enforcement.tier == EnforcementTier::KernelSealed {
Some(SealedSourceFds::open(
enforcement,
temp_dir_fd.as_ref(),
home_dir_fd.as_ref(),
)?)
} else {
None
};
let runtime_dir_fd = match &sealed_sources {
Some(sources) => Some(Arc::clone(&sources.runtime_dir)),
None if enforcement.tier == EnforcementTier::KernelFull => enforcement
.runtime_dir
.as_ref()
.map(|roots| roots.runtime.path_fd().map(Arc::new))
.transpose()
.map_err(|error| {
format!(
"sandbox: failed to open the runtime directory for Landlock scoping: {error}"
)
})?,
None => None,
};
let records_dir_fd = match &sealed_sources {
Some(sources) => sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
None if enforcement.tier == EnforcementTier::KernelFull => enforcement
.runtime_dir
.as_ref()
.and_then(|roots| roots.records.as_ref())
.map(|records| records.path_fd().map(Arc::new))
.transpose()
.map_err(|error| {
format!(
"sandbox: failed to open the records directory for Landlock scoping: {error}"
)
})?,
None => None,
};
let tmp_map_spec = match (&temp_dir_fd, enforcement.installed_temp_mapping()) {
(Some(folder_fd), crate::temp_dir::TempMapping::MountNamespace) => Some(
build_tmp_map_spec(enforcement, folder_fd, accessible_workdir)?,
),
_ => None,
};
let landlock_fds = if matches!(
enforcement.tier,
EnforcementTier::KernelFull | EnforcementTier::KernelSealed
) {
Some(linux_enforce::open_landlock_fds(
linux_enforce::LandlockFdRequest {
accessible_workdir,
landlock_grants: &enforcement.landlock_grants,
device_grants: &landlock_device_grants(enforcement.tier),
workdir_exec: enforcement.workdir_exec,
abi: enforcement.landlock_abi,
sealed: sealed_sources.as_ref(),
temp_dir_fd,
home_dir_fd,
runtime_dir_fd,
records_dir_fd,
host_path_fds: host_path_fds
.iter()
.map(|host_path| (Arc::clone(&host_path.fd), host_path.read_write))
.collect(),
},
)?)
} else {
None
};
let sealed_spec = match &sealed_sources {
Some(sources) => Some(build_sealed_root(
accessible_workdir,
sources,
&[
enforcement.pinned_program_dirs.as_slice(),
enforcement.sealed_bind_dirs.as_slice(),
enforcement.sealed_need_mirrors.as_slice(),
]
.concat(),
&enforcement.staged_runtime_dirs,
&host_path_fds,
&enforcement.host_path_read_only_binds,
)?),
None => None,
};
let mut diag_fds = [0i32; 2];
let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
if rc != 0 {
return Err(format!(
"sandbox: pipe2() for enforcement diagnostics failed: {}",
std::io::Error::last_os_error()
));
}
let (diag_read, diag_write) = unsafe {
(
OwnedFd::from_raw_fd(diag_fds[0]),
OwnedFd::from_raw_fd(diag_fds[1]),
)
};
let observed_held_ports = enforcement.observed_held_ports();
let netns = enforcement.session_netns.establish(
&enforcement.egress_tcp_ports,
crate::egress_proxy::EgressPolicy::new(
enforcement.network_allow_rules.clone(),
enforcement.network_allow_ips.clone(),
),
observed_held_ports,
)?;
let (bind_listener_socket, bind_listener_handoff) = match observed_held_ports {
Some(_) => {
let (parent, handoff) = linux_enforce::bind_listener_socketpair()?;
(Some(parent), Some(handoff))
}
None => (None, None),
};
let tier = enforcement.tier;
let private_proc =
enforcement
.installed_proc_view()
.is_private()
.then_some(linux_enforce::PrivateProc {
mount_namespace_entered: tmp_map_spec.is_some(),
});
let unix_sockets_allowed = enforcement.unix_sockets_allowed;
let fd_hygiene_fallback = enforcement.fd_hygiene_fallback;
let resource_limits = enforcement.resource_limits;
let nproc = enforcement.nproc_bound();
let cgroup_scope = enforcement.cgroup_scope.clone();
unsafe {
command.pre_exec(move || {
if let Err(error) = crate::resources::apply_nproc_bound(&resource_limits, nproc) {
linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
return Err(error);
}
if let Some(scope) = cgroup_scope.as_ref() {
if let Err(error) = scope.join_current_process() {
linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
return Err(error);
}
}
let (user_ns_fd, net_ns_fd) = netns.raw_fds();
if let Err(error) = linux_enforce::child_install_enforcement(
tier,
linux_enforce::SessionNamespaces {
user_ns_fd,
net_ns_fd,
pid_ns_fd: netns.pid_ns_fd(),
},
private_proc,
tmp_map_spec.as_ref(),
sealed_spec.as_ref(),
landlock_fds.as_ref(),
unix_sockets_allowed,
bind_listener_handoff.as_ref(),
fd_hygiene_fallback,
) {
linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
return Err(error);
}
if let Err(error) = crate::resources::apply_per_process_rlimits(&resource_limits) {
linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
return Err(error);
}
Ok(())
});
}
Ok(SupervisorHandle::Linux {
diag_read,
bind_listener: bind_listener_socket,
})
}
#[cfg(target_os = "linux")]
fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
match tier {
EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
.iter()
.map(|device| CapsuleDeviceGrant {
path: device.path,
writable: device.writable,
})
.collect(),
_ => CAPSULE_DEVICE_GRANTS.to_vec(),
}
}
#[cfg(target_os = "linux")]
fn build_tmp_map_spec(
enforcement: &ShellEnforcement,
folder_fd: &std::os::fd::OwnedFd,
accessible_workdir: &Path,
) -> Result<crate::temp_dir::TmpMapSpec, String> {
let folder = enforcement.temp_dir_path().ok_or_else(|| {
format!(
"{} this enforcement holds none",
crate::temp_dir::TMP_MAP_FAILURE_PREFIX
)
})?;
let identity = crate::workdir_fs::fd_identity(folder_fd).map_err(|errno| {
format!(
"{} fstat of the temporary folder failed: {errno}",
crate::temp_dir::TMP_MAP_FAILURE_PREFIX
)
})?;
let canonical_tmp = crate::temp_dir::canonical_tmp();
let read_only = [
enforcement.runtime_dir_path(),
enforcement.records_dir_path(),
]
.into_iter()
.flatten()
.chain(
enforcement
.landlock_grants
.iter()
.map(|grant| grant.path.as_path()),
);
let reexposed = crate::temp_dir::reexposed_paths(
&canonical_tmp,
std::iter::once(accessible_workdir).chain(enforcement.home_dir_path()),
read_only,
);
crate::temp_dir::TmpMapSpec::new(
folder,
identity,
&canonical_tmp,
&reexposed,
accessible_workdir,
)
}
#[cfg(target_os = "linux")]
fn ensure_sealed_identity_files(
sealed_etc_root: &crate::workdir_fs::WorkdirRoot,
home_root: &crate::workdir_fs::WorkdirRoot,
) -> Result<Vec<PathBuf>, String> {
let (uid, gid) = home_root.owner().map_err(|error| {
format!(
"sealed: failed to stat the session's home folder for the capsule's uid/gid: {error}"
)
})?;
let home = home_root.path().to_string_lossy().into_owned();
let identity = crate::sealed::SealedAccountIdentity {
uid,
gid,
home: &home,
};
let mut written = Vec::new();
for entry in crate::sealed::SEALED_ETC_PATHS {
let Some(file) = entry.synthetic else {
continue;
};
sealed_etc_root
.overwrite_in_place(
file.file_name(),
file.render(&identity)?.as_bytes(),
crate::workdir_fs::DEFAULT_FILE_MODE,
)
.map_err(|error| {
format!(
"sealed: failed to write the synthetic {}: {error}",
entry.path
)
})?;
written.push(crate::sealed::synthetic_etc_source(
sealed_etc_root.path(),
file,
));
}
Ok(written)
}
#[cfg(target_os = "linux")]
pub(crate) struct HostPathFd {
fd: Arc<std::os::fd::OwnedFd>,
path: PathBuf,
read_write: bool,
}
#[cfg(target_os = "linux")]
pub(crate) struct SealedSourceFds {
tmp_store: Arc<std::os::fd::OwnedFd>,
tmp_store_path: PathBuf,
home_dir: (Arc<std::os::fd::OwnedFd>, PathBuf),
runtime_dir: Arc<std::os::fd::OwnedFd>,
runtime_dir_path: PathBuf,
records_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
etc_dir: PathBuf,
identity_files: Vec<PathBuf>,
}
#[cfg(target_os = "linux")]
impl SealedSourceFds {
fn open(
enforcement: &ShellEnforcement,
temp_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
home_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
) -> Result<Self, String> {
let roots = enforcement.runtime_dir.as_ref().ok_or_else(|| {
"sealed: a composed root needs the session's runtime directory, and this \
enforcement holds none"
.to_string()
})?;
let (Some(temp), Some(tmp_store)) = (roots.temp.as_ref(), temp_dir_fd) else {
return Err(
"sealed: a composed root binds the session's temporary folder at /tmp, \
and this enforcement holds none"
.to_string(),
);
};
let tmp_store = Arc::clone(tmp_store);
let (Some(home), Some(home_fd)) = (roots.home.as_ref(), home_dir_fd) else {
return Err(crate::shell::NO_HOME_FOLDER_REFUSAL.to_string());
};
let home_dir = (Arc::clone(home_fd), home.path().to_path_buf());
let identity_files = ensure_sealed_identity_files(&roots.sealed_etc, home)?;
let runtime_dir = Arc::new(roots.runtime.path_fd().map_err(|error| {
format!("sealed: failed to open the runtime directory for its bind: {error}")
})?);
let records_dir = roots
.records
.as_ref()
.map(|records| {
records
.path_fd()
.map(|fd| (Arc::new(fd), records.path().to_path_buf()))
.map_err(|error| {
format!(
"sealed: failed to open the records directory for its bind: {error}"
)
})
})
.transpose()?;
Ok(Self {
tmp_store,
tmp_store_path: temp.path().to_path_buf(),
home_dir,
runtime_dir,
runtime_dir_path: roots.runtime.path().to_path_buf(),
records_dir,
etc_dir: roots.sealed_etc.path().to_path_buf(),
identity_files,
})
}
fn identity_of(fd: &std::os::fd::OwnedFd) -> Result<(u64, u64), String> {
crate::workdir_fs::fd_identity(fd)
.map_err(|errno| format!("sealed: fstat of a bind source failed: {errno}"))
}
}
#[cfg(target_os = "linux")]
fn build_sealed_root(
accessible_workdir: &Path,
sources: &SealedSourceFds,
extra_read_only: &[PathBuf],
staged_runtime_read_only: &[PathBuf],
host_paths: &[HostPathFd],
host_path_read_only: &[PathBuf],
) -> Result<crate::sealed::SealedRootSpec, String> {
use crate::sealed;
if !accessible_workdir.is_absolute() {
return Err(format!(
"sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
its own absolute path",
accessible_workdir.display()
));
}
let host_path_binds = host_paths
.iter()
.map(|host_path| {
SealedSourceFds::identity_of(&host_path.fd).map(|identity| sealed::HostPathBind {
path: host_path.path.clone(),
identity,
read_only: !host_path.read_write,
})
})
.collect::<Result<Vec<_>, _>>()?;
let host_path_dirs: Vec<PathBuf> = host_paths
.iter()
.map(|host_path| host_path.path.clone())
.collect();
let base = sealed::choose_root_base(
accessible_workdir,
&host_path_dirs,
sealed::SEALED_ROOT_BASE_CANDIDATES,
|path| path.is_dir(),
)
.ok_or_else(|| {
format!(
"sealed: no usable base directory for the composed root; none of {:?} exists outside \
the session workdir's own path and every host path's",
sealed::SEALED_ROOT_BASE_CANDIDATES
)
})?;
let plan = sealed::plan_composed_root(
accessible_workdir,
&sealed::SealedBindSources {
tmp_store: (
&sources.tmp_store_path,
SealedSourceFds::identity_of(&sources.tmp_store)?,
),
etc_dir: &sources.etc_dir,
runtime_dir: Some((
&sources.runtime_dir_path,
SealedSourceFds::identity_of(&sources.runtime_dir)?,
)),
records_dir: sources
.records_dir
.as_ref()
.map(|(fd, path)| {
SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
})
.transpose()?,
home_dir: Some((
&sources.home_dir.1,
SealedSourceFds::identity_of(&sources.home_dir.0)?,
)),
host_paths: &host_path_binds,
host_path_read_only,
},
&base,
extra_read_only,
staged_runtime_read_only,
&sealed::RealHostLayout,
);
sealed::build_sealed_root_spec(&plan)
}
#[cfg_attr(target_os = "linux", allow(unsafe_code))]
#[cfg(target_os = "linux")]
mod linux_enforce {
use std::io;
use std::os::fd::{AsFd, OwnedFd, RawFd};
use std::path::Path;
use landlock::{make_bitflags, AccessFs, BitFlags};
use super::{EnforcementTier, LandlockGrant};
const MAX_DIAG_LEN: usize = 1024;
pub(super) struct OpenLandlockGrant {
fd: OwnedFd,
list_dir: bool,
executable: bool,
}
pub(super) struct OpenDeviceGrant {
fd: OwnedFd,
writable: bool,
}
pub(super) struct LandlockChildFds {
workdir_fd: OwnedFd,
workdir_exec: bool,
abi: Option<u8>,
temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
sealed_identity_fds: Vec<OwnedFd>,
runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
grants: Vec<OpenLandlockGrant>,
devices: Vec<OpenDeviceGrant>,
}
impl LandlockChildFds {
#[cfg(test)]
pub(super) fn temp_dir_fd(&self) -> Option<&OwnedFd> {
self.temp_dir_fd.as_deref()
}
#[cfg(test)]
pub(super) fn home_dir_fd(&self) -> Option<&OwnedFd> {
self.home_dir_fd.as_deref()
}
#[cfg(test)]
pub(super) fn runtime_dir_fd(&self) -> Option<&OwnedFd> {
self.runtime_dir_fd.as_deref()
}
#[cfg(test)]
pub(super) fn records_dir_fd(&self) -> Option<&OwnedFd> {
self.records_dir_fd.as_deref()
}
#[cfg(test)]
pub(super) fn sealed_identity_fd_count(&self) -> usize {
self.sealed_identity_fds.len()
}
}
pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
WriteFile
| ReadFile
| ReadDir
| RemoveDir
| RemoveFile
| MakeDir
| MakeReg
| MakeFifo
| MakeSym
| Refer
| Truncate
});
pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
if workdir_exec {
WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
} else {
WORKDIR_ACCESS_RIGHTS_NO_EXEC
}
}
pub(super) fn host_path_access_rights(read_write: bool) -> BitFlags<AccessFs> {
if read_write {
workdir_access_rights(false)
} else {
AccessFs::ReadFile | AccessFs::ReadDir
}
}
pub(super) fn kernel_landlock_abi() -> Option<u8> {
const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1;
let version = unsafe {
libc::syscall(
libc::SYS_landlock_create_ruleset,
std::ptr::null::<libc::c_void>(),
0usize,
LANDLOCK_CREATE_RULESET_VERSION,
)
};
if version <= 0 {
return None;
}
Some(u8::try_from(version).unwrap_or(u8::MAX))
}
pub(super) fn probe_landlock_full_access() -> Option<bool> {
use landlock::{
Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
RulesetCreatedAttr, RulesetStatus, ABI,
};
const PROBE_FULLY_ENFORCED: i32 = 0;
const PROBE_PARTIAL: i32 = 1;
const PROBE_FAILED: i32 = 2;
let abi = ABI::V1;
let access_all = AccessFs::from_all(abi);
let root_fd = PathFd::new("/").ok()?;
let ruleset = Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(access_all)
.ok()?
.create()
.ok()?
.add_rule(PathBeneath::new(root_fd, access_all))
.ok()?;
let pid = unsafe { libc::fork() };
if pid < 0 {
return None;
}
if pid == 0 {
let code = match ruleset.restrict_self() {
Ok(status) => {
if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
PROBE_FULLY_ENFORCED
} else {
PROBE_PARTIAL
}
}
Err(_) => PROBE_FAILED,
};
unsafe { libc::_exit(code) }
}
let mut wait_status: libc::c_int = 0;
let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
if waited < 0 || !libc::WIFEXITED(wait_status) {
return None;
}
match libc::WEXITSTATUS(wait_status) {
PROBE_FULLY_ENFORCED => Some(true),
PROBE_PARTIAL => Some(false),
_ => None,
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn child_install_enforcement(
tier: EnforcementTier,
namespaces: SessionNamespaces,
private_proc: Option<PrivateProc>,
tmp_map_spec: Option<&crate::temp_dir::TmpMapSpec>,
sealed_spec: Option<&crate::sealed::SealedRootSpec>,
landlock_fds: Option<&LandlockChildFds>,
unix_sockets_allowed: bool,
bind_listener: Option<&BindListenerHandoff>,
fd_hygiene_fallback: bool,
) -> io::Result<()> {
unsafe {
crate::network_namespace::join_session_netns(
namespaces.user_ns_fd,
namespaces.net_ns_fd,
)
}
.map_err(|error| {
io::Error::other(format!(
"sandbox: joining this session's network namespace failed: {error}"
))
})?;
unsafe { enter_session_pid_namespace(namespaces.pid_ns_fd) }?;
if let Some(spec) = tmp_map_spec {
#[cfg(test)]
if super::FORCE_TMP_MAP_FAILURE.with(|flag| flag.get()) {
return Err(io::Error::other(format!(
"{} (forced by test seam)",
spec.describe(crate::temp_dir::TmpMapFailure::forced(libc::EPERM))
)));
}
unsafe { crate::temp_dir::map_private_tmp(spec) }
.map_err(|failure| io::Error::other(spec.describe(failure)))?;
}
let proc_fd = match private_proc {
None => None,
Some(private_proc) => {
#[cfg(test)]
if super::FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.get()) {
return Err(io::Error::other(format!(
"{} (forced by test seam)",
crate::proc_view::describe_mount_failure(
crate::proc_view::ProcMountStep::MountProc,
libc::EPERM
)
)));
}
let fd = unsafe {
crate::proc_view::mount_private_proc(!private_proc.mount_namespace_entered)
}
.map_err(|(step, errno)| {
io::Error::other(crate::proc_view::describe_mount_failure(step, errno))
})?;
Some(unsafe { <OwnedFd as std::os::fd::FromRawFd>::from_raw_fd(fd) })
}
};
if tier == EnforcementTier::KernelSealed {
if let Some(spec) = sealed_spec {
#[cfg(test)]
if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
return Err(io::Error::other(format!(
"{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
crate::sealed::SEALED_ROOT_FAILURE_PREFIX
)));
}
crate::sealed::construct_composed_root(spec)
.map_err(|failure| io::Error::other(spec.describe(failure)))?;
}
}
match mark_inherited_fds_cloexec() {
Err(error)
if fd_hygiene_fallback
&& matches!(error.raw_os_error(), Some(libc::ENOSYS | libc::EINVAL)) =>
{
mark_inherited_fds_cloexec_one_by_one()?;
}
result => result?,
}
drop_all_capabilities()?;
install_seccomp_filter(unix_sockets_allowed, bind_listener)?;
if matches!(
tier,
EnforcementTier::KernelFull | EnforcementTier::KernelSealed
) {
if let Some(fds) = landlock_fds {
apply_landlock_scope(fds, proc_fd.as_ref()).map_err(io::Error::other)?;
}
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub(super) struct SessionNamespaces {
pub(super) user_ns_fd: RawFd,
pub(super) net_ns_fd: RawFd,
pub(super) pid_ns_fd: RawFd,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct PrivateProc {
pub(super) mount_namespace_entered: bool,
}
const RELAY_GONE_EXIT: i32 = 125;
unsafe fn enter_session_pid_namespace(pid_ns_fd: RawFd) -> io::Result<()> {
unsafe {
if libc::setns(pid_ns_fd, libc::CLONE_NEWPID) != 0 {
return Err(io::Error::other(format!(
"sandbox: joining this session's PID namespace failed: {}",
io::Error::last_os_error()
)));
}
let mut default: libc::sigaction = std::mem::zeroed();
default.sa_sigaction = libc::SIG_DFL;
libc::sigaction(libc::SIGCHLD, &default, std::ptr::null_mut());
let command = libc::fork();
if command < 0 {
return Err(io::Error::other(format!(
"sandbox: forking the command into this session's PID namespace failed: {}",
io::Error::last_os_error()
)));
}
if command > 0 {
run_relay(command);
}
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
return Err(io::Error::other(format!(
"sandbox: prctl(PR_SET_PDEATHSIG) in the command failed: {}",
io::Error::last_os_error()
)));
}
if libc::getppid() != 0 {
libc::_exit(RELAY_GONE_EXIT);
}
}
Ok(())
}
unsafe fn run_relay(command: libc::pid_t) -> ! {
unsafe {
let mut all: libc::sigset_t = std::mem::zeroed();
libc::sigfillset(&mut all);
libc::sigprocmask(libc::SIG_SETMASK, &all, std::ptr::null_mut());
crate::cgroup::close_range(0, libc::c_uint::MAX);
let mut status: libc::c_int = 0;
loop {
let waited = libc::waitpid(command, &mut status, 0);
if waited == command {
break;
}
if waited < 0 && *libc::__errno_location() == libc::EINTR {
continue;
}
libc::_exit(1);
}
if libc::WIFEXITED(status) {
libc::_exit(libc::WEXITSTATUS(status));
}
if libc::WIFSIGNALED(status) {
let signal = libc::WTERMSIG(status);
let mut default: libc::sigaction = std::mem::zeroed();
default.sa_sigaction = libc::SIG_DFL;
libc::sigaction(signal, &default, std::ptr::null_mut());
let mut only: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut only);
libc::sigaddset(&mut only, signal);
libc::sigprocmask(libc::SIG_UNBLOCK, &only, std::ptr::null_mut());
libc::kill(libc::getpid(), signal);
libc::_exit(128 + signal);
}
libc::_exit(1)
}
}
pub(super) fn mark_inherited_fds_cloexec_one_by_one() -> io::Result<()> {
unsafe {
let mut limit: libc::rlimit = std::mem::zeroed();
if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 {
return Err(io::Error::last_os_error());
}
let end = libc::c_int::try_from(limit.rlim_cur).unwrap_or(libc::c_int::MAX);
let mut fd = super::FD_HYGIENE_FIRST_FD as libc::c_int;
while fd < end {
let flags = libc::fcntl(fd, libc::F_GETFD);
if flags >= 0
&& flags & libc::FD_CLOEXEC == 0
&& libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) != 0
{
return Err(io::Error::last_os_error());
}
fd += 1;
}
}
Ok(())
}
pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
let rc = unsafe {
libc::syscall(
libc::SYS_close_range,
super::FD_HYGIENE_FIRST_FD,
libc::c_uint::MAX,
libc::CLOSE_RANGE_CLOEXEC,
)
};
if rc != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
const CAP_LAST_CAP_FALLBACK: u32 = 63;
#[repr(C)]
#[allow(dead_code)]
struct CapUserHeader {
version: u32,
pid: libc::c_int,
}
#[repr(C)]
#[derive(Clone, Copy)]
#[allow(dead_code)]
struct CapUserData {
effective: u32,
permitted: u32,
inheritable: u32,
}
fn drop_all_capabilities() -> io::Result<()> {
drop_capability_bounding_set()?;
clear_capability_sets()?;
set_no_new_privs()
}
#[allow(unsafe_code)]
fn drop_capability_bounding_set() -> io::Result<()> {
let zero: libc::c_ulong = 0;
for cap in 0..=kernel_last_cap() {
let cap_arg = libc::c_ulong::from(cap);
let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
if rc == 0 {
continue;
}
let error = io::Error::last_os_error();
match error.raw_os_error() {
Some(libc::EINVAL) | Some(libc::EPERM) => continue,
_ => return Err(error),
}
}
Ok(())
}
#[allow(unsafe_code)]
fn clear_capability_sets() -> io::Result<()> {
let header = CapUserHeader {
version: LINUX_CAPABILITY_VERSION_3,
pid: 0,
};
let data = [CapUserData {
effective: 0,
permitted: 0,
inheritable: 0,
}; 2];
let rc = unsafe {
libc::syscall(
libc::SYS_capset,
&header as *const CapUserHeader,
data.as_ptr(),
)
};
if rc != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[allow(unsafe_code)]
fn set_no_new_privs() -> io::Result<()> {
#[cfg(test)]
if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
return Err(io::Error::other(
"sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
));
}
let enable: libc::c_ulong = 1;
let zero: libc::c_ulong = 0;
let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
if rc != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[allow(unsafe_code)]
fn kernel_last_cap() -> u32 {
const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
let fd = unsafe {
libc::open(
PATH.as_ptr() as *const libc::c_char,
libc::O_RDONLY | libc::O_CLOEXEC,
)
};
if fd < 0 {
return CAP_LAST_CAP_FALLBACK;
}
let mut buf = [0u8; 16];
let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
unsafe { libc::close(fd) };
if read <= 0 {
return CAP_LAST_CAP_FALLBACK;
}
let parsed = std::str::from_utf8(&buf[..read as usize])
.ok()
.and_then(|text| text.trim().parse::<u32>().ok());
match parsed {
Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
_ => CAP_LAST_CAP_FALLBACK,
}
}
const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
("landlock_create_ruleset", 444),
("landlock_add_rule", 445),
("landlock_restrict_self", 446),
];
fn build_seccomp_filter(
unix_sockets_allowed: bool,
observe_binds: bool,
) -> io::Result<libseccomp::ScmpFilterContext> {
let mut filter =
libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
.map_err(to_io_err)?;
let _ = filter.set_ctl_log(true);
let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
for domain in super::denied_socket_domains(unix_sockets_allowed) {
filter
.add_rule_conditional(
libseccomp::ScmpAction::Errno(libc::EACCES),
socket_syscall,
&[libseccomp::ScmpArgCompare::new(
0,
libseccomp::ScmpCompareOp::Equal,
domain as u64,
)],
)
.map_err(to_io_err)?;
}
for domain in super::allowed_socket_domains(unix_sockets_allowed) {
filter
.add_rule_conditional(
libseccomp::ScmpAction::Allow,
socket_syscall,
&[libseccomp::ScmpArgCompare::new(
0,
libseccomp::ScmpCompareOp::Equal,
domain as u64,
)],
)
.map_err(to_io_err)?;
}
for name in super::SECCOMP_SYSCALL_ALLOWLIST {
if observe_binds && *name == "bind" {
continue;
}
if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
}
}
if observe_binds {
let bind = libseccomp::ScmpSyscall::from_name("bind").map_err(to_io_err)?;
filter
.add_rule(libseccomp::ScmpAction::Notify, bind)
.map_err(to_io_err)?;
}
for (name, fallback_nr) in LANDLOCK_SYSCALLS {
let syscall = libseccomp::ScmpSyscall::from_name(name)
.unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
filter
.add_rule(libseccomp::ScmpAction::Allow, syscall)
.map_err(to_io_err)?;
}
Ok(filter)
}
fn install_seccomp_filter(
unix_sockets_allowed: bool,
bind_listener: Option<&BindListenerHandoff>,
) -> io::Result<()> {
let filter = build_seccomp_filter(unix_sockets_allowed, bind_listener.is_some())?;
filter.load().map_err(to_io_err)?;
let Some(handoff) = bind_listener else {
return Ok(());
};
let listener = filter.get_notify_fd().map_err(|error| {
io::Error::other(format!(
"sandbox: the seccomp filter observing bind left no listener: {error}"
))
})?;
let sent = handoff.send(listener);
unsafe { libc::close(listener) };
sent.map_err(|error| {
io::Error::other(format!(
"sandbox: sending the seccomp listener observing bind failed: {error}"
))
})
}
pub(super) struct BindListenerHandoff {
socket: OwnedFd,
}
impl BindListenerHandoff {
fn send(&self, listener: RawFd) -> io::Result<()> {
use std::os::fd::AsRawFd;
unsafe { crate::network_namespace::send_fds(self.socket.as_raw_fd(), &[listener]) }
.map_err(|()| io::Error::last_os_error())
}
}
pub(super) fn bind_listener_socketpair() -> Result<(OwnedFd, BindListenerHandoff), String> {
use std::os::fd::FromRawFd;
let mut fds = [0 as RawFd; 2];
let rc = unsafe {
libc::socketpair(
libc::AF_UNIX,
libc::SOCK_SEQPACKET | libc::SOCK_CLOEXEC,
0,
fds.as_mut_ptr(),
)
};
if rc != 0 {
return Err(format!(
"sandbox: socketpair() for the bind listener failed: {}",
io::Error::last_os_error()
));
}
let (parent, child) =
unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
Ok((parent, BindListenerHandoff { socket: child }))
}
pub(super) fn receive_bind_listener(socket: &OwnedFd) -> io::Result<OwnedFd> {
use std::os::fd::{AsRawFd, FromRawFd};
let mut payload = [0u8; 1];
let mut iov = libc::iovec {
iov_base: payload.as_mut_ptr().cast(),
iov_len: payload.len(),
};
#[repr(C, align(8))]
struct Control([u8; 64]);
let mut cmsg = Control([0; 64]);
unsafe {
let mut message: libc::msghdr = std::mem::zeroed();
message.msg_iov = &mut iov;
message.msg_iovlen = 1;
message.msg_control = cmsg.0.as_mut_ptr().cast();
message.msg_controllen = cmsg.0.len() as _;
let received = libc::recvmsg(
socket.as_raw_fd(),
&mut message,
libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC,
);
if received < 0 {
return Err(io::Error::last_os_error());
}
let header = libc::CMSG_FIRSTHDR(&message);
if header.is_null()
|| (*header).cmsg_level != libc::SOL_SOCKET
|| (*header).cmsg_type != libc::SCM_RIGHTS
{
return Err(io::Error::other(
"the message carried no SCM_RIGHTS descriptor",
));
}
let fd = std::ptr::read_unaligned(libc::CMSG_DATA(header).cast::<RawFd>());
Ok(OwnedFd::from_raw_fd(fd))
}
}
fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
io::Error::other(error.to_string())
}
#[cfg(test)]
const SOCK_FILTER_BYTES: usize = 8;
#[cfg(test)]
fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
where
F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
{
use std::io::{Read, Seek, SeekFrom};
use std::os::fd::FromRawFd;
let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
if raw < 0 {
return Err(io::Error::last_os_error());
}
let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
export(&memfd).map_err(to_io_err)?;
let mut file = std::fs::File::from(memfd);
file.seek(SeekFrom::Start(0))?;
let mut exported = Vec::new();
file.read_to_end(&mut exported)?;
Ok(exported)
}
#[cfg(test)]
fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
let filter = build_seccomp_filter(unix_sockets_allowed, false)?;
let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
Ok(exported.len() / SOCK_FILTER_BYTES)
}
#[cfg(test)]
fn seccomp_filter_pfc(unix_sockets_allowed: bool, observe_binds: bool) -> io::Result<String> {
let filter = build_seccomp_filter(unix_sockets_allowed, observe_binds)?;
let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
String::from_utf8(exported).map_err(to_io_err)
}
fn apply_landlock_scope(
fds: &LandlockChildFds,
proc_fd: Option<&OwnedFd>,
) -> Result<(), String> {
#[cfg(test)]
if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
}
use landlock::{
Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
RulesetCreatedAttr, ABI,
};
let abi = fds.abi.map_or(ABI::V1, |abi| ABI::from(i32::from(abi)));
let handled = AccessFs::from_all(abi);
let read_execute = AccessFs::Execute | AccessFs::ReadFile;
let read_execute_list = read_execute | AccessFs::ReadDir;
let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
let device_read_write = device_read | AccessFs::WriteFile | AccessFs::Truncate;
let writable_rights = workdir_access_rights(fds.workdir_exec) & handled;
let mut ruleset = Ruleset::default()
.set_compatibility(CompatLevel::HardRequirement)
.handle_access(handled)
.map_err(|error| format!("landlock: handle_access failed: {error}"))?
.create()
.map_err(|error| format!("landlock: ruleset create failed: {error}"))?
.add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
.map_err(|error| format!("landlock: add_rule failed: {error}"))?;
if let Some(proc_fd) = proc_fd {
ruleset = ruleset
.add_rule(PathBeneath::new(proc_fd.as_fd(), read_list & handled))
.map_err(|error| {
format!("landlock: add_rule for the session's private /proc failed: {error}")
})?;
}
if let Some(temp_dir_fd) = &fds.temp_dir_fd {
ruleset = ruleset
.add_rule(PathBeneath::new(temp_dir_fd.as_fd(), writable_rights))
.map_err(|error| {
format!("landlock: add_rule for the session's temporary folder failed: {error}")
})?;
}
if let Some(home_dir_fd) = &fds.home_dir_fd {
ruleset = ruleset
.add_rule(PathBeneath::new(home_dir_fd.as_fd(), writable_rights))
.map_err(|error| {
format!("landlock: add_rule for the session's home folder failed: {error}")
})?;
}
for (host_path_fd, read_write) in &fds.host_path_fds {
ruleset = ruleset
.add_rule(PathBeneath::new(
host_path_fd.as_fd(),
host_path_access_rights(*read_write) & handled,
))
.map_err(|error| format!("landlock: add_rule for a host path failed: {error}"))?;
}
if let Some(runtime_dir_fd) = &fds.runtime_dir_fd {
ruleset = ruleset
.add_rule(PathBeneath::new(
runtime_dir_fd.as_fd(),
read_list & handled,
))
.map_err(|error| {
format!("landlock: add_rule for the runtime directory failed: {error}")
})?;
}
if let Some(records_dir_fd) = &fds.records_dir_fd {
ruleset = ruleset
.add_rule(PathBeneath::new(
records_dir_fd.as_fd(),
read_list & handled,
))
.map_err(|error| {
format!("landlock: add_rule for the records directory failed: {error}")
})?;
}
for identity_fd in &fds.sealed_identity_fds {
ruleset = ruleset
.add_rule(PathBeneath::new(identity_fd.as_fd(), read_only & handled))
.map_err(|error| {
format!("landlock: add_rule for a synthetic /etc file failed: {error}")
})?;
}
for grant in &fds.grants {
let access = match (grant.executable, grant.list_dir) {
(true, true) => read_execute_list,
(true, false) => read_execute,
(false, true) => read_list,
(false, false) => read_only,
};
ruleset = ruleset
.add_rule(PathBeneath::new(grant.fd.as_fd(), access & handled))
.map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
}
for device in &fds.devices {
let access = if device.writable {
device_read_write
} else {
device_read
};
ruleset = ruleset
.add_rule(PathBeneath::new(device.fd.as_fd(), access & handled))
.map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
}
ruleset
.restrict_self()
.map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
Ok(())
}
pub(super) struct LandlockFdRequest<'a> {
pub(super) accessible_workdir: &'a Path,
pub(super) landlock_grants: &'a [LandlockGrant],
pub(super) device_grants: &'a [super::CapsuleDeviceGrant],
pub(super) workdir_exec: bool,
pub(super) abi: Option<u8>,
pub(super) sealed: Option<&'a super::SealedSourceFds>,
pub(super) temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
pub(super) home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
pub(super) runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
pub(super) records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
pub(super) host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
}
impl<'a> LandlockFdRequest<'a> {
#[cfg(test)]
pub(super) fn workdir_only(accessible_workdir: &'a Path) -> Self {
Self {
accessible_workdir,
landlock_grants: &[],
device_grants: &[],
workdir_exec: false,
abi: None,
sealed: None,
temp_dir_fd: None,
home_dir_fd: None,
runtime_dir_fd: None,
records_dir_fd: None,
host_path_fds: Vec::new(),
}
}
}
pub(super) fn open_landlock_fds(
request: LandlockFdRequest<'_>,
) -> Result<LandlockChildFds, String> {
let LandlockFdRequest {
accessible_workdir,
landlock_grants,
device_grants,
workdir_exec,
abi,
sealed,
temp_dir_fd,
home_dir_fd,
runtime_dir_fd,
records_dir_fd,
host_path_fds,
} = request;
let workdir_fd = open_o_path(accessible_workdir)
.map(OwnedFd::from)
.map_err(|error| {
format!(
"sandbox: failed to open workdir {} for Landlock scoping: {error}",
accessible_workdir.display()
)
})?;
let mut sealed_identity_fds = Vec::new();
for path in sealed
.map(|sources| sources.identity_files.as_slice())
.unwrap_or_default()
{
let file = open_o_path(path).map_err(|error| {
format!(
"sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
{error}",
path.display()
)
})?;
sealed_identity_fds.push(OwnedFd::from(file));
}
let mut grants = Vec::with_capacity(landlock_grants.len());
for grant in landlock_grants {
match open_o_path(&grant.path) {
Ok(file) => grants.push(OpenLandlockGrant {
list_dir: grant.list_dir
&& file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
fd: OwnedFd::from(file),
executable: grant.executable,
}),
Err(_) => continue,
}
}
let mut devices = Vec::with_capacity(device_grants.len());
for device in device_grants {
match open_o_path(Path::new(device.path)) {
Ok(file) => devices.push(OpenDeviceGrant {
fd: OwnedFd::from(file),
writable: device.writable,
}),
Err(_) => continue,
}
}
Ok(LandlockChildFds {
workdir_fd,
workdir_exec,
abi,
temp_dir_fd,
home_dir_fd,
sealed_identity_fds,
runtime_dir_fd,
records_dir_fd,
host_path_fds,
grants,
devices,
})
}
fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_PATH | libc::O_CLOEXEC)
.open(path)
}
pub(super) fn open_host_path(path: &Path) -> Result<OwnedFd, String> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
.open(path)
.map(OwnedFd::from)
.map_err(|error| {
format!(
"sandbox: failed to open host path {} (host_paths in ~/.murmur/config.yaml): \
{error}",
path.display()
)
})
}
#[allow(unsafe_code)]
pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
let bytes = message.as_bytes();
let len = bytes.len().min(MAX_DIAG_LEN);
let mut written = 0;
while written < len {
let n = unsafe {
libc::write(
fd,
bytes[written..len].as_ptr() as *const libc::c_void,
len - written,
)
};
if n < 0 {
if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
continue;
}
break;
}
if n == 0 {
break;
}
written += n as usize;
}
}
#[allow(unsafe_code)]
pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
let mut buf = Vec::new();
let mut chunk = [0u8; 256];
loop {
let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
if n < 0 {
if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
continue;
}
break;
}
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n as usize]);
if buf.len() >= MAX_DIAG_LEN {
break;
}
}
if buf.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&buf).into_owned())
}
}
#[cfg(test)]
#[allow(clippy::print_stdout, clippy::print_stderr)]
mod seccomp_budget {
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use super::{
build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
};
use crate::sandbox::{
allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
};
const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
const PROBE_ERRNO_UNKNOWN: i32 = 254;
fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
.map_err(to_io_err)?;
filter.set_api_sysrawrc(true).map_err(to_io_err)?;
let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
for value in 0..rules {
filter
.add_rule_conditional(
libseccomp::ScmpAction::Errno(libc::EPERM),
syscall,
&[libseccomp::ScmpArgCompare::new(
0,
libseccomp::ScmpCompareOp::Equal,
value as u64,
)],
)
.map_err(to_io_err)?;
}
let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
Ok((filter, exported.len() / SOCK_FILTER_BYTES))
}
fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
let mut under = 1;
let mut over = 8;
while probe_filter(over)?.1 <= instructions {
under = over;
over *= 2;
assert!(
over <= 1 << 20,
"no probe filter of up to {over} rules exceeded {instructions} instructions — \
the probe is not growing the program the way this search assumes"
);
}
while over - under > 1 {
let mid = under + (over - under) / 2;
if probe_filter(mid)?.1 <= instructions {
under = mid;
} else {
over = mid;
}
}
Ok(over)
}
fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
use libseccomp::error::SeccompErrno;
if let Some(raw) = error.sysrawrc() {
return -raw;
}
match error.errno() {
Some(SeccompErrno::EACCES) => libc::EACCES,
Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
Some(SeccompErrno::EINVAL) => libc::EINVAL,
Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
_ => PROBE_ERRNO_UNKNOWN,
}
}
fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
let pid = unsafe { libc::fork() };
assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
if pid == 0 {
let code = match filter.load() {
Ok(()) => 0,
Err(error) => refusal_errno(&error),
};
unsafe { libc::_exit(code) }
}
let mut status: libc::c_int = 0;
let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
assert!(
libc::WIFEXITED(status),
"the load probe child did not exit normally (status {status})"
);
match libc::WEXITSTATUS(status) {
0 => Ok(()),
errno => Err(errno),
}
}
fn resolvable_allowlist_syscalls() -> io::Result<usize> {
use std::collections::HashSet;
let mut scratch =
libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
.map_err(to_io_err)?;
let mut accepted = HashSet::new();
for name in SECCOMP_SYSCALL_ALLOWLIST {
if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
if scratch
.add_rule(libseccomp::ScmpAction::Allow, syscall)
.is_ok()
{
accepted.insert(i32::from(syscall));
}
}
}
Ok(accepted.len())
}
fn skip_without_seccomp(what: &str) -> bool {
let tier = detect_enforcement_tier();
if tier == EnforcementTier::EnvironmentOnly {
eprintln!(
"SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
can load a seccomp filter at all, which is every tier above \
EnforcementTier::EnvironmentOnly; detected {tier:?}"
);
return true;
}
false
}
#[test]
fn seccomp_filter_instruction_count() {
for unix_sockets_allowed in [false, true] {
let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
.expect("export the built seccomp filter");
eprintln!(
"seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
-> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
BPF_MAXINSNS_CEILING as f64 / count as f64,
SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
);
assert!(
count > 0,
"the exported filter is empty, so nothing was measured — \
`export_bpf` wrote no `struct sock_filter` records at all"
);
assert!(
count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
"the child's seccomp filter compiles to {count} instructions with \
unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
{SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
{BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
deliberately — updating \
docs/content/reference/seccomp-filter-instruction-budget.md with the new \
numbers — rather than letting the filter grow into a spawn failure"
);
}
}
#[test]
fn seccomp_filter_rule_shape() {
let allowlist_syscalls =
resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
assert!(
allowlist_syscalls > 0,
"no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
for the wrong reason"
);
for unix_sockets_allowed in [false, true] {
let filter = build_seccomp_filter(unix_sockets_allowed, false)
.expect("build the seccomp filter");
assert_eq!(
filter.get_act_default().expect("read the default action"),
libseccomp::ScmpAction::Errno(libc::EPERM),
"the filter's default action is no longer a deny with EPERM, which is what \
makes every syscall outside the allowlist unreachable"
);
let pfc = seccomp_filter_pfc(unix_sockets_allowed, false)
.expect("export the filter's pfc");
let denied = denied_socket_domains(unix_sockets_allowed);
let allowed = allowed_socket_domains(unix_sockets_allowed);
let syscall_blocks = pfc.matches("# filter for syscall ").count();
assert_eq!(
syscall_blocks,
1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
"the built filter carries rules for {syscall_blocks} syscalls, not the \
socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
syscalls this code adds",
LANDLOCK_SYSCALLS.len()
);
let deny_actions = pfc
.matches(&format!("action ERRNO({});", libc::EACCES))
.count();
assert_eq!(
deny_actions,
denied.len(),
"the built filter carries {deny_actions} EACCES rules, not one per denied \
socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
exactly like this, and would have discarded every domain denial with it"
);
let allow_actions = pfc.matches("action ALLOW;").count();
assert_eq!(
allow_actions,
allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
"the built filter carries {allow_actions} Allow rules, not one per allowed \
socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
Landlock syscall"
);
for (name, _) in LANDLOCK_SYSCALLS {
assert!(
pfc.contains(&format!("\"{name}\"")),
"{name} has no rule in the built filter, so `apply_landlock_scope` would \
be refused by the filter installed just before it"
);
}
eprintln!(
"seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
{allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
denied.len(),
allowed.len(),
LANDLOCK_SYSCALLS.len(),
);
}
}
fn bind_block(pfc: &str) -> &str {
let start = pfc
.find("# filter for syscall \"bind\"")
.unwrap_or_else(|| panic!("no bind block in:\n{pfc}"));
let rest = &pfc[start + 1..];
let end = rest
.find("# filter for syscall ")
.map_or(pfc.len(), |at| start + 1 + at);
&pfc[start..end]
}
fn returns_of(filter: &libseccomp::ScmpFilterContext, action: u32) -> usize {
let exported = export_to_memfd(|fd| filter.export_bpf(fd)).expect("export the bpf");
let (insns, _) = exported.as_chunks::<SOCK_FILTER_BYTES>();
insns
.iter()
.filter(|insn| {
let code = u16::from_ne_bytes([insn[0], insn[1]]);
let k = u32::from_ne_bytes([insn[4], insn[5], insn[6], insn[7]]);
code == (libc::BPF_RET | libc::BPF_K) as u16 && k == action
})
.count()
}
#[test]
fn seccomp_observed_filter_notifies_on_bind_instead_of_allowing_it() {
const SECCOMP_RET_USER_NOTIF: u32 = 0x7fc0_0000;
for unix_sockets_allowed in [false, true] {
let plain = seccomp_filter_pfc(unix_sockets_allowed, false).expect("plain pfc");
let observed =
seccomp_filter_pfc(unix_sockets_allowed, true).expect("observed pfc");
eprintln!(
"unix_sockets_allowed={unix_sockets_allowed}\nplain bind block:\n{}\
observed bind block:\n{}",
bind_block(&plain),
bind_block(&observed)
);
let notify = format!("action {SECCOMP_RET_USER_NOTIF:#x};");
assert!(bind_block(&plain).contains("action ALLOW;"));
assert!(!plain.contains(¬ify), "{plain}");
assert!(bind_block(&observed).contains(¬ify));
assert!(!bind_block(&observed).contains("ALLOW"));
assert_eq!(observed.matches(¬ify).count(), 1, "{observed}");
assert_eq!(
observed.matches("action ALLOW;").count() + 1,
plain.matches("action ALLOW;").count()
);
assert_eq!(
observed.matches("# filter for syscall ").count(),
plain.matches("# filter for syscall ").count()
);
let plain = build_seccomp_filter(unix_sockets_allowed, false).unwrap();
let observed = build_seccomp_filter(unix_sockets_allowed, true).unwrap();
assert_eq!(returns_of(&plain, SECCOMP_RET_USER_NOTIF), 0);
assert!(returns_of(&observed, SECCOMP_RET_USER_NOTIF) >= 1);
}
}
fn second_listener_errno(first: &libseccomp::ScmpFilterContext) -> i32 {
let allow = [libc::sock_filter {
code: (libc::BPF_RET | libc::BPF_K) as u16,
jt: 0,
jf: 0,
k: libc::SECCOMP_RET_ALLOW,
}];
let program = libc::sock_fprog {
len: allow.len() as u16,
filter: allow.as_ptr().cast_mut(),
};
let pid = unsafe { libc::fork() };
assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
if pid == 0 {
let code = match first.load() {
Err(_) => PROBE_ERRNO_UNKNOWN,
Ok(()) => unsafe {
if libc::syscall(
libc::SYS_seccomp,
libc::SECCOMP_SET_MODE_FILTER,
libc::SECCOMP_FILTER_FLAG_NEW_LISTENER,
std::ptr::addr_of!(program),
) >= 0
{
0
} else {
*libc::__errno_location()
}
},
};
unsafe { libc::_exit(code) }
}
let mut status: libc::c_int = 0;
let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
assert!(libc::WIFEXITED(status), "status {status}");
libc::WEXITSTATUS(status)
}
#[test]
fn a_second_listener_under_the_observed_filter_is_refused() {
if skip_without_seccomp("a second seccomp listener under the observed filter") {
return;
}
let observed = build_seccomp_filter(false, true).expect("build the observed filter");
let mut permissive =
libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow).unwrap();
permissive
.add_rule(
libseccomp::ScmpAction::Notify,
libseccomp::ScmpSyscall::from_name("bind").unwrap(),
)
.unwrap();
let shipped = second_listener_errno(&observed);
let kernel = second_listener_errno(&permissive);
eprintln!(
"second listener under the observed filter: errno {shipped} ({}); under a \
filter permitting seccomp(2): errno {kernel} ({})",
io::Error::from_raw_os_error(shipped),
io::Error::from_raw_os_error(kernel)
);
assert_eq!(shipped, libc::EPERM);
assert_eq!(kernel, libc::EBUSY);
}
#[test]
fn seccomp_per_filter_ceiling() {
if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
return;
}
let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
.expect("bracket the per-filter ceiling");
let (under_filter, under_count) =
probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
let (over_filter, over_count) =
probe_filter(over_rules).expect("build the over-ceiling probe filter");
assert!(
under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
"the two probe filters ({under_count} and {over_count} instructions) do not \
straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
nothing about where the ceiling is"
);
let under_outcome = load_in_child(&under_filter);
let over_outcome = load_in_child(&over_filter);
eprintln!(
"seccomp per-filter ceiling: largest filter that loaded = {under_count} \
instructions; smallest that did not = {over_count} instructions, refused with \
errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
over_outcome.err().unwrap_or(0),
libc::EINVAL,
);
assert_eq!(
under_outcome,
Ok(()),
"a {under_count}-instruction filter was refused, below the \
{BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
);
assert_eq!(
over_outcome,
Err(libc::EINVAL),
"a {over_count}-instruction filter was not refused with EINVAL, so \
BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
per-filter limit"
);
}
#[test]
fn seccomp_cumulative_ceiling() {
if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
return;
}
let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
let mut fds = [0 as libc::c_int; 2];
let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
let (read_fd, write_fd) =
unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
let pid = unsafe { libc::fork() };
assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
if pid == 0 {
let mut loads: u32 = 0;
let mut refusal: i32 = 0;
while loads < max_loads as u32 {
match filter.load() {
Ok(()) => loads += 1,
Err(error) => {
refusal = refusal_errno(&error);
break;
}
}
}
let mut report = [0u8; 8];
report[..4].copy_from_slice(&loads.to_ne_bytes());
report[4..].copy_from_slice(&refusal.to_ne_bytes());
unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
unsafe { libc::_exit(0) }
}
drop(write_fd);
let mut report = [0u8; 8];
std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
.expect("read the cumulative probe child's report");
let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
let mut status: libc::c_int = 0;
let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
let stacked = loads * count;
let cumulative_at_refusal = count + loads * per_filter_cost;
let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
let shipped = super::seccomp_filter_instruction_count(false)
.expect("measure the shipped filter for comparison");
eprintln!(
"seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
{SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
{loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
instructions, so reaching this ceiling would take {} of them in a single task.",
loads + 1,
libc::ENOMEM,
SECCOMP_MAX_INSNS_PER_PATH / shipped,
);
assert!(
loads >= 2,
"the kernel refused the filter chain after only {loads} load(s), so this is not \
a cumulative limit and the ENOMEM it returns is not the one being ruled out"
);
assert_eq!(
refusal,
libc::ENOMEM,
"stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
{refusal}, not ENOMEM ({}) — the rule-out in \
docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
being what this ceiling returns",
libc::ENOMEM
);
assert!(
loads <= loads_if_counted_classic,
"the kernel took {loads} loads of a {count}-instruction filter — more than the \
{loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
each one, so this kernel's ceiling is higher than the recorded value"
);
assert!(
loads * 2 > loads_if_counted_classic,
"the kernel refused after {loads} loads of a {count}-instruction filter, far \
short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
one recorded"
);
assert!(
shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
"the shipped filter ({shipped} instructions) is within a factor of two of the \
cumulative ceiling, which would make the rule-out this test supports untrue"
);
}
}
}
#[cfg(test)]
pub(crate) fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
let has_dynamic = !needed.is_empty() || runpath.is_some();
let mut phnum: u16 = 1; if interp.is_some() {
phnum += 1;
}
if has_dynamic {
phnum += 1;
}
let ehsize = 64usize;
let phentsize = 56usize;
let phoff = ehsize;
let body_start = phoff + phentsize * phnum as usize;
let mut strtab: Vec<u8> = vec![0];
let mut needed_offsets = Vec::new();
for name in needed {
needed_offsets.push(strtab.len() as u64);
strtab.extend_from_slice(name.as_bytes());
strtab.push(0);
}
let runpath_offset = runpath.map(|r| {
let off = strtab.len() as u64;
strtab.extend_from_slice(r.as_bytes());
strtab.push(0);
off
});
let mut body: Vec<u8> = Vec::new();
let interp_offset = body_start + body.len();
if let Some(i) = interp {
body.extend_from_slice(i.as_bytes());
body.push(0);
}
while !(body_start + body.len()).is_multiple_of(8) {
body.push(0);
}
let dyn_offset = body_start + body.len();
let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; let dyn_size = num_dyn * 16;
let strtab_offset = dyn_offset + dyn_size;
if has_dynamic {
let mut push_dyn = |tag: u64, val: u64| {
body.extend_from_slice(&tag.to_le_bytes());
body.extend_from_slice(&val.to_le_bytes());
};
for &o in &needed_offsets {
push_dyn(1, o); }
if let Some(ro) = runpath_offset {
push_dyn(29, ro); }
push_dyn(5, strtab_offset as u64); push_dyn(10, strtab.len() as u64); push_dyn(0, 0); assert_eq!(body_start + body.len(), strtab_offset);
body.extend_from_slice(&strtab);
}
let total_len = body_start + body.len();
let mut out = vec![0u8; total_len];
out[0..4].copy_from_slice(b"\x7fELF");
out[4] = 2; out[5] = 1; out[6] = 1; out[16..18].copy_from_slice(&3u16.to_le_bytes()); out[18..20].copy_from_slice(&0x3eu16.to_le_bytes()); out[20..24].copy_from_slice(&1u32.to_le_bytes()); out[32..40].copy_from_slice(&(phoff as u64).to_le_bytes()); out[54..56].copy_from_slice(&(phentsize as u16).to_le_bytes()); out[56..58].copy_from_slice(&phnum.to_le_bytes());
let mut phdrs: Vec<(u32, u64, u64, u64)> = Vec::new(); phdrs.push((1, 0, 0, total_len as u64)); if let Some(i) = interp {
phdrs.push((
3,
interp_offset as u64,
interp_offset as u64,
i.len() as u64 + 1,
));
}
if has_dynamic {
phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
}
for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
let base = phoff + idx * phentsize;
out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
}
out[body_start..total_len].copy_from_slice(&body);
out
}
#[cfg(test)]
mod tests {
use super::*;
use murmur_artifact::security_warnings::W_SEC_003;
#[test]
fn fd_hygiene_range_starts_above_stdio() {
assert_eq!(
FD_HYGIENE_FIRST_FD, 3,
"fd hygiene must start at 3 — 0/1/2 are the stdio pipes every spawn needs to \
survive execve"
);
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
#[test]
fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
let rc = unsafe {
libc::syscall(
libc::SYS_close_range,
FD_HYGIENE_FIRST_FD,
libc::c_uint::MAX,
libc::c_uint::MAX,
)
};
assert_eq!(rc, -1, "an invalid flag word must not be accepted");
assert_eq!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::EINVAL),
"the two statements the production path runs on failure must carry the kernel's errno"
);
}
fn sealed_capable() -> crate::namespace_probe::NamespaceReading {
crate::namespace_probe::NamespaceReading {
network: Ok(()),
mount: crate::namespace_probe::MountAttempt::Created,
sealed_root: crate::namespace_probe::MountAttempt::Created,
private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
userns_grant: crate::sealed::UsernsGrant::Withheld,
attachment: crate::sealed::ProfileAttachment::Unconfined,
profile_gap: crate::sealed::ProfileGap::Undetermined,
}
}
fn sealed_incapable() -> crate::namespace_probe::NamespaceReading {
crate::namespace_probe::NamespaceReading {
network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
"the namespace creator did not run".to_string(),
)),
mount: crate::namespace_probe::MountAttempt::NotAttempted,
sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
userns_grant: crate::sealed::UsernsGrant::Withheld,
attachment: crate::sealed::ProfileAttachment::NotReported,
profile_gap: crate::sealed::ProfileGap::Undetermined,
}
}
#[test]
fn doctor_reads_the_decision_from_the_probe_it_is_handed() {
if !cfg!(target_os = "linux") {
return;
}
let sealed_host = HostProbe::from_parts(Some(true), sealed_capable());
assert_eq!(sealed_host.tier(), EnforcementTier::KernelSealed);
assert_eq!(
crate::containment::applied_mechanism(
&sealed_host,
murmur_artifact::ContainmentClass::Scoped
),
"landlock+seccomp"
);
assert_eq!(
crate::containment::applied_mechanism(
&sealed_host,
murmur_artifact::ContainmentClass::Sealed
),
"mountns+pivot_root+landlock+seccomp"
);
}
fn unprobed() -> HostProbe {
HostProbe::from_parts(None, sealed_incapable())
}
#[test]
fn an_unprobed_host_resolves_to_seccomp_only() {
let tier = unprobed().tier();
if cfg!(target_os = "linux") {
assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
} else {
assert_eq!(tier, EnforcementTier::EnvironmentOnly);
}
assert_ne!(tier, EnforcementTier::KernelSealed);
assert_ne!(tier, EnforcementTier::KernelFull);
}
#[test]
fn an_unprobed_host_names_a_blocker() {
assert!(unprobed().sealed_shortfall().is_some());
}
#[test]
fn an_unprobed_host_achieves_less_than_sealed() {
let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
}
#[test]
fn an_unprobed_host_refuses_a_declared_sealed_floor() {
let probe = unprobed();
let achieved = crate::containment::achieved_class_for_tier(probe.tier());
let error = crate::containment::check_containment_floor(
murmur_artifact::ContainmentClass::Sealed,
achieved,
probe.sealed_shortfall().as_ref(),
false,
None,
)
.expect_err("an unprobed host cannot back a declared sealed floor");
assert!(matches!(
error,
crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
));
}
#[test]
fn a_probe_that_created_every_namespace_backs_sealed_while_the_setting_reads_one() {
for attachment in [
crate::sealed::ProfileAttachment::Unconfined,
crate::sealed::ProfileAttachment::Other {
profile: "docker-default".to_string(),
},
] {
let probe = HostProbe::from_parts(
Some(true),
crate::namespace_probe::NamespaceReading {
attachment,
..sealed_capable()
},
);
if !cfg!(target_os = "linux") {
assert_eq!(probe.tier(), EnforcementTier::EnvironmentOnly);
continue;
}
assert_eq!(probe.tier(), EnforcementTier::KernelSealed);
assert_eq!(probe.sealed_shortfall(), None);
assert!(probe.mount_namespace_usable());
assert_eq!(probe.egress_namespace_failure(), None);
assert_eq!(
crate::temp_dir::temp_mapping(
EnforcementTier::KernelFull,
probe.mount_namespace_usable(),
Path::new("/home/u/w"),
),
crate::temp_dir::TempMapping::MountNamespace
);
}
}
#[test]
fn every_decision_is_identical_across_every_userns_grant() {
use crate::namespace_probe::{
MountAttempt, MountAttemptError, MountStep, NamespaceReading,
};
let root_refused = NamespaceReading {
sealed_root: MountAttempt::Failed(MountAttemptError::Step {
step: MountStep::PivotRoot,
errno: libc::EPERM,
}),
..sealed_capable()
};
for base in [sealed_capable(), root_refused, sealed_incapable()] {
let decide = |grant: crate::sealed::UsernsGrant| {
let probe = HostProbe::from_parts(
Some(true),
NamespaceReading {
userns_grant: grant,
..base.clone()
},
);
(
probe.tier(),
probe.sealed_shortfall().map(|shortfall| shortfall.blocker),
probe.mount_namespace_usable(),
probe
.egress_namespace_failure()
.map(|failure| failure.blocker),
)
};
let expected = decide(crate::sealed::UsernsGrant::Withheld);
for grant in crate::sealed::UsernsGrant::ALL {
assert_eq!(decide(*grant), expected, "{grant:?} over {base:?}");
}
}
}
#[test]
fn a_live_reading_answers_every_question_from_its_own_attempts() {
let probe = HostProbe::probe();
let namespaces = probe.namespaces().clone();
let linux = cfg!(target_os = "linux");
assert_eq!(
probe.tier() == EnforcementTier::KernelSealed,
probe.sealed_shortfall().is_none(),
"{namespaces:?}"
);
assert_eq!(
probe.mount_namespace_usable(),
linux
&& namespaces.network.is_ok()
&& namespaces.mount == crate::namespace_probe::MountAttempt::Created
);
assert_eq!(
probe.egress_namespace_failure().is_some(),
linux && namespaces.network.is_err()
);
}
#[test]
fn tier_from_probe_non_linux_is_always_environment_only() {
for landlock in [None, Some(true), Some(false)] {
for namespaces in [sealed_capable(), sealed_incapable()] {
assert_eq!(
tier_from_probe(false, landlock, &namespaces),
EnforcementTier::EnvironmentOnly
);
}
}
}
#[test]
fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
assert_eq!(
tier_from_probe(true, Some(true), &sealed_incapable()),
EnforcementTier::KernelFull
);
}
#[test]
fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
assert_eq!(
tier_from_probe(true, Some(false), &sealed_incapable()),
EnforcementTier::KernelSeccompOnly
);
}
#[test]
fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
assert_eq!(
tier_from_probe(true, None, &sealed_incapable()),
EnforcementTier::KernelSeccompOnly
);
}
#[test]
fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
use crate::namespace_probe::{
MountAttempt, MountAttemptError, MountStep, NamespaceReading,
};
assert_eq!(
tier_from_probe(true, Some(true), &sealed_capable()),
EnforcementTier::KernelSealed
);
let network_refused = NamespaceReading {
network: Err(crate::network_namespace::NamespaceAttemptError::Step {
step: crate::network_namespace::CreationStep::IdentityMaps,
errno: libc::EPERM,
}),
mount: MountAttempt::NotAttempted,
sealed_root: MountAttempt::NotAttempted,
..sealed_capable()
};
assert_eq!(
tier_from_probe(true, Some(true), &network_refused),
EnforcementTier::KernelFull
);
for step in [
MountStep::JoinSession,
MountStep::UnshareUserMount,
MountStep::IdentityMaps,
MountStep::MakePrivateNested,
MountStep::Tmpfs,
MountStep::PivotPrepare,
MountStep::PivotRoot,
MountStep::DetachOldRoot,
] {
let reading = NamespaceReading {
sealed_root: MountAttempt::Failed(MountAttemptError::Step {
step,
errno: libc::EPERM,
}),
..sealed_capable()
};
assert_eq!(
tier_from_probe(true, Some(true), &reading),
EnforcementTier::KernelFull,
"a sealed root refused at {step:?} must fall back to KernelFull"
);
}
let tmp_refused = NamespaceReading {
mount: MountAttempt::Failed(MountAttemptError::Step {
step: MountStep::MakePrivate,
errno: libc::EACCES,
}),
..sealed_capable()
};
assert_eq!(
tier_from_probe(true, Some(true), &tmp_refused),
EnforcementTier::KernelSealed
);
assert_eq!(
tier_from_probe(true, Some(false), &sealed_capable()),
EnforcementTier::KernelSeccompOnly
);
assert_eq!(
tier_from_probe(true, None, &sealed_capable()),
EnforcementTier::KernelSeccompOnly
);
assert_eq!(
tier_from_probe(false, Some(true), &sealed_capable()),
EnforcementTier::EnvironmentOnly
);
}
#[test]
fn the_tmp_mapping_follows_the_mount_attempt() {
use crate::namespace_probe::{
MountAttempt, MountAttemptError, MountStep, NamespaceReading,
};
use crate::temp_dir::{temp_mapping, NotMappedReason, TempMapping};
if !cfg!(target_os = "linux") {
return;
}
let workdir = Path::new("/home/u/w");
let refused = |step, errno| MountAttempt::Failed(MountAttemptError::Step { step, errno });
let mount_refused = HostProbe::from_parts(
Some(true),
NamespaceReading {
mount: refused(MountStep::MakePrivate, libc::EACCES),
sealed_root: refused(MountStep::MakePrivateNested, libc::EACCES),
..sealed_capable()
},
);
assert_eq!(mount_refused.tier(), EnforcementTier::KernelFull);
assert_eq!(
temp_mapping(
mount_refused.tier(),
mount_refused.mount_namespace_usable(),
workdir
),
TempMapping::NotMapped(NotMappedReason::NoMountNamespace)
);
let root_refused = HostProbe::from_parts(
Some(true),
NamespaceReading {
sealed_root: refused(MountStep::PivotRoot, libc::EPERM),
..sealed_capable()
},
);
assert_eq!(root_refused.tier(), EnforcementTier::KernelFull);
assert_eq!(
temp_mapping(
root_refused.tier(),
root_refused.mount_namespace_usable(),
workdir
),
TempMapping::MountNamespace
);
let network_refused = HostProbe::from_parts(
Some(true),
NamespaceReading {
network: Err(crate::network_namespace::NamespaceAttemptError::Step {
step: crate::network_namespace::CreationStep::Unshare,
errno: libc::EPERM,
}),
mount: MountAttempt::NotAttempted,
sealed_root: MountAttempt::NotAttempted,
..sealed_capable()
},
);
assert!(!network_refused.mount_namespace_usable());
assert!(matches!(
temp_mapping(
network_refused.tier(),
network_refused.mount_namespace_usable(),
workdir
),
TempMapping::NotMapped(_)
));
assert!(network_refused.egress_namespace_failure().is_some());
}
#[test]
fn ruleset_landlock_abi_is_the_kernel_abi_capped_at_the_target_on_landlock_tiers() {
for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
for (kernel, built) in [
(Some(1), Some(1)),
(Some(2), Some(2)),
(Some(3), Some(3)),
(Some(8), Some(3)),
(Some(u8::MAX), Some(3)),
] {
assert_eq!(
ruleset_landlock_abi(kernel, tier),
built,
"kernel ABI {kernel:?} on {tier:?}"
);
}
}
}
#[test]
fn ruleset_landlock_abi_is_none_without_a_kernel_abi_or_a_landlock_tier() {
for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
assert_eq!(ruleset_landlock_abi(None, tier), None);
assert_eq!(ruleset_landlock_abi(Some(0), tier), None);
}
for tier in [
EnforcementTier::KernelSeccompOnly,
EnforcementTier::EnvironmentOnly,
] {
for kernel in [None, Some(0), Some(1), Some(3), Some(8)] {
assert_eq!(
ruleset_landlock_abi(kernel, tier),
None,
"kernel ABI {kernel:?} on {tier:?}"
);
}
}
}
#[test]
fn ruleset_landlock_abi_reaches_shell_enforcement_from_the_probe() {
let negative = unprobed();
for abi in [None, Some(1), Some(8)] {
let enforcement = ShellEnforcement::resolve_declared(
&CapabilityPolicy::default(),
murmur_artifact::ContainmentClass::Advisory,
negative.clone().with_landlock_kernel_abi(abi),
)
.unwrap()
.with_test_session_dirs();
assert_eq!(
enforcement.landlock_abi,
ruleset_landlock_abi(abi, enforcement.tier),
"kernel ABI {abi:?}"
);
assert_eq!(enforcement.landlock_abi, None);
}
if cfg!(target_os = "linux") {
let full = HostProbe::from_parts(Some(true), sealed_incapable());
let enforcement = ShellEnforcement::resolve_declared(
&CapabilityPolicy::default(),
murmur_artifact::ContainmentClass::Scoped,
full.with_landlock_kernel_abi(Some(8)),
)
.unwrap()
.with_test_session_dirs();
assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
assert_eq!(enforcement.landlock_abi, Some(LANDLOCK_TARGET_ABI));
}
}
#[test]
fn landlock_doctor_lines_name_the_kernel_and_ruleset_abi() {
if !cfg!(target_os = "linux") {
for abi in [None, Some(1), Some(8)] {
assert_eq!(
landlock_doctor_lines(abi),
vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()]
);
}
return;
}
let none = landlock_doctor_lines(None).join("\n");
assert!(none.contains("no Landlock on this host"), "{none}");
assert_eq!(landlock_doctor_lines(Some(0)), landlock_doctor_lines(None));
let v1 = landlock_doctor_lines(Some(1)).join("\n");
assert!(v1.contains("kernel ABI: 1"), "{v1}");
assert!(v1.contains("ruleset ABI: 1"), "{v1}");
assert!(v1.contains("EXDEV"), "{v1}");
assert!(v1.contains("Invalid cross-device link"), "{v1}");
assert!(v1.contains("Linux 5.19 (Landlock ABI v2)"), "{v1}");
assert!(v1.contains("uv"), "{v1}");
let v2 = landlock_doctor_lines(Some(2)).join("\n");
assert!(v2.contains("kernel ABI: 2"), "{v2}");
assert!(v2.contains("ruleset ABI: 2"), "{v2}");
assert!(!v2.contains("EXDEV"), "{v2}");
assert!(
v2.contains("not mediated below Landlock ABI v3 (Linux 6.2)"),
"{v2}"
);
for kernel in [3, 8] {
let lines = landlock_doctor_lines(Some(kernel));
assert_eq!(
lines,
vec![
format!("kernel ABI: {kernel}"),
"ruleset ABI: 3 (the kernel's, capped at 3)".to_string(),
]
);
}
}
#[test]
fn applied_tier_is_unconfined_on_every_host_for_the_unconfined_stance() {
use murmur_artifact::ContainmentClass;
for host_tier in [
EnforcementTier::KernelSealed,
EnforcementTier::KernelFull,
EnforcementTier::KernelSeccompOnly,
EnforcementTier::EnvironmentOnly,
] {
assert_eq!(
applied_tier(host_tier, ContainmentClass::Unconfined),
EnforcementTier::Unconfined,
"{host_tier:?}"
);
}
assert_eq!(tier_warning(EnforcementTier::Unconfined, false), None);
assert_eq!(
ruleset_landlock_abi(Some(4), EnforcementTier::Unconfined),
None
);
}
#[test]
fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
use murmur_artifact::ContainmentClass;
assert_eq!(
applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
EnforcementTier::KernelSealed
);
for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
assert_eq!(
applied_tier(EnforcementTier::KernelSealed, declared),
EnforcementTier::KernelFull,
"declaring {declared} must not opt a capsule into the composed root"
);
}
for host_tier in [
EnforcementTier::KernelFull,
EnforcementTier::KernelSeccompOnly,
EnforcementTier::EnvironmentOnly,
] {
for declared in [
ContainmentClass::Advisory,
ContainmentClass::Scoped,
ContainmentClass::Sealed,
] {
assert_eq!(applied_tier(host_tier, declared), host_tier);
}
}
}
pub(super) fn granted_needs(
paths: &[(PathBuf, bool)],
) -> Vec<crate::declared_needs::ResolvedProgramNeeds> {
vec![crate::declared_needs::ResolvedProgramNeeds {
artifact: "local/needs-tool".to_string(),
program: "cat".to_string(),
granted: paths
.iter()
.map(|(path, directory)| crate::declared_needs::ResolvedNeed {
need: murmur_artifact::DeclaredNeed {
form: murmur_artifact::NeedForm::Etc("fixture".to_string()),
packages: Default::default(),
},
paths: vec![crate::declared_needs::NeedPath {
path: path.clone(),
directory: *directory,
}],
})
.collect(),
missing: Vec::new(),
}]
}
#[test]
fn sealed_need_mirrors_are_each_need_at_its_own_path() {
let needs = granted_needs(&[
(PathBuf::from("/etc/fonts/fonts.conf"), false),
(PathBuf::from("/usr/share/fonts"), true),
(PathBuf::from("/lib/x86_64-linux-gnu/libm.so.6"), false),
(PathBuf::from("/etc/ssl/certs"), true),
(PathBuf::from("/opt/fixture/share"), true),
(PathBuf::from("/opt/fixture/share"), true),
(PathBuf::from("/var/cache/fontconfig"), true),
]);
assert_eq!(
resolve_sealed_need_mirrors(&needs, &[]),
[
PathBuf::from("/etc/fonts/fonts.conf"),
PathBuf::from("/opt/fixture/share"),
PathBuf::from("/var/cache/fontconfig"),
]
);
assert!(!resolve_sealed_need_mirrors(&needs, &[]).contains(&PathBuf::from("/etc")));
assert_eq!(
resolve_sealed_need_mirrors(&needs, &[PathBuf::from("/opt/fixture")]),
[
PathBuf::from("/etc/fonts/fonts.conf"),
PathBuf::from("/var/cache/fontconfig"),
]
);
}
#[test]
fn resolve_declared_need_grants_reads_and_lists_but_never_executes() {
let needs = granted_needs(&[
(PathBuf::from("/opt/fixture/lib/libfix.so.1"), false),
(PathBuf::from("/opt/fixture/share"), true),
]);
assert_eq!(
resolve_declared_need_grants(&needs),
[
LandlockGrant {
path: PathBuf::from("/opt/fixture/lib/libfix.so.1"),
list_dir: false,
executable: false,
},
LandlockGrant {
path: PathBuf::from("/opt/fixture/share"),
list_dir: true,
executable: false,
},
]
);
let mut policy = CapabilityPolicy {
shell_allow: vec!["cat".to_string()],
..CapabilityPolicy::default()
};
policy.shell_declared_needs = needs;
policy.shell_interpreter_runtime = vec![murmur_artifact::InterpreterRuntimeGrant {
binary: "cat".to_string(),
dirs: vec![murmur_artifact::InterpreterRuntimeDir {
path: "/opt/own-grant".to_string(),
list_dir: false,
}],
}];
let enforcement = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Scoped,
HostProbe::probe(),
)
.unwrap();
for path in ["/opt/fixture/lib/libfix.so.1", "/opt/fixture/share"] {
let grant = enforcement
.landlock_grants
.iter()
.find(|grant| grant.path == Path::new(path))
.unwrap_or_else(|| panic!("{path} is granted"));
assert!(!grant.executable, "{grant:?}");
}
assert!(enforcement
.landlock_grants
.iter()
.any(|grant| grant.path == Path::new("/opt/own-grant") && grant.executable));
assert_eq!(
enforcement.sealed_need_mirrors,
[
PathBuf::from("/opt/fixture/lib/libfix.so.1"),
PathBuf::from("/opt/fixture/share"),
]
);
}
#[test]
fn pinned_programs_grant_list_and_execute_on_the_entry_and_bind_it_only_when_pinned() {
use crate::pinned_programs::{PinnedSource, ResolvedPinnedProgram};
let temp = tempfile::tempdir().unwrap();
let tree = std::fs::canonicalize(temp.path())
.unwrap()
.join("programs/abc");
std::fs::create_dir_all(tree.join("files/fixture")).unwrap();
std::fs::create_dir_all(tree.join("bin")).unwrap();
let file = tree.join("files/fixture/murmur-pinned-fixture-prog");
std::fs::copy("/bin/true", &file).unwrap();
std::os::unix::fs::symlink(
"../files/fixture/murmur-pinned-fixture-prog",
tree.join("bin/murmur-pinned-fixture-prog"),
)
.unwrap();
let chosen = |source: PinnedSource| CapabilityPolicy {
shell_allow: vec!["murmur-pinned-fixture-prog".to_string()],
shell_pinned_programs: vec![ResolvedPinnedProgram {
artifact: "local/pinned-tool".to_string(),
program: "murmur-pinned-fixture-prog".to_string(),
platform: "linux-x86_64".to_string(),
source,
}],
..CapabilityPolicy::default()
};
let resolve = |policy: &CapabilityPolicy| {
ShellEnforcement::resolve_declared(
policy,
murmur_artifact::ContainmentClass::Scoped,
HostProbe::probe(),
)
.unwrap()
};
let pinned = chosen(PinnedSource::Pinned {
path: file.clone(),
bin_dir: tree.join("bin"),
tree: tree.clone(),
url: "http://127.0.0.1:1/f.zip".to_string(),
sha256: "abc".to_string(),
});
assert_eq!(resolve_policy_exec_allowlist(&pinned), vec![file.clone()]);
assert_eq!(
resolve_pinned_program_landlock_grants(&pinned),
vec![LandlockGrant {
path: tree.clone(),
list_dir: true,
executable: true,
}]
);
let enforcement = resolve(&pinned);
assert!(enforcement.landlock_grants.contains(&LandlockGrant {
path: tree.clone(),
list_dir: true,
executable: true,
}));
assert!(
enforcement
.landlock_grants
.iter()
.any(|grant| grant.path == file && grant.executable),
"the pinned file seeds the DT_NEEDED closure like any shell.allow binary"
);
assert_eq!(enforcement.pinned_program_dirs, vec![tree.clone()]);
assert!(host_path_execute_roots(&pinned)
.iter()
.any(|root| root.dir == tree));
for source in [
PinnedSource::Host {
path: PathBuf::from("/bin/true"),
},
PinnedSource::Missing {
reason: "the store holds no copy".to_string(),
},
] {
let policy = chosen(source);
assert!(resolve_policy_exec_allowlist(&policy).is_empty());
let enforcement = resolve(&policy);
assert!(enforcement.pinned_program_dirs.is_empty());
assert!(!enforcement
.landlock_grants
.iter()
.any(|grant| grant.path.starts_with(&tree)));
assert!(!host_path_execute_roots(&policy)
.iter()
.any(|root| root.dir.starts_with(&tree)));
}
}
#[test]
fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
let policy = CapabilityPolicy {
shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
binary: "python3".to_string(),
dirs: vec![
InterpreterRuntimeDir {
path: "/usr/lib/python3.11".to_string(),
list_dir: true,
},
InterpreterRuntimeDir {
path: "/opt/py/lib".to_string(),
list_dir: true,
},
InterpreterRuntimeDir {
path: "/opt/py/lib".to_string(),
list_dir: false,
},
InterpreterRuntimeDir {
path: "relative/lib".to_string(),
list_dir: false,
},
],
}],
..CapabilityPolicy::default()
};
let exec_allow = vec![
PathBuf::from("/usr/bin/python3"),
PathBuf::from("/opt/toolchain/bin/cc"),
];
assert_eq!(
resolve_sealed_bind_dirs(&exec_allow, &policy),
vec![
PathBuf::from("/opt/toolchain/bin"),
PathBuf::from("/opt/py/lib")
]
);
}
#[test]
fn host_path_execute_roots_include_interpreter_runtime_dirs_and_staged_trees() {
use crate::host_paths::{check_execute_roots, HostPath, HostPathAccess, HostPathMechanism};
let policy = CapabilityPolicy {
shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
binary: "python3".to_string(),
dirs: vec![murmur_artifact::InterpreterRuntimeDir {
path: "/nonexistent-host-path-test/interp".to_string(),
list_dir: true,
}],
}],
shell_staged_runtime: vec![murmur_artifact::StagedRuntimeGrant {
binary: "python3".to_string(),
source_path: "/nonexistent-host-path-test/opt/py".to_string(),
pin: "pin-1".to_string(),
}],
..CapabilityPolicy::default()
};
let roots = execute_roots_from(&[], &policy);
let dirs: Vec<&Path> = roots.iter().map(|root| root.dir.as_path()).collect();
assert_eq!(
dirs,
[
Path::new("/nonexistent-host-path-test/interp"),
Path::new("/nonexistent-host-path-test/opt/py"),
]
);
let read_write = |path: &str| HostPath {
declared: path.to_string(),
path: PathBuf::from(path),
access: HostPathAccess::ReadWrite,
credential_locations: Vec::new(),
};
let inside_interpreter = [read_write("/nonexistent-host-path-test/interp/cache")];
for mechanism in [HostPathMechanism::Landlock, HostPathMechanism::Bind] {
assert!(check_execute_roots(&inside_interpreter, &roots, mechanism).is_err());
}
let holding_staged = [read_write("/nonexistent-host-path-test/opt")];
assert!(check_execute_roots(&holding_staged, &roots, HostPathMechanism::Landlock).is_err());
assert_eq!(
check_execute_roots(&holding_staged, &roots, HostPathMechanism::Bind).unwrap(),
[PathBuf::from("/nonexistent-host-path-test/opt/py")]
);
}
#[test]
fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
use murmur_artifact::StagedRuntimeGrant;
let grant = |binary: &str, source: &str| StagedRuntimeGrant {
binary: binary.to_string(),
source_path: source.to_string(),
pin: "pin-1".to_string(),
};
let policy = CapabilityPolicy {
shell_staged_runtime: vec![
grant("python3", "/opt/py"),
grant("perl", "/usr/lib/perl5"),
grant("python3.12", "/opt/py"),
grant("ruby", "relative/ruby"),
],
..CapabilityPolicy::default()
};
assert_eq!(
resolve_staged_runtime_dirs(&policy),
vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
);
}
#[test]
fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
use murmur_artifact::StagedRuntimeGrant;
let policy = CapabilityPolicy {
shell_staged_runtime: vec![StagedRuntimeGrant {
binary: "python3".to_string(),
source_path: "/opt/py".to_string(),
pin: "pin-1".to_string(),
}],
..CapabilityPolicy::default()
};
let dirs = resolve_staged_runtime_dirs(&policy);
let grants = resolve_staged_runtime_landlock_grants(&policy);
assert_eq!(
grants,
vec![LandlockGrant {
path: PathBuf::from("/opt/py"),
list_dir: true,
executable: true,
}],
"a staged tree is walked by the runtime that uses it, so it must be listable",
);
assert_eq!(
grants
.iter()
.map(|grant| grant.path.clone())
.collect::<Vec<_>>(),
dirs,
"the bound set and the granted set must not drift apart",
);
}
#[test]
fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
assert_eq!(
grants,
crate::sealed::SEALED_RUNTIME_PATHS
.iter()
.map(|path| LandlockGrant {
path: PathBuf::from(path),
list_dir: true,
executable: false,
})
.collect::<Vec<_>>(),
"the composed root binds exactly these, so exactly these must be enumerable — \
the bound set and the granted set must not drift apart",
);
assert!(
grants.iter().all(|grant| grant.list_dir),
"a runtime tree an interpreter walks is useless without getdents64",
);
assert!(
grants.iter().all(|grant| !grant.executable),
"Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
sealed capsule run every binary the host ships, whatever shell.allow says",
);
}
#[test]
fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
for tier in [
EnforcementTier::KernelFull,
EnforcementTier::KernelSeccompOnly,
EnforcementTier::EnvironmentOnly,
] {
assert!(
resolve_sealed_runtime_landlock_grants(tier).is_empty(),
"{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
would enumerate the real host filesystem",
);
}
}
#[test]
fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
let policy = CapabilityPolicy::default();
let usr = PathBuf::from("/usr");
let scoped = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Scoped,
HostProbe::probe(),
)
.expect("an empty policy resolves")
.with_test_session_dirs();
assert!(
!scoped.landlock_grants.iter().any(|grant| grant.path == usr),
"a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
unenumerable there",
);
let sealed = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Sealed,
HostProbe::probe(),
)
.expect("an empty policy resolves")
.with_test_session_dirs();
let granted: Vec<&LandlockGrant> = sealed
.landlock_grants
.iter()
.filter(|grant| {
crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
})
.collect();
if sealed.tier == EnforcementTier::KernelSealed {
assert_eq!(
granted.len(),
crate::sealed::SEALED_RUNTIME_PATHS.len(),
"a sealed session must carry one listable grant per bound runtime path",
);
assert!(granted
.iter()
.all(|grant| grant.list_dir && !grant.executable));
} else {
assert!(
granted.is_empty(),
"this host fell back to {:?}, where there is no composed root to enumerate",
sealed.tier,
);
}
}
#[test]
fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
assert_eq!(
grants,
crate::sealed::SEALED_ETC_PATHS
.iter()
.map(|entry| LandlockGrant {
path: PathBuf::from(entry.path),
list_dir: entry.directory,
executable: false,
})
.collect::<Vec<_>>(),
"the composed root binds exactly these /etc entries, so exactly these must be \
readable — the bound set and the granted set must not drift apart",
);
assert!(
grants.iter().all(|grant| !grant.executable),
"Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
run — /etc/alternatives is a directory of symlinks into /usr/bin",
);
let listable: Vec<&str> = grants
.iter()
.filter(|grant| grant.list_dir)
.map(|grant| grant.path.to_str().unwrap())
.collect();
assert_eq!(
listable,
vec![
"/etc/ld.so.conf.d",
"/etc/alternatives",
"/etc/ssl",
"/etc/pki",
"/etc/ca-certificates",
"/etc/terminfo",
],
"ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
);
}
#[test]
fn sealed_etc_landlock_grants_are_sealed_tier_only() {
for tier in [
EnforcementTier::KernelFull,
EnforcementTier::KernelSeccompOnly,
EnforcementTier::EnvironmentOnly,
] {
assert!(
resolve_sealed_etc_landlock_grants(tier).is_empty(),
"{tier:?} has no composed root, so these rules would hand out the real host's \
/etc — its trust store, resolv.conf and account databases",
);
}
}
#[test]
fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
let policy = CapabilityPolicy::default();
let is_etc_grant = |grant: &LandlockGrant| {
crate::sealed::SEALED_ETC_PATHS
.iter()
.any(|entry| Path::new(entry.path) == grant.path)
};
let scoped = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Scoped,
HostProbe::probe(),
)
.expect("an empty policy resolves")
.with_test_session_dirs();
let name_resolution = |grant: &LandlockGrant| {
SCOPED_NAME_RESOLUTION_PATHS
.iter()
.any(|path| Path::new(path) == grant.path)
};
assert!(
!scoped
.landlock_grants
.iter()
.any(|grant| is_etc_grant(grant) && !name_resolution(grant)),
"a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
neighbours must not become readable there",
);
assert_eq!(
scoped
.landlock_grants
.iter()
.filter(|grant| name_resolution(grant))
.count(),
SCOPED_NAME_RESOLUTION_PATHS.len()
);
let sealed = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Sealed,
HostProbe::probe(),
)
.expect("an empty policy resolves")
.with_test_session_dirs();
let granted: Vec<&LandlockGrant> = sealed
.landlock_grants
.iter()
.filter(|grant| is_etc_grant(grant))
.collect();
if sealed.tier == EnforcementTier::KernelSealed {
assert_eq!(
granted.len(),
crate::sealed::SEALED_ETC_PATHS.len(),
"a sealed session must carry one read grant per bound /etc entry",
);
assert!(granted.iter().all(|grant| !grant.executable));
} else {
assert!(
granted
.iter()
.all(|grant| name_resolution(grant) && !grant.executable && !grant.list_dir),
"this host fell back to {:?}, where there is no composed root and /etc is the \
host's own: {granted:?}",
sealed.tier,
);
assert_eq!(
granted.len(),
resolve_scoped_name_resolution_landlock_grants(sealed.tier).len(),
"{:?}: {granted:?}",
sealed.tier,
);
}
}
#[test]
fn resolve_network_allowlist_ips_empty_list_is_empty() {
let ips = resolve_network_allowlist_ips(&[]).unwrap();
assert!(ips.is_empty());
}
#[test]
fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
assert!(
!ips.is_empty(),
"localhost should resolve to at least one address"
);
assert!(
ips.iter().all(|ip| ip.is_loopback()),
"every resolved localhost address should be loopback, got {ips:?}"
);
}
#[test]
fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
assert!(!error.is_empty());
}
#[test]
fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
let ips =
resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
.expect("an unresolvable host must not be a hard error");
assert!(
ips.is_empty(),
"an unresolvable host should contribute no IPs, got {ips:?}"
);
}
#[test]
fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
let ips = resolve_network_allowlist_ips(&[
"definitely-not-a-real-host.invalid".to_string(),
"localhost".to_string(),
])
.expect("a mix of resolvable and unresolvable hosts must not be a hard error");
assert!(
!ips.is_empty(),
"localhost's addresses should survive alongside an unresolvable host"
);
assert!(
ips.iter().all(|ip| ip.is_loopback()),
"only localhost should have contributed addresses, got {ips:?}"
);
}
#[test]
fn resolve_network_allowlist_ips_dedupes_across_hosts() {
let ips =
resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
.unwrap();
let mut sorted = ips.clone();
sorted.sort();
sorted.dedup();
assert_eq!(
ips.len(),
sorted.len(),
"resolved IPs should already be deduplicated"
);
}
#[test]
fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
let enforcement = ShellEnforcement::environment_only();
assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
assert!(enforcement.network_allow_ips.is_empty());
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn nproc_bound_without_a_scope_is_headroom_over_a_live_count() {
match ShellEnforcement::environment_only()
.with_test_session_dirs()
.nproc_bound()
{
crate::resources::NprocBound::Headroom { baseline } => assert!(
baseline > 0,
"the test process is owned by this uid, so the count cannot be zero"
),
other => panic!("no scope must give headroom, got {other:?}"),
}
}
#[cfg(target_os = "linux")]
#[test]
fn nproc_bound_without_a_scope_is_measured_on_each_call() {
let _lock = crate::resources::test_support::host_threads_lock();
let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
let baseline = |bound| match bound {
crate::resources::NprocBound::Headroom { baseline } => baseline,
other => panic!("no scope must give headroom, got {other:?}"),
};
let first = baseline(enforcement.nproc_bound());
let holders = crate::resources::test_support::HeldThreads::start(200);
let second = baseline(enforcement.nproc_bound());
drop(holders);
assert!(
second > first,
"200 threads started between the calls must raise the baseline: {first} then {second}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max() {
if !crate::cgroup::cgroup_delegation_available() {
crate::runtime_err!(
"[SKIP-HOST] nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max: this host \
cannot delegate a cgroup v2 scope"
);
return;
}
let temp = tempfile::tempdir().unwrap();
let limits = crate::resources::HostResourceLimits::default();
let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_bound", temp.path())
.expect("this host delegates a cgroup scope")
.scope;
let enforcement = ShellEnforcement::environment_only()
.with_test_session_dirs()
.with_host_bounding(scope, None);
assert_eq!(
enforcement.nproc_bound(),
crate::resources::NprocBound::CgroupPidsMax
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_scoped_capsule_keeps_spawning_after_the_host_adds_threads() {
const TEST: &str = "a_scoped_capsule_keeps_spawning_after_the_host_adds_threads";
if !crate::cgroup::cgroup_delegation_available() {
crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
return;
}
let _lock = crate::resources::test_support::host_threads_lock();
let temp = tempfile::tempdir().unwrap();
let limits = crate::resources::HostResourceLimits {
max_processes: 32,
max_open_files: 256,
..crate::resources::HostResourceLimits::default()
};
let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_race", temp.path())
.expect("this host delegates a cgroup scope")
.scope;
assert!(
scope.is_some(),
"a required scope on Linux is always created"
);
let node_available = crate::sandbox::resolve_invoked_binary_path("node") != "node";
let mut shell_allow = vec!["bash".to_string()];
if node_available {
shell_allow.push("node".to_string());
}
let policy = CapabilityPolicy {
shell_allow,
resources: limits,
..CapabilityPolicy::default()
};
let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
enforcement.resource_limits = limits;
let enforcement = enforcement.with_host_bounding(scope, None);
let holders = crate::resources::test_support::HeldThreads::start(400);
let result = crate::shell::execute_shell(
"bash",
&[
"-c",
"for i in $(seq 1 40); do sleep 0.2 & done; wait; ulimit -Hu; ulimit -Hn",
],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.expect("bash must run");
assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
assert_eq!(result.resource_limit_hit, None);
let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
let lines: Vec<&str> = result.stdout.lines().collect();
assert_eq!(
lines,
[
crate::resources::test_support::rendered_rlimit(own_hard).as_str(),
"256"
],
"RLIMIT_NPROC is inherited unchanged and RLIMIT_NOFILE is the declared ceiling",
);
if node_available {
let script = "const { spawn } = require('child_process');\n\
const runs = Array.from({ length: 8 }, () => new Promise((resolve) => {\n\
const child = spawn(process.execPath, ['-e', 'process.exit(0)']);\n\
child.once('error', (error) => resolve(error.message));\n\
child.once('exit', (code) => resolve(code === 0 ? null : `exit ${code}`));\n\
}));\n\
Promise.all(runs).then((outcomes) => {\n\
const failures = outcomes.filter((outcome) => outcome !== null);\n\
if (failures.length > 0) { console.error(failures.join('\\n')); process.exit(1); }\n\
});\n";
let result = crate::shell::execute_shell(
"node",
&["-e", script],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.expect("node must run");
assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
} else {
crate::runtime_err!("[SKIP-HOST] {TEST}: no node on PATH, skipping the node half");
}
drop(holders);
}
#[cfg(target_os = "linux")]
#[test]
fn without_a_scope_the_nproc_baseline_is_measured_per_spawn() {
const TEST: &str = "without_a_scope_the_nproc_baseline_is_measured_per_spawn";
let _lock = crate::resources::test_support::host_threads_lock();
let temp = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
resources: crate::resources::HostResourceLimits {
max_processes: 64,
..crate::resources::HostResourceLimits::default()
},
..CapabilityPolicy::default()
};
let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
enforcement.resource_limits = policy.resources;
let before = crate::resources::uid_task_count().expect("Linux can count the uid's tasks");
let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
if own_hard != libc::RLIM_INFINITY && own_hard < before + 664 {
crate::runtime_err!(
"[SKIP-HOST] {TEST}: the inherited hard RLIMIT_NPROC is {own_hard}, too close to \
the uid's {before} tasks for 300 more threads to show in a child's ceiling"
);
return;
}
let hard_nproc = || -> u64 {
let result = crate::shell::execute_shell(
"bash",
&["-c", "ulimit -Hu"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.expect("bash must run");
assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
result.stdout.trim().parse().expect("a finite ceiling")
};
let holders = crate::resources::test_support::HeldThreads::start(300);
let held = hard_nproc();
assert!(
held >= before + 64 + 150,
"the ceiling must count the holder threads started after the enforcement was \
resolved: {held} with {before} tasks before them"
);
drop(holders);
let released = hard_nproc();
assert!(
released < held,
"the ceiling must drop once the holders exit: {released} after, {held} while held"
);
}
fn make_executable(path: &Path, contents: &str) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, contents).unwrap();
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
fn bootstrap_log_contents(workdir: &Path) -> String {
std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
}
#[test]
fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
let temp = tempfile::tempdir().unwrap();
let bin_dir = temp.path().join("bin");
std::fs::create_dir(&bin_dir).unwrap();
let tool = bin_dir.join("mytool");
make_executable(&tool, "#!/bin/sh\nexit 0\n");
let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
}
#[test]
fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
let temp = tempfile::tempdir().unwrap();
let resolved = resolve_exec_allowlist_in(
&["definitely-not-a-real-binary".to_string(), String::new()],
&[temp.path().to_path_buf()],
);
assert!(
resolved.is_empty(),
"unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
);
}
#[test]
fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
let resolved =
resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
assert!(resolved.is_empty());
}
#[test]
fn resolve_exec_allowlist_canonicalizes_path_matches_through_symlinks() {
let temp = tempfile::tempdir().unwrap();
let real = temp.path().join("real-tool");
make_executable(&real, "#!/bin/sh\nexit 0\n");
let bin_dir = temp.path().join("bin");
std::fs::create_dir(&bin_dir).unwrap();
std::os::unix::fs::symlink(&real, bin_dir.join("link-tool")).unwrap();
let resolved = resolve_exec_allowlist_in(&["link-tool".to_string()], &[bin_dir]);
assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
}
#[test]
fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
let temp = tempfile::tempdir().unwrap();
let bin_dir = temp.path().join("bin");
std::fs::create_dir(&bin_dir).unwrap();
let tool = bin_dir.join("mytool");
make_executable(&tool, "#!/bin/sh\nexit 0\n");
let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
assert_eq!(
resolved,
std::fs::canonicalize(&tool)
.unwrap()
.to_string_lossy()
.into_owned(),
"a resolvable name must report the canonical absolute path, not the bare name"
);
}
#[test]
fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
let temp = tempfile::tempdir().unwrap();
let path_dirs = [temp.path().to_path_buf()];
let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
assert_eq!(
resolved, "definitely-not-a-real-binary",
"an unresolvable name must fall back to the bare name, never error"
);
assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
}
#[test]
fn resolve_invoked_binary_path_takes_the_first_path_match() {
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("first");
let second = temp.path().join("second");
std::fs::create_dir(&first).unwrap();
std::fs::create_dir(&second).unwrap();
make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
assert_eq!(
resolved,
std::fs::canonicalize(first.join("mytool"))
.unwrap()
.to_string_lossy()
.into_owned(),
"reporting must match execvp: the first PATH hit is the one that ran"
);
}
#[test]
fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
assert_eq!(
resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
"mytool"
);
}
#[test]
fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
let temp = tempfile::tempdir().unwrap();
let real = temp.path().join("real-tool");
make_executable(&real, "#!/bin/sh\nexit 0\n");
let link = temp.path().join("link-tool");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert_eq!(
resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
std::fs::canonicalize(&real)
.unwrap()
.to_string_lossy()
.into_owned()
);
}
#[test]
fn parse_elf_extracts_interp_needed_and_runpath() {
let bytes = build_elf64(
Some("/lib64/ld-linux-x86-64.so.2"),
&["libc.so.6", "libm.so.6"],
Some("$ORIGIN/../lib:/opt/custom/lib"),
);
let deps = parse_elf_dependencies(bytes.as_slice()).expect("well-formed ELF64 must parse");
assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
assert_eq!(
deps.needed,
vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
);
assert_eq!(
deps.runpaths,
vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
"DT_RUNPATH must be split on ':' with empty parts dropped"
);
}
#[test]
fn parse_elf_static_binary_contributes_only_itself() {
let bytes = build_elf64(None, &[], None);
let deps =
parse_elf_dependencies(bytes.as_slice()).expect("a static ELF64 is still valid ELF");
assert_eq!(deps, ElfDependencies::default());
}
#[test]
fn parse_elf_rejects_non_elf_and_non_elf64() {
assert!(parse_elf_dependencies(&b"not an elf at all, way too short"[..]).is_none());
let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
elf32[4] = 1;
assert!(
parse_elf_dependencies(elf32.as_slice()).is_none(),
"32-bit ELF is not a binary this runtime execs and must not parse"
);
}
#[cfg(target_os = "linux")]
#[test]
#[allow(clippy::print_stdout)]
fn a_file_read_by_position_parses_exactly_as_its_bytes_in_memory() {
let mut binaries = Vec::new();
for name in ["python3", "perl", "bash", "git"] {
match find_on_path(name) {
Some(path) => binaries.push(std::fs::canonicalize(path).unwrap()),
None => println!("[SKIP] no {name} on PATH"),
}
}
let mut images: std::collections::BTreeSet<PathBuf> =
resolve_landlock_grants(&binaries).into_iter().collect();
let dynload = std::process::Command::new("python3")
.args(["-I", "-S", "-c", "import sys; print('\\n'.join(sys.path))"])
.output()
.ok()
.map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
.unwrap_or_default();
for dir in dynload.lines().filter(|line| line.ends_with("lib-dynload")) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
images.insert(entry.path());
}
}
let compare = |path: &Path| {
let Ok(bytes) = std::fs::read(path) else {
return;
};
let file = ElfFile::open_host(path).unwrap();
assert_eq!(
parse_elf_dependencies(bytes.as_slice()),
parse_elf_dependencies(&file),
"{}",
path.display()
);
assert_eq!(
elf_dynamic_symbol_names(bytes.as_slice()),
elf_dynamic_symbol_names(&file),
"{}",
path.display()
);
assert_eq!(
elf_is_shared_object(bytes.as_slice()),
elf_is_shared_object(&file),
"{}",
path.display()
);
};
for path in &images {
compare(path);
}
println!("compared {} host images", images.len());
let temp = tempfile::tempdir().unwrap();
let mut variants = 0;
for binary in &binaries {
let bytes = std::fs::read(binary).unwrap();
let mut cuts = vec![
0,
1,
17,
18,
63,
64,
100,
4096,
bytes.len() / 2,
bytes.len() - 1,
];
for field in [32usize, 40] {
if let Some(offset) = read_u64(&bytes, field, true) {
cuts.push(offset as usize + 30);
}
}
let mut copies: Vec<Vec<u8>> = cuts
.into_iter()
.filter(|cut| *cut <= bytes.len())
.map(|cut| bytes[..cut].to_vec())
.collect();
for field in [32usize, 40] {
let mut moved = bytes.clone();
moved[field..field + 8].copy_from_slice(&(bytes.len() as u64 - 8).to_le_bytes());
copies.push(moved);
}
let mut big_endian = bytes.clone();
big_endian[5] = 2;
copies.push(big_endian);
for copy in copies {
let path = temp.path().join(format!("variant-{variants}"));
std::fs::write(&path, ©).unwrap();
compare(&path);
variants += 1;
}
}
println!("compared {variants} malformed images");
}
#[cfg(target_os = "linux")]
#[test]
#[allow(clippy::print_stdout)]
fn resolving_a_closure_reads_no_binary_in_full() {
let (_temp, root) = crate::interpreter_runtime::tests::canonical_tempdir();
let Some(stand_in) = crate::interpreter_runtime::tests::dash_copy(&root, "padded") else {
return;
};
crate::interpreter_runtime::tests::pad_with_zeros(&stand_in, 64 << 20);
let unpadded = resolve_landlock_grants(&[std::fs::canonicalize("/bin/dash").unwrap()]);
let before = crate::interpreter_runtime::tests::thread_rchar();
let grants = resolve_landlock_grants(std::slice::from_ref(&stand_in));
let read = crate::interpreter_runtime::tests::thread_rchar() - before;
assert_eq!(grants.len(), unpadded.len(), "{grants:?}");
assert!(grants.contains(&stand_in));
assert!(
read < 8 * 1024 * 1024,
"resolving the closure read {read} bytes"
);
println!("resolving the padded stand-in's closure read {read} bytes");
}
#[test]
fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
let temp = tempfile::tempdir().unwrap();
let origin = temp.path().join("app");
std::fs::create_dir(&origin).unwrap();
let libs = temp.path().join("libs");
std::fs::create_dir(&libs).unwrap();
std::fs::write(libs.join("libc.so.6"), "x").unwrap();
assert_eq!(
resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
);
assert_eq!(
resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
None
);
let origin_lib = origin.join("lib");
std::fs::create_dir(&origin_lib).unwrap();
std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
assert_eq!(
resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
);
}
#[test]
fn resolve_landlock_grants_derives_interp_and_needed_closure() {
let temp = tempfile::tempdir().unwrap();
let bin_dir = temp.path().join("bin");
let lib_dir = temp.path().join("lib");
std::fs::create_dir(&bin_dir).unwrap();
std::fs::create_dir(&lib_dir).unwrap();
let interp = bin_dir.join("ld-fake.so");
std::fs::write(&interp, "not really elf").unwrap();
std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
let prog = bin_dir.join("prog");
let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
std::fs::write(&prog, &elf).unwrap();
let grants =
resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
assert!(
grants.contains(&expect(prog)),
"the binary itself must be granted: {grants:?}"
);
assert!(
grants.contains(&expect(interp)),
"the ELF interpreter must be granted: {grants:?}"
);
assert!(
grants.contains(&expect(lib_dir.join("libfake.so.1"))),
"each resolved DT_NEEDED library must be granted: {grants:?}"
);
}
#[test]
fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
let temp = tempfile::tempdir().unwrap();
let bin_dir = temp.path().join("bin");
std::fs::create_dir(&bin_dir).unwrap();
let prog = bin_dir.join("prog");
std::fs::write(
&prog,
build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
)
.unwrap();
let missing = bin_dir.join("does-not-exist");
let grants =
resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
}
fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
InterpreterRuntimeGrant {
binary: binary.to_string(),
dirs: dirs
.iter()
.map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
path: path.to_string(),
list_dir: *list_dir,
})
.collect(),
}
}
#[test]
fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
let grants = resolve_interpreter_runtime_grants(&[interp_grant(
"python3",
&[
("/usr/lib/python3.11", true),
("/usr/lib/python3.11/lib-dynload", false),
],
)]);
assert_eq!(
grants,
vec![
LandlockGrant {
path: PathBuf::from("/usr/lib/python3.11"),
list_dir: true,
executable: true,
},
LandlockGrant {
path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
list_dir: false,
executable: true,
},
]
);
}
#[test]
fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
let grants = resolve_interpreter_runtime_grants(&[
interp_grant("python3", &[("/opt/py/nonexistent", false)]),
interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
]);
assert_eq!(
grants,
vec![
LandlockGrant {
path: PathBuf::from("/opt/py/nonexistent"),
list_dir: false,
executable: true,
},
LandlockGrant {
path: PathBuf::from("/opt/rb/also-nonexistent"),
list_dir: true,
executable: true,
},
]
);
assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
}
#[test]
fn non_listable_files_marks_every_closure_file_non_enumerable() {
let wrapped = LandlockGrant::non_listable_files(vec![
PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
PathBuf::from("/usr/bin/bash"),
]);
assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
assert_eq!(
wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
vec![
PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
PathBuf::from("/usr/bin/bash"),
]
);
}
#[test]
fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
.iter()
.map(|device| (device.path, device.writable))
.collect();
assert_eq!(
listed,
vec![
("/dev/null", true),
("/dev/zero", false),
("/dev/urandom", false),
],
"the capsule device set is fixed at exactly these three paths and these access \
levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
both. Widening this list is allowed only on demonstrated workload failure, and the \
failure gets recorded alongside the widening (see the manual acceptance procedure on \
the diagnostics reference page)."
);
}
#[test]
fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
for excluded in [
"/dev/random",
"/dev/full",
"/dev/tty",
"/dev/console",
"/dev/mem",
"/dev/sda",
"/dev",
] {
assert!(
!CAPSULE_DEVICE_GRANTS
.iter()
.any(|device| device.path == excluded),
"{excluded} must not be in the capsule device set — it is denied by the ordinary \
no-matching-rule path, and adding it here would be the only way to change that"
);
}
}
#[test]
fn capsule_device_grants_name_absolute_paths_under_dev() {
for device in CAPSULE_DEVICE_GRANTS {
assert!(
device.path.starts_with("/dev/"),
"{} must be an absolute path under /dev",
device.path
);
}
}
#[test]
fn denied_socket_domains_denies_unix_by_default() {
let denied = denied_socket_domains(false);
assert!(
denied.contains(&LINUX_AF_UNIX),
"AF_UNIX must be denied when the manifest does not declare \
capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
);
}
#[test]
fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
let denied = denied_socket_domains(true);
assert!(
!denied.contains(&LINUX_AF_UNIX),
"a capsule that declared capabilities.network.unix_sockets: true must get no \
AF_UNIX deny rule at all"
);
}
#[test]
fn denied_socket_domains_always_denies_netlink_and_packet() {
for unix_sockets_allowed in [false, true] {
let denied = denied_socket_domains(unix_sockets_allowed);
assert!(
denied.contains(&LINUX_AF_NETLINK),
"AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
{unix_sockets_allowed})"
);
assert!(
denied.contains(&LINUX_AF_PACKET),
"AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
{unix_sockets_allowed})"
);
}
}
#[test]
fn denied_socket_domains_never_denies_ip_families() {
for unix_sockets_allowed in [false, true] {
let denied = denied_socket_domains(unix_sockets_allowed);
assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
}
}
#[test]
fn denied_socket_domains_returns_exactly_the_documented_sets() {
assert_eq!(
denied_socket_domains(false),
vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
);
assert_eq!(
denied_socket_domains(true),
vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_socket_domain_constants_match_libc() {
assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
}
#[test]
fn socket_domain_allow_and_deny_sets_never_overlap() {
for unix_sockets_allowed in [false, true] {
let denied = denied_socket_domains(unix_sockets_allowed);
for domain in allowed_socket_domains(unix_sockets_allowed) {
assert!(
!denied.contains(&domain),
"socket domain {domain} is both allowed and denied \
(unix_sockets_allowed = {unix_sockets_allowed})"
);
}
}
}
#[test]
fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
assert_eq!(
allowed_socket_domains(false),
vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
);
assert_eq!(
allowed_socket_domains(true),
vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
);
}
#[test]
fn allowlist_contains_no_syscall_that_must_stay_denied() {
for name in SECCOMP_MUST_STAY_DENIED {
assert!(
!SECCOMP_SYSCALL_ALLOWLIST.contains(name),
"{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
in the same edit and say why in the commit"
);
}
}
#[test]
fn allowlist_permits_exec_because_landlock_now_decides_it() {
for name in ["execve", "execveat"] {
assert!(
SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
"{name} must reach the kernel — the default action is a deny, so omitting it \
refuses the child's own exec and nothing runs"
);
}
}
#[test]
fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
assert!(
!SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
"socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
);
}
#[test]
fn allowlist_has_no_duplicates() {
let mut seen = std::collections::HashSet::new();
for name in SECCOMP_SYSCALL_ALLOWLIST {
assert!(seen.insert(*name), "{name} appears twice in the allowlist");
}
}
#[test]
fn allowlist_covers_the_syscalls_no_process_can_run_without() {
for name in [
"read",
"write",
"openat",
"close",
"mmap",
"munmap",
"brk",
"exit_group",
"futex",
"rt_sigreturn",
"clone",
"wait4",
"getpid",
"getdents64",
] {
assert!(
SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
"{name} is missing from the allowlist — no process could run under this filter"
);
}
}
#[test]
fn network_ip_allowed_matches_only_the_resolved_allowlist() {
let allow = vec![IpAddr::from([127, 0, 0, 1])];
assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
assert!(
!network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
"an empty resolved allowlist must deny every destination"
);
}
#[test]
fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
assert_eq!(
tier_warning(EnforcementTier::KernelFull, false),
Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
);
assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
}
#[test]
fn tier_warning_kernel_sealed_states_what_proc_is() {
let (code, notice) = tier_warning(EnforcementTier::KernelSealed, false).unwrap();
assert_eq!(code, W_SEC_005);
assert!(!notice.contains("no PID namespace"), "{notice}");
assert!(notice.contains("own PID namespace"), "{notice}");
assert!(notice.contains("private procfs"), "{notice}");
assert!(notice.contains("no host process appears"), "{notice}");
assert!(
notice.contains("Landlock refuses every open beneath it"),
"{notice}"
);
assert!(notice.contains("stat(2) on /proc/<pid>"), "{notice}");
assert!(!notice.contains("any process of the same user"), "{notice}");
assert_eq!(tier_warning(EnforcementTier::KernelSealed, true), None);
}
#[test]
fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
assert_eq!(
tier_warning(EnforcementTier::KernelSeccompOnly, false),
Some((W_SEC_002, SECCOMP_ONLY_WARNING))
);
assert_eq!(
tier_warning(EnforcementTier::EnvironmentOnly, false),
Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
);
assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
}
#[test]
fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
assert_eq!(
aggregate_bounding_warning(false, true, false),
Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
);
assert_eq!(
aggregate_bounding_warning(false, false, false),
None,
"a capsule that cannot spawn a subprocess has nothing to bound"
);
assert_eq!(
aggregate_bounding_warning(true, true, false),
None,
"on Linux this case is a refused launch, not a warning"
);
assert_eq!(
aggregate_bounding_warning(true, true, true),
None,
"a scope exists, so there is no gap"
);
}
#[test]
fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
assert!(message.contains("per-uid"), "message was: {message}");
assert!(message.contains("fork bomb"), "message was: {message}");
assert!(message.contains("pids.max"), "message was: {message}");
}
#[test]
fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
let temp = tempfile::tempdir().unwrap();
warn_for_missing_aggregate_bounding(
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
true,
false,
);
let log = bootstrap_log_contents(temp.path());
if cfg!(target_os = "linux") {
assert!(
log.is_empty(),
"Linux refuses the launch instead of warning: {log}"
);
} else {
assert!(log.contains(W_SEC_010), "log was: {log}");
assert!(
log.contains(&security_warning_link(W_SEC_010)),
"log was: {log}"
);
}
}
fn warning_test_policy() -> CapabilityPolicy {
CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
network_allow: vec!["https://api.example.com".to_string()],
..CapabilityPolicy::default()
}
}
#[test]
fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
let temp = tempfile::tempdir().unwrap();
warn_for_enforcement_tier(
EnforcementTier::KernelFull,
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&warning_test_policy(),
);
let log = bootstrap_log_contents(temp.path());
assert!(
log.contains(W_SEC_005),
"KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
);
assert!(
log.contains("denied, not absent"),
"must state the scope's documented limit: paths outside the workdir are denied, not \
absent: {log}"
);
assert!(
log.contains("read+execute") && log.contains("outside the workdir"),
"must describe what is now actually granted (narrow, derived read+execute outside \
the workdir), not an unfixed grant bug: {log}"
);
assert!(
log.contains(&security_warning_link(W_SEC_005)),
"must link to the diagnostics doc page: {log}"
);
}
#[test]
fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
let temp = tempfile::tempdir().unwrap();
warn_for_enforcement_tier(
EnforcementTier::KernelFull,
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&CapabilityPolicy::default(),
);
assert_eq!(
bootstrap_log_contents(temp.path()),
"",
"with no shell declared there is no subprocess to enforce on — stay silent"
);
}
#[test]
fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
let temp = tempfile::tempdir().unwrap();
warn_for_enforcement_tier(
EnforcementTier::KernelSeccompOnly,
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&warning_test_policy(),
);
let log = bootstrap_log_contents(temp.path());
assert!(
log.contains("Landlock"),
"must name the missing primitive: {log}"
);
assert!(
log.contains("filesystem"),
"must name what is not enforced: {log}"
);
assert!(
log.contains(W_SEC_002),
"must carry its warning code: {log}"
);
assert!(
log.contains(&security_warning_link(W_SEC_002)),
"must link to the diagnostics doc page: {log}"
);
}
#[test]
fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
let temp = tempfile::tempdir().unwrap();
warn_for_enforcement_tier(
EnforcementTier::EnvironmentOnly,
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&warning_test_policy(),
);
let log = bootstrap_log_contents(temp.path());
assert!(
log.contains("permanent"),
"must state the tier is permanent, not pending: {log}"
);
assert!(
log.contains("bash"),
"the still-accurate bash/network bypass warning must also fire: {log}"
);
assert!(
log.contains(W_SEC_001),
"must carry the tier warning code: {log}"
);
assert!(
log.contains(W_SEC_003),
"must carry the bash/network bypass warning code: {log}"
);
}
struct ForcePrepareFailureGuard;
impl ForcePrepareFailureGuard {
fn new() -> Self {
FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
Self
}
}
impl Drop for ForcePrepareFailureGuard {
fn drop(&mut self) {
FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
}
}
#[test]
fn execute_shell_fails_closed_when_enforcement_setup_fails() {
let temp = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let marker = temp.path().join("spawned-anyway.marker");
let script = format!("echo ran > '{}'", marker.display());
{
let _guard = ForcePrepareFailureGuard::new();
let error = crate::shell::execute_shell(
"bash",
&["-c", &script],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&ShellEnforcement::environment_only().with_test_session_dirs(),
)
.unwrap_err();
assert!(
error.to_string().contains("sandbox"),
"the error must name what failed to initialize: {error}"
);
assert!(
!marker.exists(),
"no subprocess may be spawned at all when enforcement setup fails"
);
}
let result = crate::shell::execute_shell(
"bash",
&["-c", &script],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&ShellEnforcement::environment_only().with_test_session_dirs(),
)
.unwrap();
assert_eq!(result.exit_code, 0);
assert!(marker.exists());
}
}
#[cfg(target_os = "linux")]
#[cfg(test)]
#[allow(clippy::print_stdout, clippy::print_stderr)]
mod landlock_probe_isolation {
#[test]
fn the_landlock_probe_does_not_restrict_the_calling_process() {
let before = crate::namespace_probe::probe_namespaces().sealed_root;
let _ = super::linux_enforce::probe_landlock_full_access();
let after = crate::namespace_probe::probe_namespaces().sealed_root;
assert_eq!(
before, after,
"the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
it has restricted the calling process instead of a forked child, which permanently \
forbids the mount family to this process and everything it spawns",
);
}
}
#[cfg(all(test, target_os = "linux"))]
#[allow(clippy::print_stdout, clippy::print_stderr)]
mod linux_integration_tests {
use std::path::Path;
use super::*;
fn host_bounding_base() -> ShellEnforcement {
let base = tempfile::tempdir().unwrap().keep();
ShellEnforcement::environment_only().with_runtime_dir(test_runtime_dir(&base), None)
}
#[test]
fn workdir_landlock_grant_withholds_device_and_socket_creation() {
use landlock::{Access, AccessFs, ABI};
assert_eq!(
LANDLOCK_TARGET_ABI, 3,
"this test pins the ABI v3 set; raising the cap needs this test revisited with it"
);
let all_v3 = AccessFs::from_all(ABI::V3);
let granted = linux_enforce::workdir_access_rights(true);
for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
assert!(
all_v3.contains(withheld),
"{withheld:?} is expected to be part of Landlock ABI v3 — if it is not, this \
test's premise (and the workdir grant's comment) needs revisiting"
);
assert!(
!granted.contains(withheld),
"the workdir grant must withhold {withheld:?}: with the full ABI set declared \
by handle_access, withholding it here is what makes it denied rather than merely \
un-granted"
);
}
for added in [AccessFs::Refer, AccessFs::Truncate] {
assert!(
granted.contains(added),
"the workdir grant must carry {added:?}: without Refer a move between workdir \
folders is EXDEV, and without Truncate a v3 ruleset refuses truncating the \
capsule's own files"
);
}
assert!(
granted.contains(AccessFs::MakeFifo),
"MakeFifo stays granted — real build tooling creates named pipes in its working tree"
);
let expected = all_v3 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
assert_eq!(
granted, expected,
"the workdir_exec grant must be exactly Landlock ABI v3 minus \
MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
this test and the constant's comment together"
);
}
#[test]
fn workdir_rights_masked_to_abi_v1_equal_the_pre_refer_set() {
use landlock::{Access, AccessFs, ABI};
let all_v1 = AccessFs::from_all(ABI::V1);
let masked = linux_enforce::workdir_access_rights(true) & all_v1;
assert_eq!(
masked,
all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock),
"masked to ABI v1, the workdir grant must be the v1 set minus the three Make* rights"
);
assert!(!masked.contains(AccessFs::Refer));
assert!(!masked.contains(AccessFs::Truncate));
assert_eq!(
linux_enforce::workdir_access_rights(false) & all_v1,
masked & !AccessFs::Execute,
"masking must not move the workdir_exec axis"
);
}
#[test]
fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
use landlock::AccessFs;
assert!(
!linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
"the default must withhold Execute from the workdir — that withholding is the whole \
of what enforces capabilities.shell.allow now that the exec supervisor is gone"
);
assert!(
linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
"capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
compile-and-run workflows it exists for silently stop working"
);
assert_eq!(
linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
linux_enforce::workdir_access_rights(false),
"workdir_exec must be a one-bit axis — no other right may ride along with it"
);
}
fn sealed_with_runtime_dir(base: &Path) -> ShellEnforcement {
ShellEnforcement {
tier: EnforcementTier::KernelSealed,
..ShellEnforcement::environment_only().with_test_session_dirs()
}
.with_runtime_dir(test_runtime_dir(base), None)
}
fn temp_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
enforcement
.runtime_dir
.as_ref()
.and_then(|roots| roots.temp.as_ref())
.map(|temp| Arc::new(temp.path_fd().unwrap()))
}
fn home_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
enforcement
.home_root()
.map(|home| Arc::new(home.path_fd().unwrap()))
}
#[test]
fn the_temp_folder_and_runtime_dir_are_opened_for_landlock_and_the_sealed_bind() {
let accessible = tempfile::tempdir().unwrap();
let runtime = tempfile::tempdir().unwrap();
let enforcement = sealed_with_runtime_dir(runtime.path());
let temp_dir_fd = temp_dir_fd_of(&enforcement);
let sources = SealedSourceFds::open(
&enforcement,
temp_dir_fd.as_ref(),
home_dir_fd_of(&enforcement).as_ref(),
)
.expect("a real, writable workdir must yield the sealed sources");
assert_eq!(
sources.tmp_store_path,
enforcement.temp_dir_path().unwrap(),
"the composed root binds the session's temporary folder at /tmp"
);
assert_eq!(
sources.home_dir.1,
enforcement.home_dir_path().unwrap(),
"the composed root binds the session's home folder at its own path"
);
let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
sealed: Some(&sources),
temp_dir_fd,
home_dir_fd: Some(Arc::clone(&sources.home_dir.0)),
runtime_dir_fd: Some(Arc::clone(&sources.runtime_dir)),
records_dir_fd: sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
})
.expect("a real, writable workdir must yield sealed Landlock fds");
assert!(fds.temp_dir_fd().is_some());
assert!(
fds.home_dir_fd().is_some(),
"the home folder takes the temporary folder's rule"
);
assert!(fds.runtime_dir_fd().is_some());
assert!(
fds.records_dir_fd().is_some(),
"the records directory takes the runtime directory's read-only rule"
);
assert_eq!(
std::fs::read_dir(accessible.path()).unwrap().count(),
0,
"enabling sealed must add nothing to the work folder"
);
let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
accessible.path(),
))
.expect("a real, writable workdir must yield Landlock fds on any tier");
assert!(
fds.temp_dir_fd().is_none(),
"an enforcement holding no temporary folder grants none"
);
assert!(fds.home_dir_fd().is_none());
}
#[test]
fn a_sealed_root_needs_the_temp_folder() {
let runtime = tempfile::tempdir().unwrap();
let mut roots = test_runtime_dir(runtime.path());
roots.temp = None;
let enforcement = ShellEnforcement {
tier: EnforcementTier::KernelSealed,
..ShellEnforcement::environment_only().with_test_session_dirs()
}
.with_runtime_dir(roots, None);
let error =
SealedSourceFds::open(&enforcement, None, home_dir_fd_of(&enforcement).as_ref())
.err()
.expect("refused");
assert!(error.contains("temporary folder"), "{error}");
}
#[test]
fn no_home_folder_refuses_a_sealed_root() {
let runtime = tempfile::tempdir().unwrap();
let roots = crate::runtime_dir::RuntimeDirRoots {
home: None,
..test_runtime_dir(runtime.path())
};
let enforcement = ShellEnforcement {
tier: EnforcementTier::KernelSealed,
..ShellEnforcement::environment_only().with_test_session_dirs()
}
.with_runtime_dir(roots, None);
let error =
SealedSourceFds::open(&enforcement, temp_dir_fd_of(&enforcement).as_ref(), None)
.err()
.expect("refused");
assert!(error.contains("no home folder"), "{error}");
assert!(!runtime
.path()
.join(crate::runtime_dir::SEALED_ETC_SUBDIR)
.join("passwd")
.exists());
}
#[test]
fn the_synthetic_etc_files_are_written_into_sealed_etc_and_opened() {
let accessible = tempfile::tempdir().unwrap();
let runtime = tempfile::tempdir().unwrap();
let enforcement = sealed_with_runtime_dir(runtime.path());
let sources = SealedSourceFds::open(
&enforcement,
temp_dir_fd_of(&enforcement).as_ref(),
home_dir_fd_of(&enforcement).as_ref(),
)
.unwrap();
let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
sealed: Some(&sources),
..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
})
.expect("a real, writable workdir must yield sealed Landlock fds");
let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
.iter()
.filter(|entry| entry.synthetic.is_some())
.collect();
assert_eq!(
fds.sealed_identity_fd_count(),
synthetic.len(),
"every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
);
let etc_dir = runtime.path().join(crate::runtime_dir::SEALED_ETC_SUBDIR);
for entry in &synthetic {
let path = crate::sealed::synthetic_etc_source(&etc_dir, entry.synthetic.unwrap());
assert!(
path.is_file(),
"{} must be written into sealed-etc/",
entry.path
);
}
assert!(
!accessible.path().join(".mur-etc").exists(),
"the work folder gained .mur-etc"
);
let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
accessible.path(),
))
.unwrap();
assert_eq!(fds.sealed_identity_fd_count(), 0);
}
#[test]
fn a_sealed_root_needs_the_runtime_directory() {
let enforcement = ShellEnforcement {
tier: EnforcementTier::KernelSealed,
..ShellEnforcement::environment_only()
};
let error = SealedSourceFds::open(&enforcement, None, None)
.err()
.expect("refused");
assert!(error.contains("runtime directory"), "{error}");
}
#[test]
fn home_folder_is_the_synthetic_passwd_entrys_home_and_home() {
use std::os::unix::fs::MetadataExt;
let runtime = tempfile::tempdir().unwrap();
let roots = test_runtime_dir(runtime.path());
let home = roots.home.clone().unwrap();
let written = ensure_sealed_identity_files(&roots.sealed_etc, &home).unwrap();
let env = crate::shell::build_shell_env(
&crate::types::CapabilityPolicy::default(),
&[],
Some(home.path()),
None,
None,
None,
)
.unwrap();
let passwd = std::fs::read_to_string(&written[0]).unwrap();
let owner = std::fs::metadata(home.path()).unwrap();
let entry: Vec<&str> = passwd
.lines()
.last()
.expect("the capsule's own entry is the last line")
.split(':')
.collect();
assert_eq!(entry[2], owner.uid().to_string(), "uid field");
assert_eq!(entry[3], owner.gid().to_string(), "gid field");
assert_eq!(
Some(&entry[5].to_string()),
env.get("HOME"),
"pw_dir and $HOME are the same string or the entry is worse than useless",
);
assert_eq!(
entry[5],
runtime
.path()
.join(crate::records_dir::RECORDS_DIR_NAME)
.join(crate::records_dir::HOME_SUBDIR)
.to_str()
.unwrap()
);
let group = std::fs::read_to_string(&written[1]).unwrap();
assert_eq!(
group.lines().last().unwrap().split(':').nth(2),
Some(owner.gid().to_string().as_str()),
"gid field of the capsule's own group",
);
}
fn sealed_planted() -> (
tempfile::TempDir,
PathBuf,
PathBuf,
crate::workdir_fs::WorkdirRoot,
) {
let dir = tempfile::tempdir().unwrap();
let accessible = dir.path().join("work");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&accessible).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("sentinel.txt"), b"operator data").unwrap();
let root = crate::workdir_fs::WorkdirRoot::open(&accessible).unwrap();
(dir, root.path().to_path_buf(), outside, root)
}
#[test]
fn sealed_identity_files_keep_their_inode() {
use std::os::unix::fs::MetadataExt;
let (_dir, _session, _outside, root) = sealed_planted();
let runtime = tempfile::tempdir().unwrap();
let roots = test_runtime_dir(runtime.path());
let passwd = roots.sealed_etc.path().join("passwd");
ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
let first = std::fs::metadata(&passwd).unwrap().ino();
ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
assert_eq!(std::fs::metadata(&passwd).unwrap().ino(), first);
assert!(root.take_refusals().is_empty());
}
#[test]
fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
use landlock::AccessFs;
for workdir_exec in [false, true] {
let rights = linux_enforce::workdir_access_rights(workdir_exec);
for needed in [
AccessFs::WriteFile,
AccessFs::ReadFile,
AccessFs::ReadDir,
AccessFs::MakeReg,
AccessFs::MakeDir,
AccessFs::RemoveFile,
AccessFs::RemoveDir,
] {
assert!(
rights.contains(needed),
"/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
`cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
);
}
assert_eq!(
rights.contains(AccessFs::Execute),
workdir_exec,
"/tmp is backed by a directory inside the workdir, so Execute there must follow \
capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
make /tmp a hole through which capsule-written bytes run",
);
}
assert_eq!(
linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
linux_enforce::workdir_access_rights(false),
"the /tmp rule and the workdir rule share one right-set binding in \
apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
that binding must be revisited together",
);
}
fn unconfined_enforcement(declared: murmur_artifact::ResourceCapabilities) -> ShellEnforcement {
ShellEnforcement {
tier: EnforcementTier::Unconfined,
declared_resources: declared,
temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
proc_view: crate::proc_view::tier_proc_view(EnforcementTier::Unconfined),
..ShellEnforcement::environment_only()
}
.with_test_session_dirs()
}
fn status_line(status: &str, field: &str) -> String {
status
.lines()
.find(|line| line.starts_with(field))
.unwrap_or_else(|| panic!("no {field} line in {status}"))
.to_string()
}
fn spawn_unconfined(enforcement: &ShellEnforcement, script: &str) -> String {
let mut command = std::process::Command::new("bash");
command.args(["-c", script]);
let supervisor = prepare_enforcement(&mut command, enforcement, Path::new("/tmp"))
.expect("the unconfined tier installs nothing that can fail");
assert!(matches!(supervisor, SupervisorHandle::Noop));
let output = command.output().expect("bash runs");
assert!(output.status.success(), "{output:?}");
String::from_utf8(output.stdout).unwrap()
}
#[test]
fn an_unconfined_child_keeps_no_new_privs_capabilities_and_network_namespace() {
let enforcement = unconfined_enforcement(Default::default());
let child = spawn_unconfined(
&enforcement,
"cat /proc/self/status; echo \"netns=$(readlink /proc/self/ns/net)\"",
);
let own = std::fs::read_to_string("/proc/self/status").unwrap();
for field in ["NoNewPrivs:", "CapEff:"] {
assert_eq!(
status_line(&child, field),
status_line(&own, field),
"{child}"
);
}
let own_netns = std::fs::read_link("/proc/self/ns/net").unwrap();
assert!(
child.contains(&format!("netns={}", own_netns.display())),
"{child}"
);
assert_eq!(
enforcement.session_netns.creations(),
0,
"the unconfined tier creates no network namespace"
);
}
#[test]
fn an_unconfined_child_gets_only_the_declared_rlimits() {
let read = |resource| {
let limit = rustix::process::getrlimit(resource);
let show = |value: Option<u64>| {
value.map_or_else(|| "unlimited".to_string(), |value| value.to_string())
};
format!("{} {}", show(limit.current), show(limit.maximum))
};
let undeclared = spawn_unconfined(
&unconfined_enforcement(Default::default()),
"echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
);
assert_eq!(
undeclared,
format!(
"{}\n{}\n",
read(rustix::process::Resource::Nofile),
read(rustix::process::Resource::Core)
)
);
let declared = spawn_unconfined(
&unconfined_enforcement(murmur_artifact::ResourceCapabilities {
max_open_files: Some(256),
..Default::default()
}),
"echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
);
assert_eq!(
declared,
format!("256 256\n{}\n", read(rustix::process::Resource::Core))
);
}
#[test]
fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
let mut command = std::process::Command::new("true");
let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
let supervisor = prepare_enforcement(&mut command, &enforcement, Path::new("/tmp"))
.expect("environment_only must never fail");
assert!(matches!(supervisor, SupervisorHandle::Noop));
assert_eq!(
enforcement.session_netns.creations(),
0,
"a tier with no kernel sandbox must not create a network namespace"
);
}
#[test]
fn kernel_tier_allows_exec_within_shell_allowlist() {
if crate::network_namespace::skip_without_egress_namespace(
"kernel_tier_allows_exec_within_shell_allowlist",
) {
return;
}
let tier = detect_enforcement_tier();
if tier == EnforcementTier::EnvironmentOnly {
eprintln!(
"skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
EnvironmentOnly on a Linux host — degrading gracefully"
);
return;
}
let temp = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let enforcement = ShellEnforcement {
tier,
network_allow_ips: Vec::new(),
unix_sockets_allowed: policy.unix_sockets_allowed,
landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
&resolve_exec_allowlist(&policy.shell_allow),
)),
..host_bounding_base()
};
let result = crate::shell::execute_shell(
"bash",
&["-c", "exit 7"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.unwrap();
assert_eq!(
result.exit_code, 7,
"bash itself is allowlisted, so kernel enforcement must permit its own execve — \
proving the allowlist isn't a blanket deny"
);
}
#[test]
fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
if crate::network_namespace::skip_without_egress_namespace(
"kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
) {
return;
}
let tier = detect_enforcement_tier();
if tier == EnforcementTier::EnvironmentOnly {
eprintln!(
"skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
resolved to EnvironmentOnly on a Linux host — degrading gracefully"
);
return;
}
let temp = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string(), "ls".to_string()],
..CapabilityPolicy::default()
};
let enforcement = ShellEnforcement {
tier,
network_allow_ips: Vec::new(),
unix_sockets_allowed: policy.unix_sockets_allowed,
landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
&resolve_exec_allowlist(&policy.shell_allow),
)),
..host_bounding_base()
};
let result = crate::shell::execute_shell(
"bash",
&["-c", "ls"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.unwrap();
assert_eq!(
result.exit_code, 0,
"a second allowlisted binary exec'd from inside allowlisted bash must succeed \
(stderr: {})",
result.stderr
);
}
#[test]
fn kernel_tier_denies_network_connect_outside_allowlist() {
if crate::network_namespace::skip_without_egress_namespace(
"kernel_tier_denies_network_connect_outside_allowlist",
) {
return;
}
let tier = detect_enforcement_tier();
if tier == EnforcementTier::EnvironmentOnly {
eprintln!(
"skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
EnvironmentOnly on a Linux host — degrading gracefully"
);
return;
}
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let temp = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let enforcement = ShellEnforcement {
tier,
network_allow_rules: Vec::new(),
network_allow_ips: Vec::new(),
unix_sockets_allowed: policy.unix_sockets_allowed,
landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
&resolve_exec_allowlist(&policy.shell_allow),
)),
..host_bounding_base()
};
let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
let result = crate::shell::execute_shell(
"bash",
&["-c", &script],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.unwrap();
assert_ne!(
result.exit_code, 0,
"a direct connect() to a destination outside capabilities.network.allow must fail \
even though the port is really open on the host — the subprocess is in its own \
network namespace with no route to it"
);
}
#[test]
fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
if crate::network_namespace::skip_without_egress_namespace(
"kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
) {
return;
}
let tier = detect_enforcement_tier();
if tier == EnforcementTier::EnvironmentOnly {
eprintln!(
"skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
resolved to EnvironmentOnly on a Linux host — degrading gracefully"
);
return;
}
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
use std::io::Write;
let _ = stream.write_all(b"pong\n");
}
});
let temp = tempfile::tempdir().unwrap();
let allow = vec![format!("http://127.0.0.1:{port}")];
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
network_allow: allow.clone(),
..CapabilityPolicy::default()
};
let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
let enforcement = ShellEnforcement {
tier,
egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
network_allow_rules: rules,
network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
unix_sockets_allowed: policy.unix_sockets_allowed,
landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
&resolve_exec_allowlist(&policy.shell_allow),
)),
..host_bounding_base()
};
let script =
format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
let result = crate::shell::execute_shell(
"bash",
&["-c", &script],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.unwrap();
let _ = server.join();
assert_eq!(
result.exit_code, 0,
"a destination inside capabilities.network.allow must stay reachable — enforcement \
adds denials for out-of-policy destinations, it does not degrade permitted ones \
(stderr: {})",
result.stderr
);
assert!(
result.stdout.contains("pong"),
"the host listener's bytes must come back through the proxy unmodified \
(stdout: {:?}, stderr: {})",
result.stdout,
result.stderr
);
}
#[test]
fn kernel_full_denies_filesystem_access_outside_workdir() {
if crate::network_namespace::skip_without_egress_namespace(
"kernel_full_denies_filesystem_access_outside_workdir",
) {
return;
}
let tier = detect_enforcement_tier();
if tier != EnforcementTier::KernelFull {
eprintln!(
"skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
instead"
);
return;
}
let temp = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let enforcement = ShellEnforcement {
tier,
network_allow_ips: Vec::new(),
unix_sockets_allowed: policy.unix_sockets_allowed,
landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
&resolve_exec_allowlist(&policy.shell_allow),
)),
..host_bounding_base()
};
let result = crate::shell::execute_shell(
"bash",
&["-c", "cat /etc/hostname"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.unwrap();
assert_ne!(
result.exit_code, 0,
"reading a file outside the Landlock-scoped workdir must fail under KernelFull"
);
}
fn scoped_landlock_enforcement(
test: &str,
policy: &CapabilityPolicy,
min_abi: u8,
) -> Option<ShellEnforcement> {
if crate::network_namespace::skip_without_egress_namespace(test) {
return None;
}
let probe = HostProbe::probe();
let enforcement = ShellEnforcement::resolve_declared(
policy,
murmur_artifact::ContainmentClass::Scoped,
probe.clone(),
)
.unwrap()
.with_test_session_dirs();
if enforcement.tier != EnforcementTier::KernelFull
|| enforcement.landlock_abi < Some(min_abi)
{
eprintln!(
"skipping {test}: needs EnforcementTier::KernelFull with a Landlock ruleset at ABI \
{min_abi} or later; resolved {:?} at ABI {:?} (kernel reports {:?})",
enforcement.tier,
enforcement.landlock_abi,
probe.landlock_kernel_abi()
);
return None;
}
Some(enforcement)
}
fn coreutils_policy(extra: &[&str]) -> CapabilityPolicy {
let mut shell_allow: Vec<String> = ["bash", "mkdir", "mv", "ln", "stat", "cat"]
.iter()
.map(|name| name.to_string())
.collect();
shell_allow.extend(extra.iter().map(|name| name.to_string()));
CapabilityPolicy {
shell_allow,
..CapabilityPolicy::default()
}
}
fn run_bash_in(
workdir: &Path,
script: &str,
policy: &CapabilityPolicy,
enforcement: &ShellEnforcement,
) -> crate::shell::ShellResult {
let result = crate::shell::execute_shell(
"bash",
&["-c", script],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
policy,
enforcement,
)
.unwrap();
println!(
"exit {}\n--- stdout\n{}--- stderr\n{}---",
result.exit_code, result.stdout, result.stderr
);
result
}
fn work_and_outside() -> (tempfile::TempDir, PathBuf, PathBuf) {
let base = tempfile::tempdir().unwrap();
let work = base.path().join("work");
let outside = base.path().join("outside");
std::fs::create_dir(&work).unwrap();
std::fs::create_dir(&outside).unwrap();
(base, work, outside)
}
#[test]
fn kernel_tier_moves_files_between_workdir_folders() {
let test = "kernel_tier_moves_files_between_workdir_folders";
let policy = coreutils_policy(&[]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
return;
};
assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
assert_eq!(enforcement.landlock_abi, Some(3));
let temp = tempfile::tempdir().unwrap();
let result = run_bash_in(
temp.path(),
r#"set -e
mkdir a b
echo payload > a/x
before=$(stat -c %i a/x)
mv a/x b/x
after=$(stat -c %i b/x)
echo "mv a/x b/x: inode $before -> $after"
[ "$before" = "$after" ] || { echo "inode changed, mv copied instead of renaming" >&2; exit 10; }
ln b/x a/y
[ "$(cat a/y)" = payload ] || exit 11
echo "ln b/x a/y: ok"
mkdir a/d
mv a/d b/d
[ -d b/d ] && [ ! -e a/d ] || exit 12
echo "mv a/d b/d: ok"
"#,
&policy,
&enforcement,
);
assert_eq!(
result.exit_code, 0,
"a move, a hard link and a directory reparent between workdir folders must all \
succeed under Refer"
);
}
#[test]
fn home_folder_is_writable_like_tmp_and_the_records_around_it_are_not() {
let test = "home_folder_is_writable_like_tmp_and_the_records_around_it_are_not";
let policy = coreutils_policy(&["rm", "rmdir"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
eprintln!(
"{test}: tier {:?}, Landlock ruleset ABI {:?} (kernel {:?}), /tmp {:?}",
enforcement.tier,
enforcement.landlock_abi,
HostProbe::probe().landlock_kernel_abi(),
enforcement.installed_temp_mapping()
);
let records = enforcement.records_root().unwrap().clone();
records
.create_dir_all(crate::records_dir::RESULTS_SUBDIR, 0o700)
.unwrap();
let home = enforcement.home_dir_path().unwrap().to_path_buf();
let workdir = tempfile::tempdir().unwrap();
let result = run_bash_in(
workdir.path(),
&format!(
r#"[ "$HOME" = '{home}' ] && echo home-is-the-folder
echo a > "$HOME/a" && mv "$HOME/a" "$HOME/b" && mkdir "$HOME/d" && mv "$HOME/b" "$HOME/d/c" \
&& rm "$HOME/d/c" && rmdir "$HOME/d" && echo home-writable
if echo x > "$MURMUR_RECORDS_DIR/out/result.txt"; then echo result-written; else echo result-refused; fi
if mkdir -p "$MURMUR_RECORDS_DIR/children/x"; then echo child-created; else echo child-refused; fi
if mv "$HOME" "$HOME.moved"; then echo home-moved; else echo home-move-refused; fi
"#,
home = home.display()
),
&policy,
&enforcement,
);
assert_eq!(
result.stdout,
"home-is-the-folder\nhome-writable\nresult-refused\nchild-refused\nhome-move-refused\n",
"stderr: {}",
result.stderr
);
assert!(home.is_dir());
assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0);
assert!(!records.host_path("out/result.txt").exists());
assert!(!records.host_path("children").exists());
assert!(!home.with_extension("moved").exists());
assert_eq!(std::fs::read_dir(workdir.path()).unwrap().count(), 0);
}
#[test]
fn children_records_are_not_reachable_by_a_parents_commands() {
let test = "children_records_are_not_reachable_by_a_parents_commands";
let policy = coreutils_policy(&["ls"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let runtime_dir = enforcement.runtime_dir_path().unwrap().to_path_buf();
std::fs::write(runtime_dir.join("MURMUR.md"), "RUNTIME-DIR-CONTROL\n").unwrap();
let base = tempfile::tempdir().unwrap();
let child_records = base
.path()
.join("records/children/worker-0123456789abcdef/ses_child");
let result_file = child_records.join("out/result.txt");
std::fs::create_dir_all(result_file.parent().unwrap()).unwrap();
std::fs::write(&result_file, "CHILD-ANSWER-7Q2W").unwrap();
let workdir = tempfile::tempdir().unwrap();
let result = run_bash_in(
workdir.path(),
&format!(
r#"cat "$MURMUR_RUNTIME_DIR/MURMUR.md"
if ls '{dir}'; then echo listed; else echo list-refused; fi
if cat '{file}'; then echo read; else echo read-refused; fi
"#,
dir = child_records.display(),
file = result_file.display(),
),
&policy,
&enforcement,
);
assert_eq!(
result.stdout, "RUNTIME-DIR-CONTROL\nlist-refused\nread-refused\n",
"stderr: {}",
result.stderr
);
assert!(
!result.stdout.contains("CHILD-ANSWER") && !result.stderr.contains("CHILD-ANSWER"),
"the child's answer reached the parent's command: {result:?}"
);
for line in result.stderr.lines() {
assert!(
line.contains("Permission denied") || line.contains("No such file or directory"),
"an unexpected refusal: {}",
result.stderr
);
}
assert_eq!(result.stderr.lines().count(), 2, "{}", result.stderr);
}
#[test]
fn kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev() {
let test = "kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev";
let policy = coreutils_policy(&[]);
let Some(mut enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
enforcement.landlock_abi = Some(1);
let temp = tempfile::tempdir().unwrap();
let result = run_bash_in(
temp.path(),
r#"mkdir a b || exit 20
echo payload > a/x || exit 21
before=$(stat -c %i a/x)
mv a/x b/x || exit 22
[ -f b/x ] && [ ! -e a/x ] || exit 23
after=$(stat -c %i b/x)
echo "mv a/x b/x: inode $before -> $after"
[ "$before" != "$after" ] || exit 24
if ln b/x a/y; then exit 25; fi
exit 42
"#,
&policy,
&enforcement,
);
assert_eq!(
result.exit_code, 42,
"the capsule must launch at ABI v1 and run the script to its own exit code: mv falls \
back to copy-and-delete (a new inode) and ln fails"
);
assert!(
result.stderr.contains("Invalid cross-device link"),
"ln across workdir folders at ABI v1 must fail with EXDEV; stderr: {}",
result.stderr
);
}
#[test]
fn kernel_tier_refuses_rename_out_of_the_workdir() {
let test = "kernel_tier_refuses_rename_out_of_the_workdir";
let policy = coreutils_policy(&[]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
return;
};
let (_base, work, outside) = work_and_outside();
let script = format!(
r#"echo payload > x || exit 20
if mv x '{outside}/x'; then exit 21; fi
if ln x '{outside}/x2'; then exit 22; fi
exit 0
"#,
outside = outside.display()
);
let result = run_bash_in(&work, &script, &policy, &enforcement);
assert_eq!(
result.exit_code, 0,
"mv and ln out of the workdir must both fail"
);
assert!(
work.join("x").exists(),
"the source must stay in the workdir"
);
assert!(!outside.join("x").exists(), "nothing may be moved out");
assert!(!outside.join("x2").exists(), "nothing may be linked out");
}
#[test]
fn kernel_tier_refuses_rename_into_the_workdir() {
let test = "kernel_tier_refuses_rename_into_the_workdir";
let policy = coreutils_policy(&[]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
return;
};
let (_base, work, outside) = work_and_outside();
std::fs::write(outside.join("y"), "original\n").unwrap();
let script = format!(
r#"if mv '{outside}/y' ./y; then exit 21; fi
if ln '{outside}/y' ./y2; then exit 22; fi
exit 0
"#,
outside = outside.display()
);
let result = run_bash_in(&work, &script, &policy, &enforcement);
assert_eq!(
result.exit_code, 0,
"mv and ln into the workdir must both fail"
);
assert_eq!(
std::fs::read_to_string(outside.join("y")).unwrap(),
"original\n",
"the outside file must be untouched"
);
assert!(!work.join("y").exists(), "nothing may be moved in");
assert!(!work.join("y2").exists(), "nothing may be linked in");
}
#[test]
fn kernel_tier_truncate_outside_the_workdir_is_refused() {
use std::os::unix::fs::PermissionsExt;
let test = "kernel_tier_truncate_outside_the_workdir_is_refused";
let policy = coreutils_policy(&["perl"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
return;
};
let (_base, work, outside) = work_and_outside();
let target = outside.join("t");
std::fs::write(&target, "keep\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
let script = format!(
r#"perl -e 'truncate($ARGV[0], 0) or die "$!\n"' '{}'"#,
target.display()
);
let refused = run_bash_in(&work, &script, &policy, &enforcement);
assert_ne!(
refused.exit_code, 0,
"truncate(2) outside the workdir must be refused at ABI 3"
);
assert!(
refused.stderr.contains("Permission denied"),
"the refusal must be EACCES; stderr: {}",
refused.stderr
);
assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep\n");
let mut at_v1 = enforcement.clone();
at_v1.landlock_abi = Some(1);
let unmediated = run_bash_in(&work, &script, &policy, &at_v1);
assert_eq!(
unmediated.exit_code, 0,
"at ABI v1 truncate(2) is not mediated, which is the gap this test pins as closed above"
);
assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
}
#[test]
fn kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work() {
let test = "kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work";
let policy = coreutils_policy(&["truncate"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
return;
};
let temp = tempfile::tempdir().unwrap();
let result = run_bash_in(
temp.path(),
r#"set -e
echo hello > f && truncate -s 0 f && [ ! -s f ]
echo again > f && echo over > f && [ "$(cat f)" = over ]
: > g
echo discard > /dev/null
echo ok
"#,
&policy,
&enforcement,
);
assert_eq!(
result.exit_code, 0,
"truncating inside the workdir and writing to /dev/null must keep working"
);
}
#[test]
#[ignore = "needs MURMUR_TEST_UV and MURMUR_TEST_WHEEL; see the doc comment"]
fn kernel_tier_uv_installs_a_wheel_in_the_workdir() {
let test = "kernel_tier_uv_installs_a_wheel_in_the_workdir";
let input = |name: &str| {
let value = std::env::var(name)
.unwrap_or_else(|_| panic!("{test} needs {name} set to an absolute path"));
let path = PathBuf::from(value);
assert!(
path.is_absolute() && path.is_file(),
"{name}={} is not a file",
path.display()
);
path
};
let uv = input("MURMUR_TEST_UV");
let wheel = input("MURMUR_TEST_WHEEL");
let python = std::fs::canonicalize("/usr/bin/python3").expect("host python3");
let stdlib = std::process::Command::new(&python)
.args([
"-I",
"-c",
"import sysconfig; print(sysconfig.get_paths()['stdlib'])",
])
.output()
.expect("host python3 runs");
let stdlib = String::from_utf8(stdlib.stdout).unwrap().trim().to_string();
let policy = CapabilityPolicy {
shell_allow: vec![
"bash".to_string(),
uv.display().to_string(),
python.display().to_string(),
],
shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
binary: "python3".to_string(),
dirs: vec![murmur_artifact::InterpreterRuntimeDir {
path: stdlib,
list_dir: true,
}],
}],
..CapabilityPolicy::default()
};
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
return;
};
let wheel_name = wheel.file_name().unwrap().to_str().unwrap().to_string();
let script = format!(
r#"set -e
export UV_CACHE_DIR="$PWD/.uv-cache" UV_PYTHON_DOWNLOADS=never
'{uv}' venv --python '{python}' .venv
'{uv}' pip install --offline --no-index --python .venv/bin/python './{wheel_name}'
.venv/bin/python -c 'import six; print(six.__version__)'
"#,
uv = uv.display(),
python = python.display(),
);
let install = |enforcement: &ShellEnforcement| {
let temp = tempfile::tempdir().unwrap();
std::fs::copy(&wheel, temp.path().join(&wheel_name)).unwrap();
run_bash_in(temp.path(), &script, &policy, enforcement)
};
let at_probed = install(&enforcement);
assert_eq!(
at_probed.exit_code, 0,
"uv must install the wheel at ABI {:?}",
enforcement.landlock_abi
);
assert!(at_probed.stdout.contains("1.16.0"), "{}", at_probed.stdout);
let mut at_v1 = enforcement.clone();
at_v1.landlock_abi = Some(1);
let refused = install(&at_v1);
let output = format!("{}{}", refused.stdout, refused.stderr);
assert_ne!(refused.exit_code, 0, "at ABI v1 the install must fail");
assert!(
output.contains("os error 18") || output.contains("Invalid cross-device link"),
"at ABI v1 the install must fail with EXDEV: {output}"
);
}
#[test]
fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
{
if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
return;
}
let tier = detect_enforcement_tier();
if tier != EnforcementTier::KernelFull {
eprintln!(
"SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
kernel_full_runs_nontrivial_shell_allowlist_... requires \
EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
This run does NOT execute the derived-grant Landlock code path at all, so a \
green result here is not evidence the workdir-scope exec bug is fixed — only a \
real KernelFull Linux host can prove that."
);
return;
}
let temp = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string(), "cat".to_string()],
..CapabilityPolicy::default()
};
let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
let enforcement = ShellEnforcement {
tier,
network_allow_ips: Vec::new(),
unix_sockets_allowed: policy.unix_sockets_allowed,
landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
&exec_allow_paths,
)),
..host_bounding_base()
};
let result = crate::shell::execute_shell(
"bash",
&["-c", "echo landlock-grant-ok | cat"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.expect("execute_shell should return Ok, not Err");
assert_eq!(
result.exit_code, 0,
"bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
must let each exec and dynamic-link outside the workdir (stderr: {})",
result.stderr
);
assert!(
result.stdout.contains("landlock-grant-ok"),
"the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
result.stdout,
result.stderr
);
}
#[test]
fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
return;
}
let tier = detect_enforcement_tier();
if tier != EnforcementTier::KernelFull {
eprintln!(
"SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
kernel_full_denies_write_outside_workdir_... requires \
EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
This run does NOT execute the Landlock code path, so a green result here is not \
evidence writes outside the workdir are actually denied — only a real KernelFull \
Linux host can prove that."
);
return;
}
let workdir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("should-not-be-written.txt");
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
let enforcement = ShellEnforcement {
tier,
network_allow_ips: Vec::new(),
unix_sockets_allowed: policy.unix_sockets_allowed,
landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
&exec_allow_paths,
)),
..host_bounding_base()
};
let script = format!("echo pwned > '{}'", target.display());
let result = crate::shell::execute_shell(
"bash",
&["-c", &script],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
&policy,
&enforcement,
)
.expect("execute_shell should return Ok with a nonzero exit code, not Err");
assert!(
!target.exists(),
"a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
);
assert_ne!(
result.exit_code, 0,
"bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
);
}
fn kernel_full_enforcement(
tier: EnforcementTier,
policy: &CapabilityPolicy,
) -> ShellEnforcement {
let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
let mut landlock_grants =
LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
landlock_grants.extend(resolve_interpreter_runtime_grants(
&policy.shell_interpreter_runtime,
));
ShellEnforcement {
tier,
network_allow_ips: Vec::new(),
unix_sockets_allowed: policy.unix_sockets_allowed,
landlock_grants,
..host_bounding_base()
}
}
#[test]
fn with_harness_grants_adds_the_paths_and_the_exec_files_and_nothing_else() {
let session = kernel_full_enforcement(EnforcementTier::KernelFull, &allow_only(&[]));
let granted = crate::host_paths::HostPath {
declared: "~/harness-state".to_string(),
path: PathBuf::from("/srv/harness-state"),
access: crate::host_paths::HostPathAccess::ReadWrite,
credential_locations: Vec::new(),
};
let bash = std::fs::canonicalize("/bin/bash").expect("bash is installed");
let scratch =
tempfile::tempdir_in(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"))
.unwrap();
let script = std::fs::canonicalize(scratch.path())
.unwrap()
.join("harness.sh");
std::fs::write(&script, "#!/bin/bash\n").unwrap();
let grants = HarnessEnforcementGrants {
granted_paths: vec![granted.clone()],
exec_files: vec![script.clone(), bash.clone()],
};
let harness = session.with_harness_grants(&grants).expect("no overlap");
assert_eq!(harness.host_paths.len(), session.host_paths.len() + 1);
assert_eq!(harness.host_paths.last(), Some(&granted));
let added: Vec<&LandlockGrant> = harness
.landlock_grants
.iter()
.skip(session.landlock_grants.len())
.collect();
assert!(
added
.iter()
.any(|grant| grant.path == bash && grant.executable),
"the harness binary is an executable grant: {added:?}"
);
assert!(
added.iter().all(|grant| !grant.list_dir),
"every exec grant is a non-listable file: {added:?}"
);
assert!(
added.iter().all(|grant| grant.path != granted.path),
"a granted path is never an executable Landlock grant: {added:?}"
);
assert!(
added
.iter()
.any(|grant| grant.path == script && grant.executable),
"the harness script is an executable grant: {added:?}"
);
assert!(
harness
.sealed_bind_dirs
.iter()
.any(|dir| Some(dir.as_path()) == script.parent()),
"{:?}",
harness.sealed_bind_dirs
);
assert!(session.host_paths.iter().all(|path| path != &granted));
assert!(session
.landlock_grants
.iter()
.all(|grant| grant.path != bash));
}
#[test]
fn with_harness_grants_adds_nothing_without_a_landlock_domain() {
let grants = HarnessEnforcementGrants {
granted_paths: vec![crate::host_paths::HostPath {
declared: "/srv/x".to_string(),
path: PathBuf::from("/srv/x"),
access: crate::host_paths::HostPathAccess::Read,
credential_locations: Vec::new(),
}],
exec_files: vec![PathBuf::from("/bin/sh")],
};
for tier in [
EnforcementTier::Unconfined,
EnforcementTier::EnvironmentOnly,
] {
let session = ShellEnforcement {
tier,
..host_bounding_base()
};
let harness = session.with_harness_grants(&grants).unwrap();
assert_eq!(
harness.host_paths.len(),
session.host_paths.len(),
"{tier:?}"
);
assert_eq!(
harness.landlock_grants.len(),
session.landlock_grants.len(),
"{tier:?}"
);
}
}
#[test]
fn home_folder_and_tmp_in_the_records_dir_on_a_sealed_root() {
if crate::network_namespace::skip_without_egress_namespace(
"home_folder_and_tmp_in_the_records_dir_on_a_sealed_root",
) {
return;
}
let probe = HostProbe::probe();
if probe.tier() != EnforcementTier::KernelSealed {
eprintln!(
"SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
home_folder_and_tmp_in_the_records_dir_on_a_sealed_root needs a host that reaches \
KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); detected \
{:?}. No composed root is built in this run.",
probe.tier()
);
return;
}
let accessible = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec![
"bash".to_string(),
"cat".to_string(),
"id".to_string(),
"touch".to_string(),
"getent".to_string(),
"cut".to_string(),
],
..CapabilityPolicy::default()
};
let runtime = tempfile::tempdir().unwrap();
let enforcement = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Sealed,
probe.clone(),
)
.expect("a sealed-capable host resolves sealed enforcement")
.with_runtime_dir(test_runtime_dir(runtime.path()), None);
let child_result = runtime
.path()
.join("children/worker-0123456789abcdef/out/result.txt");
std::fs::create_dir_all(child_result.parent().unwrap()).unwrap();
std::fs::write(&child_result, "the child's answer").unwrap();
assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
eprintln!(
"home_folder_and_tmp_in_the_records_dir_on_a_sealed_root: tier {:?}, Landlock \
ruleset ABI {:?} (kernel {:?})",
enforcement.tier,
enforcement.landlock_abi,
probe.landlock_kernel_abi()
);
let probe_name = format!("sealed-probe-{}", std::process::id());
let result = crate::shell::execute_shell(
"bash",
&[
"-c",
&format!(
"echo t > /tmp/{probe_name} && cat /tmp/{probe_name} && id -un && \
printf '%s\\n' \"$TMPDIR\" && printf '%s\\n' \"$HOME\" && \
getent passwd \"$(id -u)\" | cut -d: -f6 && cd ~ && touch f && echo touched && \
{{ cat '{child}' 2>&1 || true; }}",
child = child_result.display()
),
],
&[],
&crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
&policy,
&enforcement,
)
.expect("execute_shell returns Ok even when the command itself fails");
assert_eq!(
result.exit_code, 0,
"stdout: {} stderr: {}",
result.stdout, result.stderr
);
let mut lines = result.stdout.lines();
assert_eq!(
lines.next(),
Some("t"),
"the capsule's /tmp must be writable"
);
assert!(
lines.next().is_some_and(|name| !name.is_empty()),
"`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
{}",
result.stdout
);
assert_eq!(
lines.next(),
Some("/tmp"),
"TMPDIR names /tmp inside the root"
);
let home = enforcement.home_dir_path().unwrap().to_str().unwrap();
assert_eq!(lines.next(), Some(home), "$HOME is the home folder");
assert_eq!(lines.next(), Some(home), "passwd's home is the home folder");
assert_eq!(
lines.next(),
Some("touched"),
"~ is writable inside the root"
);
let child_read = lines.next().unwrap_or_default();
assert!(
child_read.contains("No such file or directory"),
"a child's result is not in the composed root: {}",
result.stdout
);
assert!(
!result.stdout.contains("the child's answer"),
"{}",
result.stdout
);
assert!(Path::new(home).join("f").is_file());
let folder = enforcement.temp_dir_path().unwrap();
assert_eq!(
std::fs::read_to_string(folder.join(&probe_name)).unwrap(),
"t\n",
"the bytes written to /tmp land in the session's temporary folder"
);
assert!(
!Path::new("/tmp").join(&probe_name).exists(),
"nothing reaches the host's /tmp"
);
assert!(runtime
.path()
.join(crate::runtime_dir::SEALED_ETC_SUBDIR)
.join("passwd")
.is_file());
assert_eq!(
std::fs::read_dir(accessible.path()).unwrap().count(),
0,
"enabling sealed must add nothing to the work folder"
);
}
#[test]
fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
if crate::network_namespace::skip_without_egress_namespace(
"kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
) {
return;
}
let tier = detect_enforcement_tier();
if tier != EnforcementTier::KernelFull {
eprintln!(
"SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
kernel_full_interpreter_runtime_list_dir_false_... requires \
EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
This run does NOT execute the interpreter_runtime Landlock code path, so a green \
result here is not evidence list_dir:false behaves correctly — only a real \
KernelFull Linux host can prove that."
);
return;
}
let workdir = tempfile::tempdir().unwrap();
let stdlib = tempfile::tempdir().unwrap();
let module = stdlib.path().join("mod.py");
std::fs::write(&module, "x = 1\n").unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
binary: "bash".to_string(),
dirs: vec![murmur_artifact::InterpreterRuntimeDir {
path: stdlib.path().to_string_lossy().into_owned(),
list_dir: false,
}],
}],
..CapabilityPolicy::default()
};
let enforcement = kernel_full_enforcement(tier, &policy);
let read = crate::shell::execute_shell(
"bash",
&["-c", &format!("cat '{}'", module.display())],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
&policy,
&enforcement,
)
.expect("execute_shell should return Ok");
assert_eq!(
read.exit_code, 0,
"list_dir:false must still permit opening a known file inside the grant (stderr: {})",
read.stderr
);
assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
let list = crate::shell::execute_shell(
"bash",
&["-c", &format!("ls '{}'", stdlib.path().display())],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
&policy,
&enforcement,
)
.expect("execute_shell should return Ok with a nonzero exit code");
assert_ne!(
list.exit_code, 0,
"list_dir:false must deny enumerating the granted directory's own entries"
);
}
#[test]
fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
if crate::network_namespace::skip_without_egress_namespace(
"kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
) {
return;
}
let tier = detect_enforcement_tier();
if tier != EnforcementTier::KernelFull {
eprintln!(
"SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
kernel_full_interpreter_runtime_list_dir_true_... requires \
EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
This run does NOT execute the interpreter_runtime Landlock code path, so a green \
result here is not evidence list_dir:true (and its non-widening to the parent) \
behaves correctly — only a real KernelFull Linux host can prove that."
);
return;
}
let workdir = tempfile::tempdir().unwrap();
let parent = tempfile::tempdir().unwrap();
let stdlib = parent.path().join("stdlib");
std::fs::create_dir(&stdlib).unwrap();
std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string(), "ls".to_string()],
shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
binary: "bash".to_string(),
dirs: vec![murmur_artifact::InterpreterRuntimeDir {
path: stdlib.to_string_lossy().into_owned(),
list_dir: true,
}],
}],
..CapabilityPolicy::default()
};
let enforcement = kernel_full_enforcement(tier, &policy);
let list = crate::shell::execute_shell(
"bash",
&["-c", &format!("ls '{}'", stdlib.display())],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
&policy,
&enforcement,
)
.expect("execute_shell should return Ok");
assert_eq!(
list.exit_code, 0,
"list_dir:true must permit enumerating the granted directory (stderr: {})",
list.stderr
);
assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
let list_parent = crate::shell::execute_shell(
"bash",
&["-c", &format!("ls '{}'", parent.path().display())],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
&policy,
&enforcement,
)
.expect("execute_shell should return Ok with a nonzero exit code");
assert_ne!(
list_parent.exit_code, 0,
"a grant on stdlib/ must not make its (ungranted) parent enumerable"
);
}
#[test]
fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
if crate::network_namespace::skip_without_egress_namespace(
"kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
) {
return;
}
let host_tier = detect_enforcement_tier();
if !matches!(
host_tier,
EnforcementTier::KernelFull | EnforcementTier::KernelSealed
) {
eprintln!(
"SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
result is not evidence that withholding Execute denies execve."
);
return;
}
let workdir = tempfile::tempdir().unwrap();
let tree = tempfile::tempdir().unwrap();
std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
let runme = tree.path().join("runme");
std::fs::copy("/bin/echo", &runme).unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string(), "ls".to_string()],
..CapabilityPolicy::default()
};
let with_tree_grant = |executable: bool| {
let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
enforcement.landlock_grants.push(LandlockGrant {
path: tree.path().to_path_buf(),
list_dir: true,
executable,
});
enforcement
};
let run = |enforcement: &ShellEnforcement, command: String| {
crate::shell::execute_shell(
"bash",
&["-c", &command],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
&policy,
enforcement,
)
.expect("execute_shell should return Ok even when the command itself fails")
};
let denied = with_tree_grant(false);
let list = run(&denied, format!("ls '{}'", tree.path().display()));
assert_eq!(
list.exit_code, 0,
"a non-executable grant must still be enumerable — that is its entire purpose \
(stderr: {})",
list.stderr
);
assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
assert_ne!(
exec_denied.exit_code, 0,
"withholding Execute must deny execve inside the granted tree; a program ran instead \
(stdout: {})",
exec_denied.stdout
);
assert!(
!exec_denied.stdout.contains("ran"),
"the binary produced output, so it executed: {}",
exec_denied.stdout
);
let allowed = with_tree_grant(true);
let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
assert_eq!(
exec_allowed.exit_code, 0,
"the same tree granted Execute must run (stderr: {})",
exec_allowed.stderr
);
assert!(
exec_allowed.stdout.contains("ran"),
"stdout: {}",
exec_allowed.stdout
);
}
#[test]
fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
if crate::network_namespace::skip_without_egress_namespace(
"kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
) {
return;
}
let host_tier = detect_enforcement_tier();
if !matches!(
host_tier,
EnforcementTier::KernelFull | EnforcementTier::KernelSealed
) {
eprintln!(
"SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
green result is not evidence that a file grant reads, nor that a ReadDir claim on \
a file is narrowed instead of refusing the launch."
);
return;
}
let workdir = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let bundle = store.path().join("ca-certificates.crt");
std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string(), "cat".to_string()],
..CapabilityPolicy::default()
};
let with_file_grant = |list_dir: bool| {
let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
enforcement.landlock_grants.push(LandlockGrant {
path: bundle.clone(),
list_dir,
executable: false,
});
enforcement
};
let read = |enforcement: &ShellEnforcement| {
crate::shell::execute_shell(
"bash",
&["-c", &format!("cat '{}'", bundle.display())],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
&policy,
enforcement,
)
.expect("execute_shell should return Ok even when the command itself fails")
};
let honest = read(&with_file_grant(false));
assert_eq!(
honest.exit_code, 0,
"ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
file entries rely on (stderr: {})",
honest.stderr
);
assert!(
honest.stdout.contains("BEGIN CERTIFICATE"),
"stdout: {}",
honest.stdout
);
let overclaimed = read(&with_file_grant(true));
assert_eq!(
overclaimed.exit_code, 0,
"a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
into an EINVAL that refuses the launch (stderr: {})",
overclaimed.stderr
);
assert!(
overclaimed.stdout.contains("BEGIN CERTIFICATE"),
"stdout: {}",
overclaimed.stdout
);
}
struct ForceNoNewPrivsFailureGuard;
impl ForceNoNewPrivsFailureGuard {
fn new() -> Self {
FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
Self
}
}
impl Drop for ForceNoNewPrivsFailureGuard {
fn drop(&mut self) {
FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
}
}
struct ForceLandlockFailureGuard;
impl ForceLandlockFailureGuard {
fn new() -> Self {
FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
Self
}
}
impl Drop for ForceLandlockFailureGuard {
fn drop(&mut self) {
FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
}
}
fn kernel_full_empty_grants() -> ShellEnforcement {
ShellEnforcement {
tier: EnforcementTier::KernelFull,
network_allow_ips: Vec::new(),
unix_sockets_allowed: false,
landlock_grants: Vec::new(),
..host_bounding_base()
}
}
fn workdir_resolution_error() -> String {
let temp = tempfile::tempdir().unwrap();
let missing = temp.path().join("does-not-exist-workdir");
let marker = temp.path().join("never-spawned.marker");
let mut command = std::process::Command::new("bash");
command
.arg("-c")
.arg(format!("echo ran > '{}'", marker.display()));
let error = prepare_enforcement(&mut command, &kernel_full_empty_grants(), &missing)
.expect_err("an unresolvable workdir must fail before fork()");
assert!(
!marker.exists(),
"prepare_enforcement must never spawn a subprocess: {error}"
);
error
}
fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
}
fn child_setup_failure_typed<G>(
enforcement: &ShellEnforcement,
arm: impl FnOnce() -> G,
) -> crate::shell::ShellExecError {
let temp = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let marker = temp.path().join("spawned-anyway.marker");
let script = format!("echo ran > '{}'", marker.display());
let _guard = arm();
let error = crate::shell::execute_shell(
"bash",
&["-c", &script],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
enforcement,
)
.expect_err("a forced child-side setup failure must make execute_shell return Err");
assert!(
!marker.exists(),
"no subprocess may run when pre_exec setup fails: {error}"
);
error
}
#[test]
fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
let error = workdir_resolution_error();
assert!(
error.contains("workdir") && error.contains("does-not-exist-workdir"),
"the error must name the workdir path that failed to resolve: {error}"
);
assert!(
!error.contains("os error 22"),
"must not collapse to the bare EINVAL string: {error}"
);
}
#[test]
fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
if crate::network_namespace::skip_without_egress_namespace(
"pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
) {
return;
}
let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
assert!(
error.contains("no_new_privs"),
"the error must name the no_new_privs step specifically: {error}"
);
assert!(
!error.contains("os error 22"),
"must not collapse to the bare EINVAL string: {error}"
);
}
#[test]
fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
if crate::network_namespace::skip_without_egress_namespace(
"pre_exec_landlock_failure_is_distinct_and_fails_closed",
) {
return;
}
let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
assert!(
error.contains("landlock"),
"the error must name the Landlock construction step specifically: {error}"
);
assert!(
!error.contains("os error 22"),
"must not collapse to the bare EINVAL string: {error}"
);
}
#[test]
fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
if crate::network_namespace::skip_without_egress_namespace(
"pre_exec_setup_failures_produce_pairwise_distinct_messages",
) {
return;
}
let workdir_msg = workdir_resolution_error();
let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
let messages = [
("workdir", &workdir_msg),
("no_new_privs", &no_new_privs_msg),
("landlock", &landlock_msg),
];
for (i, (left_name, left)) in messages.iter().enumerate() {
for (right_name, right) in &messages[i + 1..] {
assert_ne!(left, right, "{left_name} vs {right_name} must differ");
}
}
for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
assert!(!message.is_empty(), "a distinct message must not be empty");
assert!(
!message.contains("os error 22"),
"no failure may read as a bare EINVAL: {message}"
);
}
}
#[test]
fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
if crate::network_namespace::skip_without_egress_namespace(
"a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
) {
return;
}
let error =
child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
assert!(
matches!(
error,
crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
),
"a sealed-root diagnostic must survive as its own variant, not collapse into \
ShellExecError::Failed: {error}"
);
let fatal = error
.session_fatal()
.expect("a composed-root failure must end the session, not just the tool call");
assert!(
matches!(
fatal,
crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
),
"must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
);
let rendered = error.to_string();
assert!(
rendered.contains("composed root") && rendered.contains("unshare"),
"the message must name both the mechanism and the step that failed: {rendered}"
);
assert!(
!rendered.contains("os error 22"),
"must not collapse to the bare EINVAL string: {rendered}"
);
}
#[test]
fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
let landlock =
child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
assert!(
landlock.session_fatal().is_none(),
"a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
);
let no_new_privs = child_setup_failure_typed(
&kernel_full_empty_grants(),
ForceNoNewPrivsFailureGuard::new,
);
assert!(
no_new_privs.session_fatal().is_none(),
"a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
);
}
fn server_policy(extra: &[&str]) -> CapabilityPolicy {
CapabilityPolicy {
shell_allow: ["bash", "nc", "sleep"]
.iter()
.chain(extra)
.map(|binary| binary.to_string())
.collect(),
..CapabilityPolicy::default()
}
}
fn session_enforcement(policy: &CapabilityPolicy) -> ShellEnforcement {
let enforcement = ShellEnforcement::resolve_declared(
policy,
murmur_artifact::ContainmentClass::Scoped,
HostProbe::probe(),
)
.expect("the policy resolves")
.with_test_session_dirs();
assert_eq!(
enforcement.tier,
EnforcementTier::KernelFull,
"a scoped capsule on this host runs on KernelFull"
);
enforcement
}
fn one_shot_server(reply: &str, port: u16) -> String {
format!(
"(printf \"{reply}\\n\" | nc -N -l 127.0.0.1 {port}) >/dev/null 2>&1 & echo $! > server.pid"
)
}
fn retrying_client(port: u16) -> String {
format!(
"for i in {{1..50}}; do exec 3<>/dev/tcp/127.0.0.1/{port} 2>/dev/null && break; \
sleep 0.1; done; read -r reply <&3; echo \"$reply\""
)
}
fn bash(
script: &str,
workdir: &Path,
policy: &CapabilityPolicy,
enforcement: &ShellEnforcement,
) -> Result<crate::shell::ShellResult, crate::shell::ShellExecError> {
crate::shell::execute_shell(
"bash",
&["-c", script],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
policy,
enforcement,
)
}
fn pid_in(workdir: &Path, file: &str) -> i32 {
std::fs::read_to_string(workdir.join(file))
.unwrap_or_else(|error| panic!("{file} was not written: {error}"))
.trim()
.parse()
.unwrap_or_else(|error| panic!("{file} holds no pid: {error}"))
}
fn process_exists(pid: i32) -> bool {
Path::new(&format!("/proc/{pid}")).exists()
}
fn process_gone_within(pid: i32, timeout: std::time::Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while process_exists(pid) {
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
true
}
fn held_port_policy() -> CapabilityPolicy {
CapabilityPolicy {
network_allow: vec!["127.0.0.1:8091".to_string()],
..server_policy(&[])
}
}
fn held_8091_line() -> String {
crate::held_ports::held_port_line(8091, &["127.0.0.1:8091"])
}
fn assert_a_held_bind_is_named(enforcement: &ShellEnforcement, policy: &CapabilityPolicy) {
let temp = tempfile::tempdir().unwrap();
assert!(
enforcement.observed_held_ports().is_some(),
"{:?} with a held port observes binds",
enforcement.tier
);
let server = bash("nc -l 127.0.0.1 8091", temp.path(), policy, enforcement)
.expect("the server command runs");
eprintln!(
"tier {:?}; nc exit {}; stderr {:?}",
enforcement.tier,
server.exit_code,
server.stderr.trim()
);
assert_ne!(server.exit_code, 0, "stdout: {}", server.stdout);
assert!(
server.stderr.contains("Address already in use"),
"the kernel's EADDRINUSE is unchanged: {}",
server.stderr
);
assert_eq!(enforcement.take_held_port_lines(), vec![held_8091_line()]);
assert!(enforcement.take_held_port_lines().is_empty());
}
#[test]
fn a_bind_on_a_held_port_names_the_allow_entry() {
if crate::network_namespace::skip_without_egress_namespace(
"a_bind_on_a_held_port_names_the_allow_entry",
) {
return;
}
let policy = held_port_policy();
let enforcement = session_enforcement(&policy);
assert_a_held_bind_is_named(&enforcement, &policy);
enforcement.end_session();
}
#[test]
fn a_bind_on_a_held_port_names_the_allow_entry_on_a_sealed_root() {
if crate::network_namespace::skip_without_egress_namespace(
"a_bind_on_a_held_port_names_the_allow_entry_on_a_sealed_root",
) {
return;
}
let probe = HostProbe::probe();
if probe.tier() != EnforcementTier::KernelSealed {
eprintln!(
"SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
a_bind_on_a_held_port_names_the_allow_entry_on_a_sealed_root needs a host that \
reaches KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); \
detected {:?}. No composed root is built in this run.",
probe.tier()
);
return;
}
let policy = held_port_policy();
let enforcement = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Sealed,
probe,
)
.expect("a sealed-capable host resolves sealed enforcement")
.with_test_session_dirs();
assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
assert_a_held_bind_is_named(&enforcement, &policy);
enforcement.end_session();
}
#[test]
fn a_server_on_a_port_no_entry_names_is_reachable_and_unreported() {
if crate::network_namespace::skip_without_egress_namespace(
"a_server_on_a_port_no_entry_names_is_reachable_and_unreported",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let policy = held_port_policy();
let enforcement = session_enforcement(&policy);
assert!(enforcement.observed_held_ports().is_some());
let server = bash(
&one_shot_server("pong", 8092),
temp.path(),
&policy,
&enforcement,
)
.expect("the server command runs");
assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
let client = bash(&retrying_client(8092), temp.path(), &policy, &enforcement)
.expect("the client command runs");
assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
assert_eq!(client.stdout.trim(), "pong");
assert!(enforcement.take_held_port_lines().is_empty());
assert_eq!(enforcement.session_netns.creations(), 1);
enforcement.end_session();
}
#[test]
fn a_held_bind_from_a_backgrounded_server_is_reported_after_its_command_returns() {
if crate::network_namespace::skip_without_egress_namespace(
"a_held_bind_from_a_backgrounded_server_is_reported_after_its_command_returns",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let policy = held_port_policy();
let enforcement = session_enforcement(&policy);
let started = std::time::Instant::now();
let command = bash(
"nc -l 127.0.0.1 8091 >/dev/null 2>&1 & echo started",
temp.path(),
&policy,
&enforcement,
)
.expect("the command runs");
assert_eq!(command.exit_code, 0, "stderr: {}", command.stderr);
assert_eq!(command.stdout.trim(), "started");
assert!(started.elapsed() < std::time::Duration::from_secs(5));
let mut lines = Vec::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while lines.is_empty() && std::time::Instant::now() < deadline {
lines.extend(enforcement.take_held_port_lines());
std::thread::sleep(std::time::Duration::from_millis(20));
}
std::thread::sleep(std::time::Duration::from_millis(300));
lines.extend(enforcement.take_held_port_lines());
assert_eq!(lines, vec![held_8091_line()]);
enforcement.end_session();
}
#[test]
fn no_bind_observer_without_a_held_port_or_on_a_tier_without_landlock() {
if crate::network_namespace::skip_without_egress_namespace(
"no_bind_observer_without_a_held_port_or_on_a_tier_without_landlock",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let empty = session_enforcement(&server_policy(&[]));
assert!(empty.bind_observation.is_none());
assert!(empty.observed_held_ports().is_none());
let mut command = std::process::Command::new("true");
let handle = prepare_enforcement(&mut command, &empty, temp.path())
.expect("the enforcement prepares");
assert!(
matches!(
handle,
SupervisorHandle::Linux {
bind_listener: None,
..
}
),
"{handle:?}"
);
drop(command);
empty.end_session();
let held = session_enforcement(&held_port_policy());
assert_eq!(
held.observed_held_ports()
.map(crate::held_ports::HeldPorts::ports),
Some(vec![8091])
);
for tier in [
EnforcementTier::KernelSeccompOnly,
EnforcementTier::EnvironmentOnly,
EnforcementTier::Unconfined,
] {
let other = ShellEnforcement {
tier,
..held.clone()
};
assert!(other.observed_held_ports().is_none(), "{tier:?}");
assert!(!observes_binds_on(tier), "{tier:?}");
}
assert!(ShellEnforcement::environment_only()
.observed_held_ports()
.is_none());
held.end_session();
}
#[test]
fn ending_the_session_stops_the_bind_observer_and_drops_pending_records() {
if crate::network_namespace::skip_without_egress_namespace(
"ending_the_session_stops_the_bind_observer_and_drops_pending_records",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let policy = held_port_policy();
let enforcement = session_enforcement(&policy);
let server = bash("nc -l 127.0.0.1 8091", temp.path(), &policy, &enforcement)
.expect("the server command runs");
assert_ne!(server.exit_code, 0);
assert!(enforcement.session_netns.bind_observer_running());
enforcement.end_session();
assert!(!enforcement.session_netns.bind_observer_running());
assert!(enforcement.take_held_port_lines().is_empty());
}
#[test]
#[allow(clippy::print_stdout)]
fn a_listener_started_by_one_command_answers_the_next() {
if crate::network_namespace::skip_without_egress_namespace(
"a_listener_started_by_one_command_answers_the_next",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let policy = server_policy(&[]);
let enforcement = session_enforcement(&policy);
let started = std::time::Instant::now();
let server = bash(
&one_shot_server("pong", 8080),
temp.path(),
&policy,
&enforcement,
)
.expect("the server command runs");
let server_ms = started.elapsed().as_millis();
assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"a backgrounded server with its output redirected must not hold the command open"
);
let client = bash(&retrying_client(8080), temp.path(), &policy, &enforcement)
.expect("the client command runs");
println!(
"tier {:?}; command 1 exit {} in {server_ms} ms; command 2 exit {} stdout {:?}; \
namespaces created: {}",
enforcement.tier,
server.exit_code,
client.exit_code,
client.stdout.trim(),
enforcement.session_netns.creations()
);
assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
assert_eq!(client.stdout.trim(), "pong");
assert_eq!(enforcement.session_netns.creations(), 1);
enforcement.end_session();
}
#[test]
#[allow(clippy::print_stdout)]
fn a_demoted_server_stays_reachable_from_later_commands() {
use crate::detached::{DetachPolicy, DetachedRegistry, DetachedReport};
if crate::network_namespace::skip_without_egress_namespace(
"a_demoted_server_stays_reachable_from_later_commands",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let base = tempfile::tempdir().unwrap();
let policy = server_policy(&[]);
let enforcement =
session_enforcement(&policy).with_runtime_dir(test_runtime_dir(base.path()), None);
let (registry, mut reports) = DetachedRegistry::new();
let outcome = crate::shell::run_shell(
"nc",
&["-l", "127.0.0.1", "8080"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
Some(DetachPolicy {
grace: Some(std::time::Duration::ZERO),
delivered: true,
registry: Arc::clone(®istry),
command: "-l 127.0.0.1 8080".to_string(),
context_id: "ctx_demoted_server".to_string(),
provenance: None,
abandoned: None,
canceled: None,
}),
)
.expect("the server command runs");
let crate::shell::ShellOutcome::Detached(info) = outcome else {
panic!("a zero grace period demotes the server, got {outcome:?}");
};
let client = bash(
"for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null && break; \
sleep 0.1; done; echo ping >&3",
temp.path(),
&policy,
&enforcement,
)
.expect("the client command runs");
assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let completion = loop {
match reports.try_recv() {
Ok(DetachedReport::Completed(completion)) => break completion,
Ok(DetachedReport::Lost(_)) => {}
Err(_) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(20));
}
Err(error) => panic!("the demoted server never completed: {error}"),
}
};
let log = std::fs::read_to_string(&completion.output_path)
.expect("the demoted command's log is written");
println!(
"demoted as {}; client exit {}; completion {} exit {}; log has ping: {}",
info.work_id,
client.exit_code,
completion.work_id,
completion.exit_code,
log.contains("ping")
);
assert_eq!(completion.work_id, info.work_id);
assert_eq!(completion.exit_code, 0, "log: {log}");
assert!(log.contains("ping"), "log: {log}");
enforcement.end_session();
}
#[test]
#[allow(clippy::print_stdout)]
fn a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write() {
if crate::network_namespace::skip_without_egress_namespace(
"a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write",
) {
return;
}
let base = home_tempdir();
let workdir = home_tempdir();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string(), "grep".to_string()],
..CapabilityPolicy::default()
};
let enforcement = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Scoped,
HostProbe::probe(),
)
.expect("the policy resolves")
.with_runtime_dir(test_runtime_dir(base.path()), None);
let landlock = matches!(
enforcement.tier,
EnforcementTier::KernelFull | EnforcementTier::KernelSealed
);
println!(
"tier {:?}; landlock ABI {:?}",
enforcement.tier,
detect_landlock_abi()
);
let printed = run_bash_in(
workdir.path(),
"for ((i = 1; i <= 20000; i++)); do echo $i; done",
&policy,
&enforcement,
);
assert_eq!(printed.exit_code, 0, "{}", printed.stderr);
assert!(printed.truncated);
let path = printed
.full_output_path
.clone()
.expect("a truncated command names its log");
assert!(path.starts_with(enforcement.records_dir_path().unwrap().to_str().unwrap()));
let before = std::fs::read(&path).unwrap();
let through_bash = run_bash_in(
workdir.path(),
&format!("grep -x 20000 {path}"),
&policy,
&enforcement,
);
assert_eq!(through_bash.exit_code, 0, "{}", through_bash.stderr);
assert_eq!(through_bash.stdout, "20000\n");
let through_grep = crate::shell::execute_shell(
"grep",
&["-x", "20000", &path],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
&policy,
&enforcement,
)
.unwrap();
assert_eq!(through_grep.exit_code, 0, "{}", through_grep.stderr);
assert_eq!(through_grep.stdout, "20000\n");
if landlock {
let write = run_bash_in(
workdir.path(),
&format!("echo x >> {path}"),
&policy,
&enforcement,
);
assert_ne!(write.exit_code, 0, "a command appended to its own log");
assert_eq!(std::fs::read(&path).unwrap(), before);
} else {
println!(
"[SKIP-HOST] a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write: \
the write half needs Landlock; detected {:?}",
enforcement.tier
);
}
assert!(!workdir.path().join("logs").exists());
enforcement.end_session();
}
#[test]
#[allow(clippy::print_stdout)]
fn two_sessions_do_not_share_loopback() {
if crate::network_namespace::skip_without_egress_namespace(
"two_sessions_do_not_share_loopback",
) {
return;
}
let port = {
let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
probe.local_addr().unwrap().port()
};
let policy = server_policy(&[]);
let (dir_a, dir_b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let session_a = session_enforcement(&policy);
let session_b = session_enforcement(&policy);
for (dir, session, reply) in [
(&dir_a, &session_a, "pong-a"),
(&dir_b, &session_b, "pong-b"),
] {
let server = bash(&one_shot_server(reply, port), dir.path(), &policy, session)
.expect("the server command runs");
assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
}
std::thread::sleep(std::time::Duration::from_millis(300));
let server_in = |dir: &tempfile::TempDir, session: &ShellEnforcement| -> Option<i32> {
let join = session
.session_netns
.establish(
&session.egress_tcp_ports,
crate::egress_proxy::EgressPolicy::new(
session.network_allow_rules.clone(),
session.network_allow_ips.clone(),
),
session.observed_held_ports(),
)
.expect("the session's namespace is established");
let pid_ns = std::fs::read_link(format!("/proc/self/fd/{}", join.pid_ns_fd()))
.expect("the session's PID namespace handle is readable");
let namespace_pid = pid_in(dir.path(), "server.pid").to_string();
std::fs::read_dir("/proc")
.expect("/proc is readable")
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
.find(|pid| {
std::fs::read_link(format!("/proc/{pid}/ns/pid"))
.is_ok_and(|namespace| namespace == pid_ns)
&& std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(
|status| {
status
.lines()
.find_map(|line| line.strip_prefix("NSpid:"))
.and_then(|fields| fields.split_whitespace().last())
== Some(namespace_pid.as_str())
},
)
})
};
let servers: std::collections::HashSet<i32> = [(&dir_a, &session_a), (&dir_b, &session_b)]
.into_iter()
.filter_map(|(dir, session)| server_in(dir, session))
.collect();
assert_eq!(
servers.len(),
2,
"a server's bind on {port} failed: {servers:?}"
);
let host = std::net::TcpStream::connect(("127.0.0.1", port));
let host_error = host.expect_err("the host must not reach a session's loopback server");
assert_eq!(host_error.kind(), std::io::ErrorKind::ConnectionRefused);
let client_b = bash(&retrying_client(port), dir_b.path(), &policy, &session_b)
.expect("B's client runs");
assert_eq!(client_b.exit_code, 0, "stderr: {}", client_b.stderr);
let second_b = bash(
&format!("exec 3<>/dev/tcp/127.0.0.1/{port}"),
dir_b.path(),
&policy,
&session_b,
)
.expect("B's second client runs");
let client_a = bash(&retrying_client(port), dir_a.path(), &policy, &session_a)
.expect("A's client runs");
println!(
"port {port}; host connect: {host_error}; B read {:?}; B's second client exit {} \
stderr {:?}; A read {:?}",
client_b.stdout.trim(),
second_b.exit_code,
second_b.stderr.trim(),
client_a.stdout.trim()
);
assert_eq!(client_b.stdout.trim(), "pong-b");
assert_ne!(second_b.exit_code, 0);
assert!(
second_b.stderr.contains("Connection refused"),
"stderr: {}",
second_b.stderr
);
assert_eq!(client_a.exit_code, 0, "stderr: {}", client_a.stderr);
assert_eq!(client_a.stdout.trim(), "pong-a");
session_a.end_session();
session_b.end_session();
}
#[test]
#[allow(clippy::print_stdout)]
fn the_session_proxy_serves_every_command() {
if crate::network_namespace::skip_without_egress_namespace(
"the_session_proxy_serves_every_command",
) {
return;
}
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
use std::io::Write;
if let Ok(mut stream) = stream {
let _ = stream.write_all(b"pong\n");
}
}
});
let temp = tempfile::tempdir().unwrap();
let policy = CapabilityPolicy {
network_allow: vec![format!("http://127.0.0.1:{port}")],
..server_policy(&[])
};
let enforcement = session_enforcement(&policy);
let through_proxy =
format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
let first = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
let unlisted = bash(
"exec 3<>/dev/tcp/10.255.255.1/9",
temp.path(),
&policy,
&enforcement,
)
.unwrap();
let third = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
println!(
"first {:?} exit {}; unlisted exit {} stderr {:?}; third {:?} exit {}; namespaces \
created: {}",
first.stdout.trim(),
first.exit_code,
unlisted.exit_code,
unlisted.stderr.trim(),
third.stdout.trim(),
third.exit_code,
enforcement.session_netns.creations()
);
assert_eq!(first.exit_code, 0, "stderr: {}", first.stderr);
assert_eq!(first.stdout.trim(), "pong");
assert_ne!(unlisted.exit_code, 0, "an unlisted destination must fail");
assert_eq!(third.exit_code, 0, "stderr: {}", third.stderr);
assert_eq!(third.stdout.trim(), "pong");
assert_eq!(enforcement.session_netns.creations(), 1);
enforcement.end_session();
}
#[test]
#[allow(clippy::print_stdout)]
fn concurrent_first_commands_share_one_namespace() {
if crate::network_namespace::skip_without_egress_namespace(
"concurrent_first_commands_share_one_namespace",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let policy = server_policy(&[]);
let enforcement = session_enforcement(&policy);
let servers = (8081..=8083)
.map(|port| one_shot_server("pong", port).replace("server.pid", &format!("{port}.pid")))
.collect::<Vec<_>>()
.join("; ");
let scripts = [
servers,
retrying_client(8081),
retrying_client(8082),
retrying_client(8083),
];
let barrier = std::sync::Barrier::new(scripts.len());
let results: Vec<crate::shell::ShellResult> = std::thread::scope(|scope| {
let handles: Vec<_> = scripts
.iter()
.map(|script| {
let enforcement = enforcement.clone();
let (barrier, policy, workdir) = (&barrier, &policy, temp.path());
scope.spawn(move || {
barrier.wait();
bash(script, workdir, policy, &enforcement).expect("the command runs")
})
})
.collect();
handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect()
});
for (index, result) in results.iter().enumerate() {
println!(
"command {index}: exit {} stdout {:?}",
result.exit_code,
result.stdout.trim()
);
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
}
for client in &results[1..] {
assert_eq!(client.stdout.trim(), "pong");
}
println!(
"namespaces created: {}",
enforcement.session_netns.creations()
);
assert_eq!(enforcement.session_netns.creations(), 1);
enforcement.end_session();
}
#[test]
#[allow(clippy::print_stdout)]
fn ending_the_session_kills_everything_it_started() {
if crate::cgroup::skip_without_host_support(
"ending_the_session_kills_everything_it_started",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let policy = server_policy(&["setsid"]);
let scope =
crate::cgroup::prepare_scope(true, &policy.resources, "ses_end_kills", temp.path())
.expect("a delegated scope")
.scope
.expect("a required scope on Linux is never None");
let enforcement =
session_enforcement(&policy).with_host_bounding(Some(Arc::clone(&scope)), None);
let started = bash(
"setsid sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid; \
nc -l 127.0.0.1 8080 >/dev/null 2>&1 & echo $! > server.pid",
temp.path(),
&policy,
&enforcement,
)
.expect("the command runs");
assert_eq!(started.exit_code, 0, "stderr: {}", started.stderr);
let sleeper = host_pid(&enforcement, pid_in(temp.path(), "sleeper.pid"));
let server = host_pid(&enforcement, pid_in(temp.path(), "server.pid"));
assert!(process_exists(sleeper), "the setsid sleeper is running");
assert!(process_exists(server), "the background server is running");
let ending = std::time::Instant::now();
enforcement.end_session();
let took = ending.elapsed();
let sleeper_gone = process_gone_within(sleeper, std::time::Duration::from_secs(2));
let server_gone = process_gone_within(server, std::time::Duration::from_secs(2));
let procs = std::fs::read_to_string(scope.path().join("cgroup.procs")).unwrap();
let after = bash("true", temp.path(), &policy, &enforcement);
println!(
"end_session took {} ms; sleeper {sleeper} gone: {sleeper_gone}; server {server} \
gone: {server_gone}; cgroup.procs after: {:?}; next command: {:?}",
took.as_millis(),
procs.trim(),
after.as_ref().map(|result| result.exit_code)
);
assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
assert!(sleeper_gone, "the setsid sleeper outlived its session");
assert!(server_gone, "the background server outlived its session");
assert!(procs.trim().is_empty(), "cgroup.procs: {procs}");
let refusal = after
.expect_err("a command after the session ended must be refused")
.to_string();
assert!(refusal.contains("session has ended"), "{refusal}");
}
fn home_tempdir() -> tempfile::TempDir {
tempfile::tempdir_in(std::env::var_os("HOME").expect("HOME is set")).unwrap()
}
fn mapped_enforcement(
test: &str,
policy: &CapabilityPolicy,
class: murmur_artifact::ContainmentClass,
base: &Path,
workdir: &Path,
) -> Option<ShellEnforcement> {
if crate::network_namespace::skip_without_egress_namespace(test) {
return None;
}
let probe = HostProbe::probe();
let mount_namespace_usable = probe.mount_namespace_usable();
let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
.unwrap()
.with_runtime_dir(test_runtime_dir(base), None);
let mapping =
crate::temp_dir::temp_mapping(enforcement.tier, mount_namespace_usable, workdir);
if mapping != crate::temp_dir::TempMapping::MountNamespace {
crate::runtime_err!(
"[SKIP-HOST] {test}: this host maps no private /tmp at {class:?} ({mapping:?} on \
{:?})",
enforcement.tier
);
return None;
}
Some(enforcement.with_temp_mapping(mapping))
}
fn unmapped_kernel_full_enforcement(
test: &str,
policy: &CapabilityPolicy,
base: &Path,
workdir: &Path,
) -> Option<ShellEnforcement> {
if crate::network_namespace::skip_without_egress_namespace(test) {
return None;
}
let live = HostProbe::probe();
if !matches!(
live.tier(),
EnforcementTier::KernelFull | EnforcementTier::KernelSealed
) {
crate::runtime_err!(
"[SKIP-HOST] {test}: needs Landlock; detected {:?}",
live.tier()
);
return None;
}
let refused = crate::namespace_probe::MountAttempt::Failed(
crate::namespace_probe::MountAttemptError::Step {
step: crate::namespace_probe::MountStep::UnshareMount,
errno: libc::EPERM,
},
);
let probe = HostProbe::from_parts(
Some(true),
crate::namespace_probe::NamespaceReading {
mount: refused.clone(),
sealed_root: refused,
..live.namespaces().clone()
},
)
.with_landlock_kernel_abi(live.landlock_kernel_abi());
assert!(!probe.mount_namespace_usable());
let enforcement = ShellEnforcement::resolve_declared(
policy,
murmur_artifact::ContainmentClass::Scoped,
probe,
)
.unwrap()
.with_runtime_dir(test_runtime_dir(base), None);
assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
let mapping = crate::temp_dir::temp_mapping(enforcement.tier, false, workdir);
assert_eq!(
mapping,
crate::temp_dir::TempMapping::NotMapped(
crate::temp_dir::NotMappedReason::NoMountNamespace
)
);
Some(enforcement.with_temp_mapping(mapping))
}
fn unique_tmp_name(label: &str) -> String {
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
format!(
"murmur-probe-{label}-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
)
}
#[test]
fn the_tmp_map_spec_reexposes_exact_paths_beneath_tmp_read_only_but_the_workdir() {
let base = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let grant_dir = tempfile::tempdir().unwrap();
let grant = grant_dir.path().join("bin");
std::fs::write(&grant, b"#!/bin/sh\n").unwrap();
let mut enforcement = ShellEnforcement {
tier: EnforcementTier::KernelFull,
..ShellEnforcement::environment_only().with_test_session_dirs()
}
.with_runtime_dir(test_runtime_dir(base.path()), None)
.with_temp_mapping(crate::temp_dir::TempMapping::MountNamespace);
enforcement.landlock_grants =
LandlockGrant::non_listable_files(vec![grant.clone(), PathBuf::from("/usr/bin/env")]);
let folder = enforcement
.runtime_dir
.as_ref()
.and_then(|roots| roots.temp.as_ref())
.unwrap()
.path_fd()
.unwrap();
let spec = build_tmp_map_spec(&enforcement, &folder, workdir.path()).unwrap();
let canonical = |path: &Path| {
std::fs::canonicalize(path)
.unwrap()
.to_string_lossy()
.into_owned()
};
let mut expected = vec![
(canonical(workdir.path()), false),
(
canonical(&base.path().join(crate::runtime_dir::RUNTIME_SUBDIR)),
true,
),
(
canonical(&base.path().join(crate::records_dir::RECORDS_DIR_NAME)),
true,
),
(
canonical(
&base
.path()
.join(crate::records_dir::RECORDS_DIR_NAME)
.join(crate::records_dir::HOME_SUBDIR),
),
false,
),
(canonical(&grant), true),
];
expected.sort();
assert_eq!(spec.reexposed_sources(), expected);
}
#[test]
fn a_literal_tmp_path_reaches_the_session_temp_folder() {
const TEST: &str = "a_literal_tmp_path_reaches_the_session_temp_folder";
for class in [
murmur_artifact::ContainmentClass::Scoped,
murmur_artifact::ContainmentClass::Advisory,
] {
let base = home_tempdir();
let workdir = home_tempdir();
let policy = coreutils_policy(&[]);
let Some(enforcement) =
mapped_enforcement(TEST, &policy, class, base.path(), workdir.path())
else {
return;
};
let name = unique_tmp_name("literal");
let result = run_bash_in(
workdir.path(),
&format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
&policy,
&enforcement,
);
assert_eq!(result.exit_code, 0, "{class:?}: {}", result.stderr);
assert_eq!(result.stdout, "x\n/tmp\n", "{class:?}");
let folder = enforcement.temp_dir_path().unwrap();
assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
assert!(
!Path::new("/tmp").join(&name).exists(),
"{class:?}: the host's /tmp gained {name}"
);
}
}
#[test]
fn a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier() {
const TEST: &str =
"a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier";
let base = home_tempdir();
let workdir = home_tempdir();
let policy = coreutils_policy(&[]);
let Some(enforcement) = mapped_enforcement(
TEST,
&policy,
murmur_artifact::ContainmentClass::Scoped,
base.path(),
workdir.path(),
) else {
return;
};
let enforcement = ShellEnforcement {
tier: EnforcementTier::KernelSeccompOnly,
..enforcement
};
assert_eq!(
enforcement.installed_temp_mapping(),
crate::temp_dir::TempMapping::MountNamespace
);
let name = unique_tmp_name("seccomp-only");
let result = run_bash_in(
workdir.path(),
&format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
&policy,
&enforcement,
);
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert_eq!(result.stdout, "x\n/tmp\n");
let folder = enforcement.temp_dir_path().unwrap();
assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
assert!(
!Path::new("/tmp").join(&name).exists(),
"the host's /tmp gained {name}"
);
}
#[test]
fn two_sessions_do_not_share_tmp() {
const TEST: &str = "two_sessions_do_not_share_tmp";
let policy = coreutils_policy(&[]);
let (base_a, base_b) = (home_tempdir(), home_tempdir());
let (work_a, work_b) = (home_tempdir(), home_tempdir());
let class = murmur_artifact::ContainmentClass::Scoped;
let Some(a) = mapped_enforcement(TEST, &policy, class, base_a.path(), work_a.path()) else {
return;
};
let Some(b) = mapped_enforcement(TEST, &policy, class, base_b.path(), work_b.path()) else {
return;
};
let name = unique_tmp_name("shared");
let first = run_bash_in(work_a.path(), &format!("echo a > /tmp/{name}"), &policy, &a);
assert_eq!(first.exit_code, 0, "{}", first.stderr);
let second = run_bash_in(
work_b.path(),
&format!("test ! -e /tmp/{name} && echo b > /tmp/{name} && cat /tmp/{name}"),
&policy,
&b,
);
assert_eq!(second.exit_code, 0, "{}", second.stderr);
assert_eq!(second.stdout, "b\n");
let again = run_bash_in(work_a.path(), &format!("cat /tmp/{name}"), &policy, &a);
assert_eq!(again.stdout, "a\n", "{}", again.stderr);
assert_ne!(a.temp_dir_path(), b.temp_dir_path());
assert!(!Path::new("/tmp").join(&name).exists());
}
#[test]
fn a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped() {
const TEST: &str = "a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped";
let base = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
assert!(workdir.path().starts_with(crate::temp_dir::canonical_tmp()));
let policy = coreutils_policy(&[]);
let Some(enforcement) = mapped_enforcement(
TEST,
&policy,
murmur_artifact::ContainmentClass::Scoped,
base.path(),
workdir.path(),
) else {
return;
};
std::fs::write(
enforcement.runtime_dir_path().unwrap().join("probe"),
b"runtime\n",
)
.unwrap();
let name = unique_tmp_name("under");
let result = run_bash_in(
workdir.path(),
&format!(
"echo a > \"$(pwd)/f\" && cat \"$(pwd)/f\" && cat \"$MURMUR_RUNTIME_DIR/probe\" && \
echo h > \"$HOME/h\" && cat \"$HOME/h\" && echo t > /tmp/{name} && \
cat /tmp/{name}"
),
&policy,
&enforcement,
);
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert_eq!(result.stdout, "a\nruntime\nh\nt\n");
assert_eq!(
std::fs::read_to_string(workdir.path().join("f")).unwrap(),
"a\n"
);
let sibling = base.path().join("sibling");
std::fs::write(&sibling, b"host\n").unwrap();
let record = enforcement.records_dir_path().unwrap().join("trace.jsonl");
std::fs::write(&record, b"{}\n").unwrap();
let refused = run_bash_in(
workdir.path(),
&format!(
"test ! -e {sibling} && echo absent; echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
sibling = sibling.display()
),
&policy,
&enforcement,
);
assert!(refused.stdout.starts_with("absent\n"), "{}", refused.stderr);
assert_ne!(refused.exit_code, 0);
assert!(
refused.stderr.contains("Read-only file system"),
"{}",
refused.stderr
);
assert_eq!(std::fs::read(&record).unwrap(), b"{}\n");
let folder = enforcement.temp_dir_path().unwrap();
assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "t\n");
assert!(!Path::new("/tmp").join(&name).exists());
}
#[test]
fn without_a_mount_namespace_tmp_is_refused_and_tmpdir_works() {
const TEST: &str = "without_a_mount_namespace_tmp_is_refused_and_tmpdir_works";
let base = home_tempdir();
let workdir = home_tempdir();
let policy = coreutils_policy(&[]);
let Some(enforcement) =
unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
else {
return;
};
let name = unique_tmp_name("refused");
let refused = run_bash_in(
workdir.path(),
&format!("echo y > /tmp/{name}"),
&policy,
&enforcement,
);
assert_ne!(refused.exit_code, 0);
assert!(
refused.stderr.contains("Permission denied"),
"{}",
refused.stderr
);
assert!(!Path::new("/tmp").join(&name).exists());
let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
let works = run_bash_in(
workdir.path(),
"echo z > \"$TMPDIR/m\" && cat \"$TMPDIR/m\" && printf '%s\\n' \"$TMPDIR\" \"$TMP\" \
\"$TEMP\"",
&policy,
&enforcement,
);
assert_eq!(works.exit_code, 0, "{}", works.stderr);
let folder_text = folder.to_string_lossy();
assert_eq!(
works.stdout,
format!("z\n{folder_text}\n{folder_text}\n{folder_text}\n")
);
assert_eq!(std::fs::read_to_string(folder.join("m")).unwrap(), "z\n");
}
#[test]
fn a_record_cannot_be_moved_or_linked_into_tmp() {
const TEST: &str = "a_record_cannot_be_moved_or_linked_into_tmp";
let policy = coreutils_policy(&[]);
for mapped in [true, false] {
let base = home_tempdir();
let workdir = home_tempdir();
let enforcement = if mapped {
mapped_enforcement(
TEST,
&policy,
murmur_artifact::ContainmentClass::Scoped,
base.path(),
workdir.path(),
)
} else {
unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
};
let Some(enforcement) = enforcement else {
return;
};
let records = enforcement.records_dir_path().unwrap().to_path_buf();
let record = records.join("trace.jsonl");
std::fs::write(&record, b"{\"event\":\"session_start\"}\n").unwrap();
let before = std::fs::read(&record).unwrap();
for script in [
"ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" /tmp/l",
"ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/l2\"",
"ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/l3\"",
"mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/m\"",
"mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/m2\"",
"echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
"mv \"$MURMUR_RECORDS_DIR/tmp\" \"$MURMUR_RECORDS_DIR/tmp-moved\"",
] {
let result = run_bash_in(workdir.path(), script, &policy, &enforcement);
assert_ne!(
result.exit_code, 0,
"mapped: {mapped}: `{script}` succeeded"
);
}
assert_eq!(std::fs::read(&record).unwrap(), before, "mapped: {mapped}");
assert!(records
.join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
.is_dir());
assert!(!records.join("tmp-moved").exists());
}
}
#[test]
fn tmp_follows_workdir_exec() {
const TEST: &str = "tmp_follows_workdir_exec";
for (workdir_exec, expected) in [(false, 126), (true, 0)] {
let base = home_tempdir();
let workdir = home_tempdir();
let policy = CapabilityPolicy {
workdir_exec_allowed: workdir_exec,
..coreutils_policy(&["cp", "true"])
};
let Some(enforcement) = mapped_enforcement(
TEST,
&policy,
murmur_artifact::ContainmentClass::Advisory,
base.path(),
workdir.path(),
) else {
return;
};
let result = run_bash_in(
workdir.path(),
"cp /bin/true /tmp/t && /tmp/t",
&policy,
&enforcement,
);
assert_eq!(
result.exit_code, expected,
"workdir_exec: {workdir_exec}: {}",
result.stderr
);
if !workdir_exec {
assert!(
result.stderr.contains("Permission denied"),
"{}",
result.stderr
);
}
}
}
#[test]
fn a_failed_tmp_mapping_spawns_nothing() {
const TEST: &str = "a_failed_tmp_mapping_spawns_nothing";
let base = home_tempdir();
let workdir = home_tempdir();
let policy = coreutils_policy(&[]);
let Some(enforcement) = mapped_enforcement(
TEST,
&policy,
murmur_artifact::ContainmentClass::Scoped,
base.path(),
workdir.path(),
) else {
return;
};
let error = {
let _forced = ForceTmpMapFailureGuard::new();
crate::shell::execute_shell(
"bash",
&["-c", "echo ran > \"$(pwd)/marker\""],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
&policy,
&enforcement,
)
.expect_err("a failed mapping fails the spawn")
};
let message = error.to_string();
println!("{message}");
assert!(
message.contains("could not map /tmp onto this session's temporary folder"),
"{message}"
);
assert!(
matches!(error, crate::shell::ShellExecError::Failed(_)),
"an ordinary spawn failure, not a session-fatal one: {error:?}"
);
assert!(!workdir.path().join("marker").exists(), "the command ran");
}
#[test]
fn a_link_swapped_onto_the_temp_folder_redirects_no_bind() {
const TEST: &str = "a_link_swapped_onto_the_temp_folder_redirects_no_bind";
let base = home_tempdir();
let workdir = home_tempdir();
let policy = coreutils_policy(&[]);
let Some(enforcement) = mapped_enforcement(
TEST,
&policy,
murmur_artifact::ContainmentClass::Scoped,
base.path(),
workdir.path(),
) else {
return;
};
let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
let decoy = base.path().join("decoy");
std::fs::create_dir(&decoy).unwrap();
std::fs::rename(&folder, base.path().join("folder-moved")).unwrap();
std::os::unix::fs::symlink(&decoy, &folder).unwrap();
let error = crate::shell::execute_shell(
"bash",
&["-c", "echo x > /tmp/swapped"],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
&policy,
&enforcement,
)
.expect_err("a swapped folder fails the spawn")
.to_string();
println!("{error}");
assert!(
error.contains("could not map /tmp onto this session's temporary folder"),
"{error}"
);
assert!(error.contains("opening the temporary folder"), "{error}");
assert!(
error.contains(&std::io::Error::from_raw_os_error(libc::ESTALE).to_string()),
"{error}"
);
assert_eq!(
std::fs::read_dir(&decoy).unwrap().count(),
0,
"the link's target was written"
);
}
fn derived_interpreter_policy(binary: &str, work: &Path) -> CapabilityPolicy {
let mut policy = CapabilityPolicy {
shell_allow: vec![binary.to_string()],
..CapabilityPolicy::default()
};
let cache = tempfile::tempdir().unwrap();
policy.shell_derived_interpreter_runtime =
crate::interpreter_runtime::derive_interpreter_runtimes(
&policy.shell_allow,
&[],
&[work],
&crate::host_facts::HostFactsCache::at(cache.path().join("host_facts")),
)
.runtimes;
policy
}
fn unsandboxed_python(program: &str) -> String {
let output = std::process::Command::new("python3")
.args(["-I", "-c", program])
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
fn assert_derives_stdlib_and_sqlite(policy: &CapabilityPolicy) -> PathBuf {
let stdlib = PathBuf::from(unsandboxed_python(
"import encodings, os; print(os.path.dirname(os.path.dirname(encodings.__file__)))",
));
let dynload = PathBuf::from(unsandboxed_python(
"import _sqlite3, os; print(os.path.dirname(os.path.realpath(_sqlite3.__file__)))",
));
let derived = &policy.shell_derived_interpreter_runtime;
println!("derived: {derived:#?}");
let dirs: Vec<&PathBuf> = derived.iter().flat_map(|runtime| &runtime.dirs).collect();
let canonical = |path: &Path| std::fs::canonicalize(path).unwrap();
assert!(
dirs.contains(&&canonical(&stdlib)),
"{stdlib:?} not in {dirs:?}"
);
assert!(
dirs.contains(&&canonical(&dynload)),
"{dynload:?} not in {dirs:?}"
);
canonical(&stdlib)
}
fn python_probe_outside(secret: &Path, parent: &Path) -> String {
format!(
"import os\n\
for label, act in (('open', lambda: open({secret:?}).read()), \
('listdir', lambda: os.listdir({parent:?}))):\n\
\x20 try:\n\
\x20 act(); print(label, 'allowed')\n\
\x20 except Exception as error:\n\
\x20 print(label, type(error).__name__)\n",
secret = secret.to_string_lossy(),
parent = parent.to_string_lossy(),
)
}
fn execute(
binary: &str,
args: &[&str],
accessible: &Path,
policy: &CapabilityPolicy,
enforcement: &ShellEnforcement,
) -> crate::shell::ShellResult {
let result = crate::shell::execute_shell(
binary,
args,
&[],
&crate::workdir_fs::WorkdirRoot::open(accessible).unwrap(),
policy,
enforcement,
)
.unwrap();
println!(
"exit {}\n--- stdout\n{}--- stderr\n{}---",
result.exit_code, result.stdout, result.stderr
);
result
}
fn trust_store_policy(
shell_allow: &[&str],
network_allow: &[&str],
work: &Path,
) -> CapabilityPolicy {
let mut policy = CapabilityPolicy {
shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
network_allow: network_allow.iter().map(|rule| rule.to_string()).collect(),
..CapabilityPolicy::default()
};
policy.shell_trust_store = crate::trust_store::derive_trust_store(
&policy.shell_allow,
&policy.network_allow,
&[work],
);
println!("derived trust store: {:#?}", policy.shell_trust_store);
policy
}
fn host_ca_bundle() -> Option<PathBuf> {
crate::trust_store::TRUST_STORE_CANDIDATES
.iter()
.filter(|candidate| !candidate.directory)
.map(|candidate| PathBuf::from(candidate.path))
.find(|path| std::fs::metadata(path).is_ok_and(|m| m.is_file()))
}
#[test]
fn a_scoped_curl_with_network_allow_completes_an_https_request() {
let test = "a_scoped_curl_with_network_allow_completes_an_https_request";
if find_on_path("curl").is_none() {
eprintln!("SKIP — PROVES NOTHING: {test} needs curl on PATH");
return;
}
let unsandboxed = std::process::Command::new("curl")
.args([
"-sS",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
"15",
])
.arg("https://example.com/")
.output()
.unwrap();
if String::from_utf8_lossy(&unsandboxed.stdout) != "200" {
eprintln!(
"SKIP — PROVES NOTHING: {test} needs https://example.com/ to answer 200 outside \
the sandbox; it gave {unsandboxed:?}"
);
return;
}
let (_base, work, _outside) = work_and_outside();
let policy = trust_store_policy(&["curl"], &["example.com"], &work);
assert!(policy.shell_interpreter_runtime.is_empty());
assert!(
!policy.shell_trust_store.is_empty(),
"this host has a trust store the candidate table names"
);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let fetched = execute(
"curl",
&[
"-sS",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
"30",
"https://example.com/",
],
&work,
&policy,
&enforcement,
);
enforcement.end_session();
assert_eq!(fetched.exit_code, 0, "{}", fetched.stderr);
assert_eq!(fetched.stdout, "200");
}
#[test]
fn a_scoped_command_reads_the_trust_store_only_with_network_allow() {
let test = "a_scoped_command_reads_the_trust_store_only_with_network_allow";
assert!(
crate::trust_store::derive_trust_store(&[], &["example.com".into()], &[]).is_empty()
);
let Some(bundle) = host_ca_bundle() else {
eprintln!("SKIP — PROVES NOTHING: {test} needs a CA bundle the candidate table names");
return;
};
let bundle_arg = bundle.to_str().unwrap();
let (_base, work, _outside) = work_and_outside();
let offline = trust_store_policy(&["cat"], &[], &work);
assert!(offline.shell_trust_store.is_empty());
let online = trust_store_policy(&["cat"], &["127.0.0.1"], &work);
assert!(!online.shell_trust_store.is_empty());
let Some(enforcement) = scoped_landlock_enforcement(test, &offline, 1) else {
return;
};
for derived in &online.shell_trust_store {
assert!(
!enforcement
.landlock_grants
.iter()
.any(|grant| grant.path == derived.path),
"{derived:?} granted without network.allow"
);
}
let denied = execute("cat", &[bundle_arg], &work, &offline, &enforcement);
enforcement.end_session();
assert_ne!(denied.exit_code, 0);
assert!(
denied.stderr.contains("Permission denied"),
"{}",
denied.stderr
);
let Some(enforcement) = scoped_landlock_enforcement(test, &online, 1) else {
return;
};
let read = execute("cat", &[bundle_arg], &work, &online, &enforcement);
enforcement.end_session();
assert_eq!(read.exit_code, 0, "{}", read.stderr);
assert!(
read.stdout.starts_with("-----BEGIN CERTIFICATE-----"),
"{}",
&read.stdout[..read.stdout.len().min(200)]
);
}
#[test]
fn a_scoped_command_reads_a_declared_need_and_nothing_beside_it() {
use std::os::unix::fs::PermissionsExt;
let test = "a_scoped_command_reads_a_declared_need_and_nothing_beside_it";
let base = home_tempdir();
let need = base.path().join("need");
std::fs::create_dir_all(need.join("share")).unwrap();
let library = need.join("lib.so.1");
std::fs::write(&library, "#!/bin/sh\necho ran\n").unwrap();
std::fs::set_permissions(&library, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::write(need.join("share/font.txt"), "font").unwrap();
let secret = need.join("secret.txt");
std::fs::write(&secret, "secret").unwrap();
let (_work_base, work, _outside) = work_and_outside();
let mut policy = allow_only(&["cat", "sh", "ls"]);
policy.shell_declared_needs =
super::tests::granted_needs(&[(library.clone(), false), (need.join("share"), true)]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
assert!(enforcement
.landlock_grants
.iter()
.filter(|grant| grant.path.starts_with(&need))
.all(|grant| !grant.executable));
let read = execute(
"cat",
&[library.to_str().unwrap()],
&work,
&policy,
&enforcement,
);
let beside = execute(
"cat",
&[secret.to_str().unwrap()],
&work,
&policy,
&enforcement,
);
let listed = execute(
"ls",
&[need.join("share").to_str().unwrap()],
&work,
&policy,
&enforcement,
);
let ran = execute(
"sh",
&["-c", library.to_str().unwrap()],
&work,
&policy,
&enforcement,
);
enforcement.end_session();
assert_eq!(read.exit_code, 0, "{}", read.stderr);
assert!(read.stdout.contains("echo ran"), "{}", read.stdout);
assert_ne!(beside.exit_code, 0);
assert!(
beside.stderr.contains("Permission denied"),
"{}",
beside.stderr
);
assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
assert!(listed.stdout.contains("font.txt"), "{}", listed.stdout);
assert_eq!(ran.exit_code, 126, "{}", ran.stderr);
assert!(ran.stderr.contains("Permission denied"), "{}", ran.stderr);
assert!(!ran.stdout.contains("ran"), "{}", ran.stdout);
}
fn allow_only(shell_allow: &[&str]) -> CapabilityPolicy {
CapabilityPolicy {
shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
..CapabilityPolicy::default()
}
}
fn sealed_landlock_enforcement(
test: &str,
policy: &CapabilityPolicy,
) -> Option<(ShellEnforcement, tempfile::TempDir)> {
if crate::network_namespace::skip_without_egress_namespace(test) {
return None;
}
let probe = HostProbe::probe();
if probe.tier() != EnforcementTier::KernelSealed {
eprintln!(
"SKIP — PROVES NOTHING ABOUT SEALED ON THIS HOST: {test} needs a host that \
reaches KernelSealed; detected {:?}",
probe.tier()
);
return None;
}
let runtime = tempfile::tempdir().unwrap();
let enforcement = ShellEnforcement::resolve_declared(
policy,
murmur_artifact::ContainmentClass::Sealed,
probe,
)
.unwrap()
.with_runtime_dir(test_runtime_dir(runtime.path()), None);
assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
Some((enforcement, runtime))
}
fn mounts_private_proc(test: &str, enforcement: &ShellEnforcement) -> bool {
match enforcement.installed_proc_view() {
crate::proc_view::ProcView::Private => true,
crate::proc_view::ProcView::Host(reason) => {
eprintln!(
"SKIP — PROVES NOTHING: {test} needs a host that mounts a private procfs; \
{}",
reason.describe()
);
false
}
}
}
const OWN_PROC_SCRIPT: &str = "head -n 1 /proc/self/status; \
head -n 1 /proc/thread-self/status; \
head -n 1 /proc/stat; \
n=0; while read -r line; do case $line in processor*) n=$((n+1));; esac; done \
< /proc/cpuinfo; echo \"processors=$n\"; \
head -n 1 /proc/meminfo; \
while read -r _ point kind options _; do \
if [ \"$point\" = /proc ]; then echo \"proc-mount: $kind $options\"; fi; \
done < /proc/self/mounts";
fn assert_reads_own_proc(result: &crate::shell::ShellResult) {
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert!(
!result.stderr.contains("Permission denied"),
"{}",
result.stderr
);
let lines: Vec<&str> = result.stdout.lines().collect();
assert!(lines.len() >= 5, "{}", result.stdout);
assert!(lines[0].starts_with("Name:"), "{}", result.stdout);
assert!(lines[1].starts_with("Name:"), "{}", result.stdout);
assert!(lines[2].starts_with("cpu "), "{}", result.stdout);
let processors: u32 = lines[3]
.strip_prefix("processors=")
.and_then(|n| n.parse().ok())
.unwrap_or_else(|| panic!("{}", result.stdout));
assert!(processors > 0, "{}", result.stdout);
assert!(lines[4].starts_with("MemTotal:"), "{}", result.stdout);
}
#[test]
fn a_scoped_command_reads_its_own_proc() {
let test = "a_scoped_command_reads_its_own_proc";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash", "cat", "head"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
if !mounts_private_proc(test, &enforcement) {
return;
}
let result = run_bash_in(&work, OWN_PROC_SCRIPT, &policy, &enforcement);
enforcement.end_session();
assert_reads_own_proc(&result);
}
struct HostSleeper(std::process::Child);
impl HostSleeper {
fn start() -> Self {
Self(
std::process::Command::new("sleep")
.arg("300")
.spawn()
.unwrap(),
)
}
fn pid(&self) -> u32 {
self.0.id()
}
}
impl Drop for HostSleeper {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn host_process_script(pid: u32) -> String {
format!("ls /proc; cat /proc/{pid}/cmdline; echo \"cat=$?\"")
}
fn assert_sees_no_host_process(result: &crate::shell::ShellResult, host_pids: &[u32]) {
assert_eq!(result.exit_code, 0, "{}", result.stderr);
let entries: Vec<u32> = result
.stdout
.lines()
.filter_map(|line| line.trim().parse().ok())
.collect();
assert!(!entries.is_empty(), "no process listed: {}", result.stdout);
assert!(entries.len() <= 3, "{entries:?}");
for pid in host_pids {
assert!(!entries.contains(pid), "host pid {pid} listed: {entries:?}");
}
assert!(
result.stderr.contains("No such file or directory"),
"{}",
result.stderr
);
assert!(result.stdout.contains("cat=1"), "{}", result.stdout);
}
#[test]
fn a_scoped_command_cannot_see_host_processes() {
let test = "a_scoped_command_cannot_see_host_processes";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash", "ls", "cat"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
if !mounts_private_proc(test, &enforcement) {
return;
}
let sleeper = HostSleeper::start();
let result = run_bash_in(
&work,
&host_process_script(sleeper.pid()),
&policy,
&enforcement,
);
enforcement.end_session();
assert_sees_no_host_process(&result, &[sleeper.pid(), std::process::id()]);
}
const NODE_OPENSSL_CONFIG: &str = "/etc/ssl/openssl.cnf";
#[test]
fn a_scoped_node_counts_cpus_and_resolves_localhost() {
let test = "a_scoped_node_counts_cpus_and_resolves_localhost";
if resolve_exec_allowlist(&["node".to_string()]).is_empty() {
eprintln!("SKIP — PROVES NOTHING: {test} needs node on PATH");
return;
}
let (_base, work, _outside) = work_and_outside();
let mut policy = allow_only(&["node"]);
policy.resources.memory_bytes = 64 * 1024 * 1024 * 1024;
if Path::new(NODE_OPENSSL_CONFIG).is_file() {
policy.shell_interpreter_runtime = vec![murmur_artifact::InterpreterRuntimeGrant {
binary: "node".to_string(),
dirs: vec![murmur_artifact::InterpreterRuntimeDir {
path: NODE_OPENSSL_CONFIG.to_string(),
list_dir: false,
}],
}];
}
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
if !mounts_private_proc(test, &enforcement) {
return;
}
let result = execute(
"node",
&[
"-e",
"console.log('cpus=' + require('os').cpus().length); \
require('dns').lookup('localhost', (error, address) => \
console.log(error ? 'lookup=' + error.code : 'lookup=' + address));",
],
&work,
&policy,
&enforcement,
);
enforcement.end_session();
assert_eq!(result.exit_code, 0, "{}", result.stderr);
let cpus: u32 = result
.stdout
.lines()
.find_map(|line| line.strip_prefix("cpus="))
.and_then(|n| n.parse().ok())
.unwrap_or_else(|| panic!("{}", result.stdout));
assert!(cpus > 0, "{}", result.stdout);
let address = result
.stdout
.lines()
.find_map(|line| line.strip_prefix("lookup="))
.unwrap_or_else(|| panic!("{}", result.stdout));
assert!(
address == "127.0.0.1" || address == "::1",
"{}",
result.stdout
);
}
#[test]
fn a_scoped_command_resolves_localhost_through_etc_hosts() {
let test = "a_scoped_command_resolves_localhost_through_etc_hosts";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash", "cat", "getent"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let result = run_bash_in(
&work,
"getent hosts localhost; echo \"getent=$?\"; \
cat /etc/hosts > /dev/null; echo \"hosts=$?\"; \
cat /etc/nsswitch.conf > /dev/null; echo \"nsswitch=$?\"; \
cat /etc/passwd > /dev/null; echo \"passwd=$?\"; \
cat /etc/resolv.conf > /dev/null; echo \"resolv=$?\"",
&policy,
&enforcement,
);
enforcement.end_session();
let stdout = &result.stdout;
assert!(
stdout
.lines()
.any(|line| line.starts_with("127.0.0.1") || line.starts_with("::1")),
"{stdout}"
);
for read in ["getent=0", "hosts=0", "nsswitch=0"] {
assert!(stdout.lines().any(|line| line == read), "{read}: {stdout}");
}
for denied in ["passwd=1", "resolv=1"] {
assert!(
stdout.lines().any(|line| line == denied),
"{denied}: {stdout}"
);
}
assert!(
result.stderr.contains("/etc/passwd: Permission denied"),
"{}",
result.stderr
);
assert!(
result
.stderr
.contains("/etc/resolv.conf: Permission denied"),
"{}",
result.stderr
);
}
#[test]
fn a_command_under_its_relay_keeps_its_exit_status_and_signal() {
let test = "a_command_under_its_relay_keeps_its_exit_status_and_signal";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let exited = run_bash_in(&work, "exit 7", &policy, &enforcement);
let killed = run_bash_in(&work, "kill -KILL $$", &policy, &enforcement);
let parent = run_bash_in(&work, "echo \"ppid=$PPID pid=$$\"", &policy, &enforcement);
enforcement.end_session();
assert_eq!(exited.exit_code, 7);
assert_eq!(killed.exit_code, 128 + libc::SIGKILL);
assert!(
parent.stdout.starts_with("ppid=0 pid="),
"{}",
parent.stdout
);
let pid: i32 = parent
.stdout
.trim()
.rsplit('=')
.next()
.unwrap()
.parse()
.unwrap();
assert!(pid > 1 && pid < 100, "a namespace pid: {}", parent.stdout);
}
fn host_pid(session: &ShellEnforcement, namespace_pid: i32) -> i32 {
let join = session
.session_netns
.establish(
&session.egress_tcp_ports,
crate::egress_proxy::EgressPolicy::new(
session.network_allow_rules.clone(),
session.network_allow_ips.clone(),
),
session.observed_held_ports(),
)
.expect("the session's namespace is established");
let pid_ns = std::fs::read_link(format!("/proc/self/fd/{}", join.pid_ns_fd()))
.expect("the session's PID namespace handle is readable");
let wanted = namespace_pid.to_string();
for _ in 0..100 {
let found = std::fs::read_dir("/proc")
.expect("/proc is readable")
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
.find(|pid| {
std::fs::read_link(format!("/proc/{pid}/ns/pid"))
.is_ok_and(|namespace| namespace == pid_ns)
&& std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(
|status| {
status
.lines()
.find_map(|line| line.strip_prefix("NSpid:"))
.and_then(|fields| fields.split_whitespace().last())
== Some(wanted.as_str())
},
)
});
if let Some(pid) = found {
return pid;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("no process of this session is pid {namespace_pid} in its namespace");
}
#[test]
fn a_backgrounded_process_survives_into_the_next_command_and_ends_with_the_session() {
let test =
"a_backgrounded_process_survives_into_the_next_command_and_ends_with_the_session";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash", "sleep"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let started = run_bash_in(
&work,
"sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid",
&policy,
&enforcement,
);
assert_eq!(started.exit_code, 0, "{}", started.stderr);
let namespace_pid = pid_in(&work, "sleeper.pid");
let sleeper = host_pid(&enforcement, namespace_pid);
let holder = host_pid(&enforcement, 1);
assert_ne!(sleeper, namespace_pid, "the pid file holds a namespace pid");
let seen = run_bash_in(
&work,
"kill -0 \"$(< sleeper.pid)\" && echo alive",
&policy,
&enforcement,
);
assert_eq!(seen.stdout.trim(), "alive", "{}", seen.stderr);
assert!(process_exists(sleeper));
enforcement.end_session();
let wait = SESSION_END_KILL_WAIT + std::time::Duration::from_secs(3);
assert!(
process_gone_within(holder, wait),
"holder {holder} outlived the session"
);
assert!(
process_gone_within(sleeper, wait),
"sleeper {sleeper} outlived the session"
);
}
#[test]
fn a_dropped_session_ends_its_holder_and_its_processes() {
let test = "a_dropped_session_ends_its_holder_and_its_processes";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash", "sleep"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let started = run_bash_in(
&work,
"sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid",
&policy,
&enforcement,
);
assert_eq!(started.exit_code, 0, "{}", started.stderr);
let sleeper = host_pid(&enforcement, pid_in(&work, "sleeper.pid"));
let holder = host_pid(&enforcement, 1);
drop(enforcement);
assert!(
process_gone_within(holder, SESSION_END_KILL_WAIT),
"holder {holder} outlived its dropped session"
);
assert!(
process_gone_within(sleeper, SESSION_END_KILL_WAIT),
"sleeper {sleeper} outlived its dropped session"
);
}
#[test]
fn killing_the_relay_ends_the_command() {
use std::os::unix::process::{CommandExt, ExitStatusExt};
let test = "killing_the_relay_ends_the_command";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash", "sleep"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let bash = resolve_exec_allowlist(&["bash".to_string()])
.into_iter()
.next()
.unwrap();
let mut command = std::process::Command::new(bash);
command
.args(["-c", "echo $$ > command.pid; exec sleep 300"])
.current_dir(&work)
.process_group(0);
let _handle = prepare_enforcement(&mut command, &enforcement, &work).unwrap();
let mut relay = command.spawn().unwrap();
let pid_file = work.join("command.pid");
for _ in 0..200 {
if std::fs::read_to_string(&pid_file).is_ok_and(|text| text.ends_with('\n')) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let command_pid = host_pid(&enforcement, pid_in(&work, "command.pid"));
assert_ne!(
command_pid,
relay.id() as i32,
"the command is not its relay"
);
relay.kill().unwrap();
let status = relay.wait().unwrap();
assert_eq!(status.signal(), Some(libc::SIGKILL));
assert!(
process_gone_within(command_pid, std::time::Duration::from_secs(5)),
"command {command_pid} outlived its relay"
);
enforcement.end_session();
}
#[test]
fn a_process_group_sigterm_reaches_the_command_past_its_relay() {
use std::os::unix::process::{CommandExt, ExitStatusExt};
let test = "a_process_group_sigterm_reaches_the_command_past_its_relay";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash", "sleep"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let bash = resolve_exec_allowlist(&["bash".to_string()])
.into_iter()
.next()
.unwrap();
let mut command = std::process::Command::new(bash);
command
.args(["-c", "echo $$ > command.pid; exec sleep 300"])
.current_dir(&work)
.process_group(0);
let _handle = prepare_enforcement(&mut command, &enforcement, &work).unwrap();
let mut relay = command.spawn().unwrap();
let pid_file = work.join("command.pid");
for _ in 0..200 {
if std::fs::read_to_string(&pid_file).is_ok_and(|text| text.ends_with('\n')) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let command_pid = host_pid(&enforcement, pid_in(&work, "command.pid"));
let sent_at = std::time::Instant::now();
assert!(crate::formation_launch::signal_group(
i64::from(relay.id()),
libc::SIGTERM
));
let status = relay.wait().unwrap();
assert_eq!(status.signal(), Some(libc::SIGTERM), "{status:?}");
assert!(
sent_at.elapsed() < std::time::Duration::from_secs(5),
"the command took {:?} to end",
sent_at.elapsed()
);
assert!(
process_gone_within(command_pid, std::time::Duration::from_secs(5)),
"command {command_pid} outlived the group SIGTERM"
);
enforcement.end_session();
}
#[test]
fn a_sealed_command_reads_its_own_proc_and_sees_no_host_process() {
let test = "a_sealed_command_reads_its_own_proc_and_sees_no_host_process";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash", "cat", "head", "ls"]);
let Some((enforcement, _runtime)) = sealed_landlock_enforcement(test, &policy) else {
return;
};
if !mounts_private_proc(test, &enforcement) {
return;
}
let own = run_bash_in(&work, OWN_PROC_SCRIPT, &policy, &enforcement);
let sleeper = HostSleeper::start();
let host = run_bash_in(
&work,
&host_process_script(sleeper.pid()),
&policy,
&enforcement,
);
enforcement.end_session();
assert_reads_own_proc(&own);
assert_sees_no_host_process(&host, &[sleeper.pid(), std::process::id()]);
}
#[test]
fn a_host_proc_view_keeps_proc_denied_and_says_why() {
let test = "a_host_proc_view_keeps_proc_denied_and_says_why";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash", "cat", "ls"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let refused =
crate::proc_view::ProcView::Host(crate::proc_view::HostProcReason::MountRefused(
"mounting proc over /proc failed with EPERM".to_string(),
));
let enforcement = enforcement.with_proc_view(refused.clone());
assert_eq!(enforcement.installed_proc_view(), refused);
let result = run_bash_in(
&work,
"cat /proc/self/status; echo \"cat=$?\"; ls /proc/1 >/dev/null; echo \"ls=$?\"",
&policy,
&enforcement,
);
enforcement.end_session();
assert!(result.stdout.contains("cat=1"), "{}", result.stdout);
assert!(
result
.stderr
.contains("/proc/self/status: Permission denied"),
"{}",
result.stderr
);
let report = crate::proc_view::process_view_report(&refused);
assert!(!report.private);
assert!(report
.not_private_because
.is_some_and(|reason| reason.contains("failed with EPERM")),);
}
#[test]
fn a_private_proc_mount_that_fails_at_spawn_fails_the_spawn() {
let test = "a_private_proc_mount_that_fails_at_spawn_fails_the_spawn";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
if !mounts_private_proc(test, &enforcement) {
return;
}
let _armed = ForcePrivateProcFailureGuard::new();
let error = crate::shell::execute_shell(
"bash",
&["-c", "echo ran > ran.txt"],
&[],
&crate::workdir_fs::WorkdirRoot::open(&work).unwrap(),
&policy,
&enforcement,
)
.expect_err("a failed private /proc mount must fail the spawn");
enforcement.end_session();
let error = error.to_string();
assert!(
error.contains(crate::proc_view::PRIVATE_PROC_FAILURE_PREFIX),
"{error}"
);
assert!(
error.contains("mounting proc over /proc failed: EPERM"),
"{error}"
);
assert!(!work.join("ran.txt").exists());
}
#[test]
fn scoped_name_resolution_grants_are_a_read_only_subset_of_sealed_etc() {
for path in SCOPED_NAME_RESOLUTION_PATHS {
assert!(
crate::sealed::SEALED_ETC_PATHS
.iter()
.any(|entry| entry.path == *path && !entry.directory),
"{path} is not a SEALED_ETC_PATHS file"
);
}
let grants = resolve_scoped_name_resolution_landlock_grants(EnforcementTier::KernelFull);
assert_eq!(
grants
.iter()
.map(|grant| grant.path.clone())
.collect::<Vec<_>>(),
vec![
PathBuf::from("/etc/hosts"),
PathBuf::from("/etc/nsswitch.conf")
]
);
assert!(grants
.iter()
.all(|grant| !grant.list_dir && !grant.executable));
for tier in [
EnforcementTier::KernelSealed,
EnforcementTier::KernelSeccompOnly,
EnforcementTier::EnvironmentOnly,
EnforcementTier::Unconfined,
] {
assert!(resolve_scoped_name_resolution_landlock_grants(tier).is_empty());
}
}
#[test]
fn a_creator_refused_the_pid_namespace_is_refused_with_e_cap_005() {
let test = "a_creator_refused_the_pid_namespace_is_refused_with_e_cap_005";
let (_base, work, _outside) = work_and_outside();
let policy = allow_only(&["bash"]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let _armed = crate::network_namespace::ForcePidNamespaceFailure::arm();
let error = crate::shell::execute_shell(
"bash",
&["-c", "echo ran > ran.txt"],
&[],
&crate::workdir_fs::WorkdirRoot::open(&work).unwrap(),
&policy,
&enforcement,
)
.expect_err("a session without its PID namespace must not run");
enforcement.end_session();
let error = error.to_string();
assert!(error.contains("E-CAP-005"), "{error}");
assert!(
error
.contains("unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID) failed with EPERM"),
"{error}"
);
assert!(error.contains("CLONE_NEWPID"), "{error}");
assert!(!work.join("ran.txt").exists());
}
#[test]
fn the_trust_store_grant_reaches_nothing_beyond_the_trust_store() {
let test = "the_trust_store_grant_reaches_nothing_beyond_the_trust_store";
let (_base, work, _outside) = work_and_outside();
let policy = trust_store_policy(&["cat", "ls", "bash"], &["127.0.0.1"], &work);
for entry in &policy.shell_trust_store {
for broad in ["/", "/etc", "/etc/ssl", "/etc/pki", "/etc/pki/tls"] {
assert_ne!(entry.path, Path::new(broad));
}
assert!(
!entry.path.components().any(|c| c.as_os_str() == "private"),
"{entry:?}"
);
}
assert!(resolve_trust_store_grants(&policy.shell_trust_store)
.iter()
.all(|grant| !grant.executable));
if std::fs::metadata("/etc/ssl/certs").map_or(true, |m| !m.is_dir()) {
eprintln!("SKIP — PROVES NOTHING: {test} needs /etc/ssl/certs on this host");
return;
}
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let listed = execute("ls", &["/etc/ssl/certs"], &work, &policy, &enforcement);
assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
let above = execute("ls", &["/etc/ssl"], &work, &policy, &enforcement);
assert_ne!(above.exit_code, 0);
assert!(
above.stderr.contains("Permission denied"),
"{}",
above.stderr
);
if std::fs::metadata("/etc/ssl/openssl.cnf").is_ok() {
let config = execute(
"cat",
&["/etc/ssl/openssl.cnf"],
&work,
&policy,
&enforcement,
);
assert_ne!(config.exit_code, 0);
assert!(
config.stderr.contains("Permission denied"),
"{}",
config.stderr
);
}
let private = execute("ls", &["/etc/ssl/private"], &work, &policy, &enforcement);
assert_ne!(private.exit_code, 0);
let probe = Path::new("/etc/ssl/certs/murmur-probe");
let written = execute(
"bash",
&["-c", "echo x > /etc/ssl/certs/murmur-probe"],
&work,
&policy,
&enforcement,
);
enforcement.end_session();
assert_ne!(written.exit_code, 0);
assert!(std::fs::symlink_metadata(probe).is_err());
}
#[test]
fn a_scoped_command_cannot_write_the_host_facts_cache() {
let test = "a_scoped_command_cannot_write_the_host_facts_cache";
let home = home_tempdir();
let workdir = home_tempdir();
let cache = home.path().join(".murmur").join(
crate::murmur_home::HomeEntryKind::HostFacts
.file_name()
.unwrap(),
);
crate::murmur_home::hold_private_dir(&home.path().join(".murmur")).unwrap();
crate::murmur_home::hold_private_dir(&cache).unwrap();
let policy = coreutils_policy(&[]);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let probe = cache.join("probe");
let written = run_bash_in(
workdir.path(),
&format!("printf x > '{}'", probe.display()),
&policy,
&enforcement,
);
enforcement.end_session();
assert_ne!(written.exit_code, 0);
assert!(
written.stderr.contains("Permission denied"),
"{}",
written.stderr
);
assert!(std::fs::symlink_metadata(&probe).is_err());
}
#[test]
fn resolve_trust_store_grants_reads_and_lists_but_never_executes() {
let paths = [
crate::trust_store::TrustStorePath {
path: PathBuf::from("/etc/ssl/certs"),
directory: true,
},
crate::trust_store::TrustStorePath {
path: PathBuf::from("/etc/pki/tls/cacert.pem"),
directory: false,
},
];
assert_eq!(
resolve_trust_store_grants(&paths),
vec![
LandlockGrant {
path: PathBuf::from("/etc/ssl/certs"),
list_dir: true,
executable: false,
},
LandlockGrant {
path: PathBuf::from("/etc/pki/tls/cacert.pem"),
list_dir: false,
executable: false,
},
]
);
assert!(resolve_trust_store_grants(&[]).is_empty());
}
#[test]
fn shell_enforcement_resolve_adds_the_trust_store() {
use murmur_artifact::ContainmentClass;
let store = vec![
crate::trust_store::TrustStorePath {
path: PathBuf::from("/murmur-test/certs"),
directory: true,
},
crate::trust_store::TrustStorePath {
path: PathBuf::from("/murmur-test/bundle.pem"),
directory: false,
},
];
let expected = resolve_trust_store_grants(&store);
let with_store = CapabilityPolicy {
shell_trust_store: store.clone(),
..CapabilityPolicy::default()
};
let without = CapabilityPolicy::default();
for (tier, declared) in [
(EnforcementTier::KernelFull, ContainmentClass::Scoped),
(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
] {
let decision =
crate::containment::ContainmentDecision::decide(tier, declared, false, false);
let granted = |policy: &CapabilityPolicy| {
let enforcement =
ShellEnforcement::resolve(policy, &decision, HostProbe::probe()).unwrap();
assert_eq!(enforcement.tier, tier);
enforcement.landlock_grants
};
let with_grants = granted(&with_store);
let without_grants = granted(&without);
for grant in &expected {
assert_eq!(
with_grants.iter().filter(|g| *g == grant).count(),
1,
"{tier:?}: {grant:?}"
);
}
assert!(without_grants
.iter()
.all(|grant| !grant.path.starts_with("/murmur-test")));
assert_eq!(with_grants.len(), without_grants.len() + expected.len());
let sealed_etc = resolve_sealed_etc_landlock_grants(tier);
assert_eq!(sealed_etc.is_empty(), tier != EnforcementTier::KernelSealed);
for grant in &sealed_etc {
assert!(with_grants.contains(grant), "{tier:?}: {grant:?}");
}
}
}
#[test]
fn a_scoped_python3_imports_its_stdlib_and_nothing_outside_it() {
let test = "a_scoped_python3_imports_its_stdlib_and_nothing_outside_it";
if find_on_path("python3").is_none() {
println!("[SKIP] {test}: no python3 on PATH");
return;
}
let (_base, work, outside) = work_and_outside();
let policy = derived_interpreter_policy("python3", &work);
let stdlib = assert_derives_stdlib_and_sqlite(&policy);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let imported = execute(
"python3",
&["-c", "import json, sqlite3, venv; print('imported')"],
&work,
&policy,
&enforcement,
);
assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
assert_eq!(imported.stdout.trim(), "imported");
let secret = outside.join("secret.txt");
std::fs::write(&secret, "secret").unwrap();
let probe = python_probe_outside(&secret, stdlib.parent().unwrap());
let denied = execute("python3", &["-c", &probe], &work, &policy, &enforcement);
assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
assert_eq!(
denied.stdout.lines().collect::<Vec<_>>(),
vec!["open PermissionError", "listdir PermissionError"]
);
}
#[test]
fn a_sealed_python3_imports_its_stdlib_and_nothing_outside_it() {
let test = "a_sealed_python3_imports_its_stdlib_and_nothing_outside_it";
if crate::network_namespace::skip_without_egress_namespace(test) {
return;
}
if find_on_path("python3").is_none() {
println!("[SKIP] {test}: no python3 on PATH");
return;
}
let probe = HostProbe::probe();
if probe.tier() != EnforcementTier::KernelSealed {
eprintln!(
"SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: {test} needs a host \
that reaches KernelSealed; detected {:?}. No composed root is built in this run.",
probe.tier()
);
return;
}
let (_base, work, outside) = work_and_outside();
let policy = derived_interpreter_policy("python3", &work);
assert_derives_stdlib_and_sqlite(&policy);
let runtime = tempfile::tempdir().unwrap();
let enforcement = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Sealed,
probe,
)
.expect("a sealed-capable host resolves sealed enforcement")
.with_runtime_dir(test_runtime_dir(runtime.path()), None);
assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
let imported = execute(
"python3",
&["-c", "import json, sqlite3, venv; print('imported')"],
&work,
&policy,
&enforcement,
);
assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
assert_eq!(imported.stdout.trim(), "imported");
let secret = outside.join("secret.txt");
std::fs::write(&secret, "secret").unwrap();
let program = format!(
"try:\n open({:?}).read(); print('allowed')\nexcept Exception as error:\n \
print(type(error).__name__)\n",
secret.to_string_lossy()
);
let denied = execute("python3", &["-c", &program], &work, &policy, &enforcement);
assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
let outcome = denied.stdout.trim();
assert!(
outcome == "PermissionError" || outcome == "FileNotFoundError",
"{outcome}"
);
}
#[test]
fn a_scoped_perl_loads_posix_from_its_inc() {
let test = "a_scoped_perl_loads_posix_from_its_inc";
if find_on_path("perl").is_none() {
println!("[SKIP] {test}: no perl on PATH");
return;
}
let (_base, work, _outside) = work_and_outside();
let policy = derived_interpreter_policy("perl", &work);
assert!(
!policy.shell_derived_interpreter_runtime.is_empty(),
"perl was not recognised or reported nothing"
);
let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
return;
};
let result = execute(
"perl",
&["-MPOSIX", "-e", "print qq{loaded\n}"],
&work,
&policy,
&enforcement,
);
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert_eq!(result.stdout.trim(), "loaded");
}
#[test]
fn host_path_landlock_rights_are_read_list_or_the_workdir_set_without_execute() {
use landlock::AccessFs;
assert_eq!(
linux_enforce::host_path_access_rights(false),
AccessFs::ReadFile | AccessFs::ReadDir
);
assert_eq!(
linux_enforce::host_path_access_rights(true),
linux_enforce::workdir_access_rights(false)
);
for read_write in [false, true] {
assert!(!linux_enforce::host_path_access_rights(read_write).contains(AccessFs::Execute));
}
}
struct HostPathDirs {
_base: tempfile::TempDir,
ro: PathBuf,
rw: PathBuf,
outside: PathBuf,
}
impl HostPathDirs {
fn new() -> Self {
let base = home_tempdir();
let root = std::fs::canonicalize(base.path()).unwrap();
let (ro, rw, outside) = (root.join("ro"), root.join("rw"), root.join("outside"));
for dir in [&ro, &rw, &outside] {
std::fs::create_dir(dir).unwrap();
}
std::fs::write(ro.join("in.txt"), "lent\n").unwrap();
std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
Self {
_base: base,
ro,
rw,
outside,
}
}
fn host_paths(&self) -> Vec<crate::host_paths::HostPath> {
use crate::host_paths::{HostPath, HostPathAccess};
vec![
HostPath {
declared: self.ro.display().to_string(),
path: self.ro.clone(),
access: HostPathAccess::Read,
credential_locations: Vec::new(),
},
HostPath {
declared: self.rw.display().to_string(),
path: self.rw.clone(),
access: HostPathAccess::ReadWrite,
credential_locations: Vec::new(),
},
]
}
fn assert_reach(&self, enforcement: &ShellEnforcement, policy: &CapabilityPolicy) {
let workdir = tempfile::tempdir().unwrap();
let read = run_bash_in(
workdir.path(),
&format!(
"read -r line < '{}/in.txt' && echo \"$line\"",
self.ro.display()
),
policy,
enforcement,
);
assert_eq!(read.exit_code, 0, "{}", read.stderr);
assert!(
read.stdout.lines().any(|line| line == "lent"),
"{}",
read.stdout
);
let write_ro = run_bash_in(
workdir.path(),
&format!("echo x > '{}/new'", self.ro.display()),
policy,
enforcement,
);
assert_ne!(write_ro.exit_code, 0);
assert!(
write_ro.stderr.contains("Permission denied")
|| write_ro.stderr.contains("Read-only file system"),
"{}",
write_ro.stderr
);
assert!(!self.ro.join("new").exists());
let write_rw = run_bash_in(
workdir.path(),
&format!("echo written > '{}/new'", self.rw.display()),
policy,
enforcement,
);
assert_eq!(write_rw.exit_code, 0, "{}", write_rw.stderr);
assert_eq!(
std::fs::read_to_string(self.rw.join("new")).unwrap(),
"written\n"
);
let outside = run_bash_in(
workdir.path(),
&format!("read -r line < '{}/secret.txt'", self.outside.display()),
policy,
enforcement,
);
assert_ne!(outside.exit_code, 0, "outside/secret.txt was read");
}
}
fn host_path_enforcement(
test: &str,
policy: &CapabilityPolicy,
class: murmur_artifact::ContainmentClass,
tier: EnforcementTier,
) -> Option<(ShellEnforcement, tempfile::TempDir)> {
if crate::network_namespace::skip_without_egress_namespace(test) {
return None;
}
let probe = HostProbe::probe();
let reaches = match tier {
EnforcementTier::KernelSealed => probe.tier() == EnforcementTier::KernelSealed,
_ => matches!(
probe.tier(),
EnforcementTier::KernelFull | EnforcementTier::KernelSealed
),
};
if !reaches {
eprintln!(
"SKIP — PROVES NOTHING ON THIS HOST: {test} needs {tier:?}; detected {:?}",
probe.tier()
);
return None;
}
let runtime = home_tempdir();
let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
.expect("the enforcement resolves")
.with_runtime_dir(test_runtime_dir(runtime.path()), None);
assert_eq!(enforcement.tier, tier);
eprintln!("{test}: tier {tier:?}");
Some((enforcement, runtime))
}
fn resolve_on_tier(
policy: &CapabilityPolicy,
tier: EnforcementTier,
class: murmur_artifact::ContainmentClass,
) -> Result<ShellEnforcement, String> {
let decision = crate::containment::ContainmentDecision::decide(tier, class, false, false);
let probe = HostProbe::from_parts(
None,
crate::namespace_probe::NamespaceReading {
network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
"not probed".to_string(),
)),
mount: crate::namespace_probe::MountAttempt::NotAttempted,
sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
userns_grant: crate::sealed::UsernsGrant::Withheld,
attachment: crate::sealed::ProfileAttachment::NotReported,
profile_gap: crate::sealed::ProfileGap::Undetermined,
},
);
ShellEnforcement::resolve(policy, &decision, probe)
}
#[test]
fn host_paths_on_kernel_full_read_write_and_outside() {
const TEST: &str = "host_paths_on_kernel_full_read_write_and_outside";
let dirs = HostPathDirs::new();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
host_paths: dirs.host_paths(),
..CapabilityPolicy::default()
};
let Some((enforcement, _runtime)) = host_path_enforcement(
TEST,
&policy,
murmur_artifact::ContainmentClass::Scoped,
EnforcementTier::KernelFull,
) else {
return;
};
dirs.assert_reach(&enforcement, &policy);
}
#[test]
fn host_paths_on_kernel_sealed_are_bound_at_their_own_path() {
const TEST: &str = "host_paths_on_kernel_sealed_are_bound_at_their_own_path";
let dirs = HostPathDirs::new();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
host_paths: dirs.host_paths(),
..CapabilityPolicy::default()
};
let Some((enforcement, _runtime)) = host_path_enforcement(
TEST,
&policy,
murmur_artifact::ContainmentClass::Sealed,
EnforcementTier::KernelSealed,
) else {
return;
};
dirs.assert_reach(&enforcement, &policy);
let workdir = tempfile::tempdir().unwrap();
let absent = run_bash_in(
workdir.path(),
&format!(
"[ -e '{}/secret.txt' ] && echo present || echo absent",
dirs.outside.display()
),
&policy,
&enforcement,
);
assert!(
absent.stdout.lines().any(|line| line == "absent"),
"{}",
absent.stdout
);
}
#[test]
fn a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed() {
const TEST: &str =
"a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed";
let base = home_tempdir();
let tools = std::fs::canonicalize(base.path()).unwrap().join("tools");
let bin = tools.join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("tool"), "#!/bin/bash\necho tool ran\n").unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
binary: "bash".to_string(),
dirs: vec![murmur_artifact::InterpreterRuntimeDir {
path: bin.display().to_string(),
list_dir: true,
}],
}],
host_paths: vec![crate::host_paths::HostPath {
declared: tools.display().to_string(),
path: tools.clone(),
access: crate::host_paths::HostPathAccess::ReadWrite,
credential_locations: Vec::new(),
}],
..CapabilityPolicy::default()
};
let error = resolve_on_tier(
&policy,
EnforcementTier::KernelFull,
murmur_artifact::ContainmentClass::Scoped,
)
.expect_err("scoped refuses a read-write host path over an execute root");
assert!(error.contains(&bin.display().to_string()), "{error}");
let Some((enforcement, _runtime)) = host_path_enforcement(
TEST,
&policy,
murmur_artifact::ContainmentClass::Sealed,
EnforcementTier::KernelSealed,
) else {
return;
};
assert_eq!(enforcement.host_path_read_only_binds, vec![bin.clone()]);
let workdir = tempfile::tempdir().unwrap();
let rewrite = run_bash_in(
workdir.path(),
&format!("echo x > '{}/tool'", bin.display()),
&policy,
&enforcement,
);
assert_ne!(rewrite.exit_code, 0);
assert!(
rewrite.stderr.contains("Read-only file system"),
"{}",
rewrite.stderr
);
let add = run_bash_in(
workdir.path(),
&format!("echo x > '{}/new'", bin.display()),
&policy,
&enforcement,
);
assert_ne!(add.exit_code, 0);
assert!(
add.stderr.contains("Read-only file system"),
"{}",
add.stderr
);
assert_eq!(
std::fs::read_to_string(bin.join("tool")).unwrap(),
"#!/bin/bash\necho tool ran\n"
);
assert!(!bin.join("new").exists());
let elsewhere = run_bash_in(
workdir.path(),
&format!("echo kept > '{}/notes'", tools.display()),
&policy,
&enforcement,
);
assert_eq!(elsewhere.exit_code, 0, "{}", elsewhere.stderr);
assert_eq!(
std::fs::read_to_string(tools.join("notes")).unwrap(),
"kept\n"
);
}
#[test]
fn a_vanished_host_path_fails_the_spawn_naming_it() {
let base = home_tempdir();
let gone = std::fs::canonicalize(base.path()).unwrap().join("gone");
std::fs::create_dir(&gone).unwrap();
let host_paths = vec![crate::host_paths::HostPath {
declared: gone.display().to_string(),
path: gone.clone(),
access: crate::host_paths::HostPathAccess::Read,
credential_locations: Vec::new(),
}];
std::fs::remove_dir(&gone).unwrap();
let workdir = tempfile::tempdir().unwrap();
for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
let enforcement = ShellEnforcement {
tier,
host_paths: host_paths.clone(),
..host_bounding_base().with_test_session_dirs()
};
let mut command = std::process::Command::new("bash");
let Err(error) = prepare_enforcement(&mut command, &enforcement, workdir.path()) else {
panic!("{tier:?}: a vanished host path must fail the spawn");
};
assert!(
error.contains(&gone.display().to_string()),
"{tier:?}: {error}"
);
}
}
#[test]
fn no_host_paths_add_nothing_to_the_enforcement() {
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
let class = if tier == EnforcementTier::KernelSealed {
murmur_artifact::ContainmentClass::Sealed
} else {
murmur_artifact::ContainmentClass::Scoped
};
let enforcement = resolve_on_tier(&policy, tier, class).unwrap();
assert_eq!(enforcement.tier, tier);
assert!(enforcement.host_paths.is_empty());
assert!(enforcement.host_path_read_only_binds.is_empty());
}
}
}
#[cfg(all(test, target_os = "linux"))]
mod guest_worker_enforcement_tests {
use super::*;
fn session(tier: EnforcementTier) -> ShellEnforcement {
let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
enforcement.tier = tier;
enforcement.workdir_exec = true;
enforcement.landlock_grants = vec![LandlockGrant {
path: PathBuf::from("/usr/bin/bash"),
list_dir: false,
executable: true,
}];
enforcement.sealed_bind_dirs = vec![PathBuf::from("/usr/bin")];
enforcement.host_paths = vec![crate::host_paths::HostPath {
declared: "~/lent".to_string(),
path: PathBuf::from("/srv/lent"),
access: crate::host_paths::HostPathAccess::Read,
credential_locations: Vec::new(),
}];
enforcement
}
#[test]
fn a_worker_enforcement_is_its_grant_closure_without_execute() {
let image = std::env::current_exe().unwrap().canonicalize().unwrap();
let state = PathBuf::from("/srv/state/notes");
for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
let session = session(tier);
let tools = session.for_guest_worker(
&WorkerGrantClosure {
read_write: vec![state.clone()],
runtime_dir: true,
host_paths: true,
},
&image,
);
assert_eq!(tools.tier, tier);
assert!(!tools.workdir_exec);
assert!(tools.sealed_bind_dirs.is_empty());
assert!(tools
.landlock_grants
.iter()
.any(|grant| grant.path == image));
for grant in &tools.landlock_grants {
assert!(
resolve_landlock_grants(std::slice::from_ref(&image)).contains(&grant.path),
"{tier:?}: only the worker image's closure is granted: {grant:?}"
);
}
assert!(!tools
.landlock_grants
.iter()
.any(|grant| grant.path == Path::new("/usr/bin/bash")));
let roots = tools
.runtime_dir
.as_ref()
.expect("the tools worker reads it");
assert!(roots.records.is_none(), "{tier:?}: no records directory");
assert_eq!(roots.temp.is_some(), tier == EnforcementTier::KernelSealed);
let host: Vec<_> = tools
.host_paths
.iter()
.map(|path| (path.path.clone(), path.access))
.collect();
assert_eq!(
host,
vec![
(
PathBuf::from("/srv/lent"),
crate::host_paths::HostPathAccess::Read
),
(state.clone(), crate::host_paths::HostPathAccess::ReadWrite),
]
);
assert_eq!(tools.resource_limits.memory_bytes, u64::MAX);
assert_eq!(tools.resource_limits.max_processes, u64::MAX);
assert_eq!(tools.resource_limits.cpu_seconds, None);
assert!(tools.cgroup_scope.is_none() && tools.runtime_dir_owner.is_none());
assert!(tools.fd_hygiene_fallback && !session.fd_hygiene_fallback);
let driver = session.for_guest_worker(&WorkerGrantClosure::default(), &image);
assert!(driver.host_paths.is_empty(), "{tier:?}");
assert_eq!(
driver.runtime_dir.is_some(),
tier == EnforcementTier::KernelSealed,
"{tier:?}: only a composed root needs the runtime directory without a grant"
);
assert!(Arc::ptr_eq(&driver.session_netns, &session.session_netns));
}
}
}