#![allow(unsafe_code)]
use std::{
collections::BTreeSet,
ffi::CString,
os::{
fd::{AsRawFd, FromRawFd, OwnedFd},
unix::{ffi::OsStrExt, fs::MetadataExt},
},
path::{Path, PathBuf},
};
use landlock::{
ABI, Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, NetPort, PathBeneath,
Ruleset, RulesetAttr, RulesetCreated, RulesetCreatedAttr, Scope,
};
use crate::{
config::SandboxPath,
error::CoreError,
profile::{NetworkMode, SandboxProfile},
sandbox::{
BackendOptions,
linux::probe::{LandlockAbi, ProbeResult},
},
};
pub const READ_ALLOWLIST_ANCHORS: &[&str] = &[
"/etc",
"/lib",
"/lib32",
"/lib64",
"/usr",
"/sys",
"/dev/null",
"/dev/zero",
"/dev/random",
"/dev/urandom",
"/dev/tty",
"/run/systemd/resolve/stub-resolv.conf",
"/run/systemd/resolve/resolv.conf",
];
pub const PROC_READ_ALLOWLIST_ANCHORS: &[&str] = &[
"/proc/cpuinfo",
"/proc/filesystems",
"/proc/loadavg",
"/proc/meminfo",
"/proc/stat",
"/proc/sys",
"/proc/uptime",
"/proc/version",
];
const BASELINE_WRITE_PATHS: &[&str] = &["/dev/null", "/dev/zero"];
const MAX_CARVED_READ_ENTRIES: usize = 100_000;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum UntrustedSymlinkBehavior {
Reject,
Skip,
}
const PRIVILEGE_ESCALATION_BINARIES: &[&str] = &[
"/usr/bin/sudo",
"/bin/sudo",
"/usr/bin/su",
"/bin/su",
"/usr/bin/runuser",
"/usr/sbin/runuser",
"/usr/bin/gosu",
"/usr/local/bin/gosu",
"/usr/bin/doas",
"/usr/local/bin/doas",
"/usr/bin/pkexec",
"/usr/bin/chsh",
"/usr/bin/chfn",
"/usr/bin/newgrp",
"/usr/bin/sg",
"/usr/bin/passwd",
"/usr/bin/gpasswd",
"/usr/bin/capsh",
"/usr/sbin/capsh",
"/usr/bin/setpriv",
"/usr/bin/nsenter",
"/usr/bin/unshare",
"/usr/sbin/unshare",
"/usr/bin/systemd-run",
"/usr/bin/machinectl",
"/usr/bin/pkttyagent",
"/usr/bin/dbus-launch",
"/usr/bin/mount",
"/usr/bin/umount",
"/bin/mount",
"/bin/umount",
"/usr/bin/fusermount",
"/usr/bin/fusermount3",
];
fn read_access(_abi: ABI) -> BitFlags<AccessFs> {
BitFlags::from(AccessFs::ReadFile) | AccessFs::ReadDir
}
fn read_directory_access(_abi: ABI) -> BitFlags<AccessFs> {
BitFlags::from(AccessFs::ReadDir)
}
fn write_access(abi: ABI) -> BitFlags<AccessFs> {
let mut access = AccessFs::from_write(abi);
access.remove(AccessFs::IoctlDev | AccessFs::ResolveUnix);
access
}
fn ephemeral_write_access(abi: ABI) -> BitFlags<AccessFs> {
write_access(abi) | (AccessFs::from_all(abi) & AccessFs::ResolveUnix)
}
fn exec_access(abi: ABI) -> BitFlags<AccessFs> {
BitFlags::from(AccessFs::Execute) | read_access(abi)
}
fn highest_abi(probe: &ProbeResult) -> ABI {
match probe.abi {
LandlockAbi::Unsupported => ABI::V1,
LandlockAbi::V1 => ABI::V1,
LandlockAbi::V2 => ABI::V2,
LandlockAbi::V3 => ABI::V3,
LandlockAbi::V4 => ABI::V4,
LandlockAbi::V5 => ABI::V5,
LandlockAbi::V6 => ABI::V6,
LandlockAbi::V7 => ABI::V7,
LandlockAbi::V8 => ABI::V8,
LandlockAbi::V9 => ABI::V9,
}
}
fn handled_fs_access(abi: ABI, mode: NetworkMode) -> BitFlags<AccessFs> {
let mut access = AccessFs::from_all(abi);
if mode == NetworkMode::AllowAll {
access.remove(AccessFs::ResolveUnix);
}
access
}
pub struct CompiledLandlock {
pub ruleset: RulesetCreated,
}
impl std::fmt::Debug for CompiledLandlock {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompiledLandlock").finish_non_exhaustive()
}
}
pub fn compile(
profile: &SandboxProfile,
proxy_port: Option<u16>,
probe: &ProbeResult,
options: BackendOptions,
) -> Result<CompiledLandlock, CoreError> {
lint_allow_exec_for_priv_escalation(profile, options)?;
let forbidden_reads = build_forbidden_reads(profile)?;
lint_forbidden_reads_against_grants(profile, &forbidden_reads, options)?;
let abi = highest_abi(probe);
let ruleset = Ruleset::default()
.set_compatibility(CompatLevel::HardRequirement)
.handle_access(handled_fs_access(abi, profile.network_mode))?;
let ruleset = if probe.abi.supports_scopes() {
let ruleset = ruleset.scope(Scope::Signal)?;
if profile.network_mode == NetworkMode::AllowAll {
ruleset
} else {
ruleset.scope(Scope::AbstractUnixSocket)?
}
} else {
ruleset
};
let ruleset =
if probe.abi.supports_net_port_filter() && profile.network_mode != NetworkMode::AllowAll {
ruleset.handle_access(AccessNet::ConnectTcp | AccessNet::BindTcp)?
} else {
ruleset
};
let mut created = ruleset.create()?.no_new_privs(false);
let baseline_reads: Vec<PathBuf> = READ_ALLOWLIST_ANCHORS
.iter()
.chain(PROC_READ_ALLOWLIST_ANCHORS)
.map(PathBuf::from)
.collect();
let mut carved_entries = 0_usize;
for path in &baseline_reads {
let sandbox_path = if path.is_dir() {
SandboxPath::dir(path.clone())
} else {
SandboxPath::file(path.clone())
};
created = add_read_rule(
created,
&sandbox_path,
&forbidden_reads,
abi,
&mut carved_entries,
)?;
}
for sp in &profile.allow_read {
created = add_read_rule(created, sp, &forbidden_reads, abi, &mut carved_entries)?;
}
for sp in &profile.allow_write {
let access = if profile
.ephemeral_write_exec
.iter()
.any(|root| sp.path.starts_with(root))
{
ephemeral_write_access(abi)
} else {
write_access(abi)
};
created = add_write_rule(created, sp, access, abi)?;
created = add_path_rules(
created,
std::slice::from_ref(&sp.path),
read_access(abi),
abi,
UntrustedSymlinkBehavior::Reject,
)?;
}
for path in BASELINE_WRITE_PATHS {
created = add_write_rule(
created,
&SandboxPath::file(PathBuf::from(path)),
write_access(abi),
abi,
)?;
}
for (index, sp) in profile.allow_exec.iter().enumerate() {
let symlink_behavior = if index < profile.first_user_allow_exec {
UntrustedSymlinkBehavior::Skip
} else {
UntrustedSymlinkBehavior::Reject
};
created = add_path_rules(
created,
std::slice::from_ref(&sp.path),
exec_access(abi),
abi,
symlink_behavior,
)?;
}
if probe.abi.supports_net_port_filter() {
match profile.network_mode {
NetworkMode::Proxy => {
let port = proxy_port.ok_or_else(|| {
CoreError::Backend("proxy network mode has no live proxy port".to_owned())
})?;
created = created.add_rule(NetPort::new(port, AccessNet::ConnectTcp))?;
}
NetworkMode::DirectHttps443 => {
created = created.add_rule(NetPort::new(443, AccessNet::ConnectTcp))?;
}
NetworkMode::DenyAll | NetworkMode::AllowAll => {}
}
}
Ok(CompiledLandlock { ruleset: created })
}
fn add_read_rule(
created: RulesetCreated,
path: &SandboxPath,
forbidden: &BTreeSet<PathBuf>,
abi: ABI,
visited: &mut usize,
) -> Result<RulesetCreated, CoreError> {
use crate::config::PathKind;
if forbidden
.iter()
.any(|denied| path_is_under(&path.path, denied))
{
return Ok(created);
}
let has_denied_descendant = forbidden
.iter()
.any(|denied| denied != &path.path && path_is_under(denied, &path.path));
if !has_denied_descendant {
return add_path_rules(
created,
std::slice::from_ref(&path.path),
read_access(abi),
abi,
UntrustedSymlinkBehavior::Reject,
);
}
if !matches!(path.kind, PathKind::Subpath) {
return Err(CoreError::ProfileLint(format!(
"literal read grant '{}' contains a denied descendant",
path.path.display()
)));
}
let Some(fd) = open_existing_safely(&path.path)? else {
return Ok(created);
};
add_carved_read_directory(created, fd, &path.path, forbidden, abi, visited)
}
#[allow(
clippy::disallowed_methods,
reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
)]
fn add_carved_read_directory(
mut created: RulesetCreated,
directory: OwnedFd,
logical_path: &Path,
forbidden: &BTreeSet<PathBuf>,
abi: ABI,
visited: &mut usize,
) -> Result<RulesetCreated, CoreError> {
let proc_path = PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd()));
let mut entries = Vec::new();
for entry in std::fs::read_dir(&proc_path).map_err(CoreError::Io)? {
let entry = entry.map_err(CoreError::Io)?;
*visited = visited.saturating_add(1);
if *visited > MAX_CARVED_READ_ENTRIES {
return Err(CoreError::ProfileLint(format!(
"read policy under '{}' exceeds {MAX_CARVED_READ_ENTRIES} entries",
logical_path.display()
)));
}
entries.push(entry.file_name());
}
created = created.add_rule(PathBeneath::new(
duplicate_fd(directory.as_raw_fd())?,
read_directory_access(abi),
))?;
for name in entries {
let child_path = logical_path.join(&name);
if forbidden
.iter()
.any(|denied| path_is_under(&child_path, denied))
{
continue;
}
let nested_hole = forbidden
.iter()
.any(|denied| denied != &child_path && path_is_under(denied, &child_path));
let name = CString::new(name.as_bytes()).map_err(|_| {
CoreError::ProfileLint(format!(
"sandbox path contains NUL below '{}'",
logical_path.display()
))
})?;
let child = match openat2_component(directory.as_raw_fd(), &name, nested_hole) {
Ok(child) => child,
Err(error) if error.raw_os_error() == Some(libc::ELOOP) => continue,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(CoreError::Io(error)),
};
if nested_hole {
created =
add_carved_read_directory(created, child, &child_path, forbidden, abi, visited)?;
} else {
let compatible = access_for_fd(&child, read_access(abi), abi)?;
created = created.add_rule(PathBeneath::new(child, compatible))?;
}
}
Ok(created)
}
fn duplicate_fd(fd: libc::c_int) -> Result<OwnedFd, CoreError> {
let duplicated = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 3) };
if duplicated < 0 {
return Err(CoreError::Io(std::io::Error::last_os_error()));
}
Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
}
fn add_path_rules(
mut created: RulesetCreated,
paths: &[PathBuf],
access: BitFlags<AccessFs>,
abi: ABI,
symlink_behavior: UntrustedSymlinkBehavior,
) -> Result<RulesetCreated, CoreError> {
for path in paths {
if let Some(fd) = open_existing_safely_with(path, symlink_behavior)? {
let compatible = access_for_fd(&fd, access, abi)?;
created = created.add_rule(PathBeneath::new(fd, compatible))?;
}
}
Ok(created)
}
fn add_write_rule(
mut created: RulesetCreated,
path: &SandboxPath,
access: BitFlags<AccessFs>,
abi: ABI,
) -> Result<RulesetCreated, CoreError> {
use crate::config::PathKind;
let fd = match path.kind {
PathKind::Subpath => Some(open_or_create_directory(&path.path)?),
PathKind::Literal => open_existing_safely(&path.path)?,
PathKind::Regex => {
return Err(CoreError::ProfileLint(format!(
"regex write grants are not safely enforceable on Linux: '{}'",
path.path.display()
)));
}
};
if let Some(fd) = fd {
let compatible = access_for_fd(&fd, access, abi)?;
created = created.add_rule(PathBeneath::new(fd, compatible))?;
} else {
tracing::debug!(
path = %path.path.display(),
"literal write target does not exist; refusing to broaden its parent"
);
}
Ok(created)
}
fn access_for_fd(
fd: &OwnedFd,
access: BitFlags<AccessFs>,
abi: ABI,
) -> Result<BitFlags<AccessFs>, CoreError> {
let mut stat: libc::stat = unsafe { std::mem::zeroed() };
if unsafe { libc::fstat(fd.as_raw_fd(), &mut stat) } != 0 {
return Err(CoreError::Io(std::io::Error::last_os_error()));
}
if stat.st_mode & libc::S_IFMT == libc::S_IFDIR {
Ok(access)
} else {
Ok(access & AccessFs::from_file(abi))
}
}
fn open_existing_safely(path: &Path) -> Result<Option<OwnedFd>, CoreError> {
open_existing_safely_with(path, UntrustedSymlinkBehavior::Reject)
}
#[allow(
clippy::disallowed_methods,
reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
)]
fn open_existing_safely_with(
path: &Path,
symlink_behavior: UntrustedSymlinkBehavior,
) -> Result<Option<OwnedFd>, CoreError> {
match open_no_symlinks(path, false) {
Ok(fd) => Ok(Some(fd)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) if error.raw_os_error() == Some(libc::ELOOP) => {
let canonical = match std::fs::canonicalize(path) {
Ok(canonical) => canonical,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(CoreError::Io(error)),
};
if let Err(reason) = root_owned_chain(path) {
return handle_untrusted_symlink(path, &reason, symlink_behavior);
}
if let Err(reason) = root_owned_chain(&canonical) {
let reason = format!("canonical target is not immutable: {reason}");
return handle_untrusted_symlink(path, &reason, symlink_behavior);
}
open_no_symlinks(&canonical, false)
.map(Some)
.map_err(CoreError::Io)
}
Err(error) => Err(CoreError::Io(error)),
}
}
fn handle_untrusted_symlink(
path: &Path,
reason: &str,
behavior: UntrustedSymlinkBehavior,
) -> Result<Option<OwnedFd>, CoreError> {
match behavior {
UntrustedSymlinkBehavior::Reject => Err(CoreError::ProfileLint(format!(
"allowlist path '{}' traverses an untrusted symlink: {reason}",
path.display()
))),
UntrustedSymlinkBehavior::Skip => {
tracing::warn!(
path = %path.display(),
reason,
"skipping unsafe optional built-in executable"
);
Ok(None)
}
}
}
fn open_or_create_directory(path: &Path) -> Result<OwnedFd, CoreError> {
if !path.is_absolute() {
return Err(CoreError::ProfileLint(format!(
"sandbox path must be absolute: '{}'",
path.display()
)));
}
let mut current = open_root().map_err(CoreError::Io)?;
for component in path.components() {
use std::path::Component;
let name = match component {
Component::RootDir => continue,
Component::Normal(name) => name,
_ => {
return Err(CoreError::ProfileLint(format!(
"sandbox path contains traversal: '{}'",
path.display()
)));
}
};
let name = CString::new(name.as_bytes()).map_err(|_| {
CoreError::ProfileLint(format!("sandbox path contains NUL: '{}'", path.display()))
})?;
let next = match openat2_component(current.as_raw_fd(), &name, true) {
Ok(fd) => fd,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let rc = unsafe { libc::mkdirat(current.as_raw_fd(), name.as_ptr(), 0o700) };
if rc != 0 {
let mkdir_error = std::io::Error::last_os_error();
if mkdir_error.kind() != std::io::ErrorKind::AlreadyExists {
return Err(CoreError::Io(mkdir_error));
}
}
openat2_component(current.as_raw_fd(), &name, true).map_err(CoreError::Io)?
}
Err(error) => return Err(CoreError::Io(error)),
};
current = next;
}
Ok(current)
}
fn open_no_symlinks(path: &Path, directory: bool) -> std::io::Result<OwnedFd> {
if !path.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"path is not absolute",
));
}
if path == Path::new("/") {
return open_root();
}
let relative = path.strip_prefix("/").expect("absolute path has root");
let relative = CString::new(relative.as_os_str().as_bytes())
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL"))?;
let root = open_root()?;
openat2_component(root.as_raw_fd(), &relative, directory)
}
fn open_root() -> std::io::Result<OwnedFd> {
let root = c"/";
let fd = unsafe { libc::open(root.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
if fd < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
}
fn openat2_component(
directory_fd: libc::c_int,
path: &CString,
directory: bool,
) -> std::io::Result<OwnedFd> {
let mut flags = (libc::O_PATH | libc::O_CLOEXEC) as u64;
if directory {
flags |= libc::O_DIRECTORY as u64;
}
let mut how: libc::open_how = unsafe { std::mem::zeroed() };
how.flags = flags;
how.mode = 0;
how.resolve = libc::RESOLVE_BENEATH | libc::RESOLVE_NO_SYMLINKS | libc::RESOLVE_NO_MAGICLINKS;
let fd = unsafe {
libc::syscall(
libc::SYS_openat2,
directory_fd,
path.as_ptr(),
&how,
std::mem::size_of::<libc::open_how>(),
) as libc::c_int
};
if fd < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
}
#[allow(
clippy::disallowed_methods,
reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
)]
fn root_owned_chain(path: &Path) -> Result<(), String> {
let mut current = PathBuf::from("/");
for component in path.components() {
use std::path::Component;
match component {
Component::RootDir => continue,
Component::Normal(name) => current.push(name),
_ => {
return Err(format!(
"'{}' contains a non-normal path component",
path.display()
));
}
}
let metadata = std::fs::symlink_metadata(¤t)
.map_err(|error| format!("cannot inspect '{}': {error}", current.display()))?;
if metadata.file_type().is_symlink() {
continue;
}
if metadata.uid() != 0 {
return Err(format!(
"'{}' is owned by UID {}, not root",
current.display(),
metadata.uid()
));
}
if metadata.mode() & 0o022 != 0 {
return Err(format!(
"'{}' is group/world writable (mode {:o})",
current.display(),
metadata.mode() & 0o7777
));
}
}
Ok(())
}
fn build_forbidden_reads(profile: &SandboxProfile) -> Result<BTreeSet<PathBuf>, CoreError> {
let mut set = BTreeSet::new();
let mut inspected = 0_usize;
for sp in &profile.deny_read {
match open_no_symlinks(&sp.path, false) {
Ok(fd) => reject_aliased_forbidden_tree(&fd, &sp.path, &mut inspected)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) if error.raw_os_error() == Some(libc::ELOOP) => {
return Err(CoreError::ProfileLint(format!(
"denyRead path '{}' traverses a symlink; refusing a policy whose canonical \
target could receive a read grant",
sp.path.display()
)));
}
Err(error) => return Err(CoreError::Io(error)),
}
set.insert(sp.path.clone());
}
Ok(set)
}
#[allow(
clippy::disallowed_methods,
reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
)]
fn reject_aliased_forbidden_tree(
fd: &OwnedFd,
logical_path: &Path,
inspected: &mut usize,
) -> Result<(), CoreError> {
let mut stat: libc::stat = unsafe { std::mem::zeroed() };
if unsafe { libc::fstat(fd.as_raw_fd(), &mut stat) } != 0 {
return Err(CoreError::Io(std::io::Error::last_os_error()));
}
let file_type = stat.st_mode & libc::S_IFMT;
if file_type == libc::S_IFREG {
if stat.st_nlink > 1 {
return Err(CoreError::ProfileLint(format!(
"denyRead file '{}' has {} hard links; Landlock cannot deny one pathname while \
an alias grants the same inode",
logical_path.display(),
stat.st_nlink,
)));
}
return Ok(());
}
if file_type != libc::S_IFDIR {
return Ok(());
}
let proc_path = PathBuf::from(format!("/proc/self/fd/{}", fd.as_raw_fd()));
for entry in std::fs::read_dir(proc_path).map_err(CoreError::Io)? {
let entry = entry.map_err(CoreError::Io)?;
*inspected = inspected.saturating_add(1);
if *inspected > MAX_CARVED_READ_ENTRIES {
return Err(CoreError::ProfileLint(format!(
"denyRead policy under '{}' exceeds {MAX_CARVED_READ_ENTRIES} entries",
logical_path.display()
)));
}
let name = entry.file_name();
let child_path = logical_path.join(&name);
let name = CString::new(name.as_bytes()).map_err(|_| {
CoreError::ProfileLint(format!(
"sandbox path contains NUL below '{}'",
logical_path.display()
))
})?;
let child = match openat2_component(fd.as_raw_fd(), &name, false) {
Ok(child) => child,
Err(error) if error.raw_os_error() == Some(libc::ELOOP) => {
return Err(CoreError::ProfileLint(format!(
"denyRead path '{}' traverses a symlink; refusing a policy whose canonical \
target could receive a read grant",
child_path.display()
)));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(CoreError::Io(error)),
};
reject_aliased_forbidden_tree(&child, &child_path, inspected)?;
}
Ok(())
}
fn lint_forbidden_reads_against_grants(
profile: &SandboxProfile,
forbidden: &BTreeSet<PathBuf>,
options: BackendOptions,
) -> Result<(), CoreError> {
let _ = options;
let user_slices: [(&str, &[SandboxPath]); 3] = [
(
"allowWrite",
&profile.allow_write[profile.first_user_allow_write..],
),
(
"allowExec",
&profile.allow_exec[profile.first_user_allow_exec..],
),
(
"allowRead",
&profile.allow_read[profile.first_user_allow_read..],
),
];
for (field, paths) in user_slices {
for sp in paths {
for f in forbidden {
if path_is_under(f, &sp.path) || path_is_under(&sp.path, f) {
return Err(CoreError::ProfileLint(format!(
"denyRead path '{}' overlaps user-supplied {} entry '{}'. Landlock grants \
on allowWrite and allowExec include data-read, so this would silently \
expose the denied path. Remove or relocate the {} entry, or remove the \
denyRead entry.",
f.display(),
field,
sp.path.display(),
field,
)));
}
}
}
}
Ok(())
}
fn lint_allow_exec_for_priv_escalation(
profile: &SandboxProfile,
options: BackendOptions,
) -> Result<(), CoreError> {
let _ = options;
for sp in &profile.allow_exec {
if !is_subpath(sp) {
continue;
}
for binary in PRIVILEGE_ESCALATION_BINARIES {
let bin_path = Path::new(binary);
if path_is_under(bin_path, &sp.path) {
return Err(CoreError::ProfileLint(format!(
"allowExec entry '{}' (directory) covers privilege-escalation binary '{}'. \
This would defeat the threat model. Replace it with explicit per-binary \
entries.",
sp.path.display(),
binary,
)));
}
}
}
Ok(())
}
fn is_subpath(sp: &SandboxPath) -> bool {
use crate::config::PathKind;
matches!(sp.kind, PathKind::Subpath)
}
fn path_is_under(candidate: &Path, anchor: &Path) -> bool {
candidate == anchor || candidate.starts_with(anchor)
}
impl From<landlock::RulesetError> for CoreError {
fn from(err: landlock::RulesetError) -> Self {
CoreError::Backend(format!("landlock ruleset error: {err}"))
}
}
impl From<landlock::AddRulesError> for CoreError {
fn from(err: landlock::AddRulesError) -> Self {
CoreError::Backend(format!("landlock add_rules error: {err}"))
}
}
impl From<landlock::AddRuleError<AccessFs>> for CoreError {
fn from(err: landlock::AddRuleError<AccessFs>) -> Self {
CoreError::Backend(format!("landlock add_rule (fs) error: {err}"))
}
}
impl From<landlock::AddRuleError<AccessNet>> for CoreError {
fn from(err: landlock::AddRuleError<AccessNet>) -> Self {
CoreError::Backend(format!("landlock add_rule (net) error: {err}"))
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::{
config::{PathKind, SandboxPath},
detect::Ecosystem,
};
#[test]
fn data_read_grants_never_include_execute() {
let access = read_access(ABI::V9);
assert!(access.contains(AccessFs::ReadFile));
assert!(access.contains(AccessFs::ReadDir));
assert!(!access.contains(AccessFs::Execute));
}
#[test]
fn persistent_write_grants_exclude_execute_ioctl_and_unix_resolution() {
for abi in [ABI::V1, ABI::V4, ABI::V5, ABI::V9] {
let access = write_access(abi);
assert!(!access.contains(AccessFs::Execute));
assert!(!access.contains(AccessFs::IoctlDev));
assert!(!access.contains(AccessFs::ResolveUnix));
}
assert!(ephemeral_write_access(ABI::V9).contains(AccessFs::ResolveUnix));
assert!(!ephemeral_write_access(ABI::V9).contains(AccessFs::IoctlDev));
}
#[test]
fn allow_all_does_not_handle_unix_socket_resolution() {
assert!(handled_fs_access(ABI::V9, NetworkMode::Proxy).contains(AccessFs::ResolveUnix));
assert!(!handled_fs_access(ABI::V9, NetworkMode::AllowAll).contains(AccessFs::ResolveUnix));
}
#[test]
fn literal_file_rules_drop_directory_only_rights() {
let temp = tempfile::tempdir().unwrap();
let file = tempfile::NamedTempFile::new_in(temp.path()).unwrap();
let directory = open_no_symlinks(temp.path(), true).unwrap();
let file = open_no_symlinks(file.path(), false).unwrap();
let requested = read_access(ABI::V9) | write_access(ABI::V9);
assert_eq!(
access_for_fd(&directory, requested, ABI::V9).unwrap(),
requested
);
let file_access = access_for_fd(&file, requested, ABI::V9).unwrap();
assert_eq!(file_access, requested & AccessFs::from_file(ABI::V9));
assert!(file_access.contains(AccessFs::ReadFile));
assert!(file_access.contains(AccessFs::WriteFile));
assert!(!file_access.contains(AccessFs::ReadDir));
assert!(!file_access.contains(AccessFs::MakeReg));
}
#[test]
#[allow(
clippy::disallowed_methods,
reason = "the Linux-only policy test is synchronous"
)]
fn immutable_system_symlinks_are_trusted() {
for path in [
Path::new("/bin/sh"),
Path::new("/lib64/ld-linux-x86-64.so.2"),
Path::new("/lib/ld-linux-x86-64.so.2"),
Path::new("/lib/ld-linux-aarch64.so.1"),
] {
if path.exists() {
root_owned_chain(path).unwrap();
let canonical = std::fs::canonicalize(path).unwrap();
root_owned_chain(&canonical).unwrap();
}
}
}
#[test]
fn missing_target_through_symlink_is_skipped_without_a_rule() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), temp.path().join("redirect")).unwrap();
let missing = temp.path().join("redirect/missing");
assert!(open_existing_safely(&missing).unwrap().is_none());
}
#[test]
fn existing_target_through_untrusted_symlink_is_rejected() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = tempfile::NamedTempFile::new_in(outside.path()).unwrap();
symlink(target.path(), temp.path().join("redirect")).unwrap();
let redirect = temp.path().join("redirect");
assert!(open_existing_safely(&redirect).is_err());
assert!(
open_existing_safely_with(&redirect, UntrustedSymlinkBehavior::Skip)
.unwrap()
.is_none()
);
}
#[test]
fn test_should_reject_priv_escalation_subpath() {
let mut profile = SandboxProfile::for_ecosystem(
Ecosystem::Rust,
&PathBuf::from("/home/test"),
&PathBuf::from("/home/test/pwd"),
);
profile.allow_exec.push(SandboxPath {
path: PathBuf::from("/usr/bin"),
kind: PathKind::Subpath,
});
let err =
lint_allow_exec_for_priv_escalation(&profile, BackendOptions::default()).unwrap_err();
assert!(format!("{err}").contains("privilege-escalation"));
}
#[test]
fn test_should_not_bypass_priv_escalation_lint_with_allow_degraded() {
let mut profile = SandboxProfile::for_ecosystem(
Ecosystem::Rust,
&PathBuf::from("/home/test"),
&PathBuf::from("/home/test/pwd"),
);
profile.allow_exec.push(SandboxPath {
path: PathBuf::from("/usr/bin"),
kind: PathKind::Subpath,
});
let res = lint_allow_exec_for_priv_escalation(
&profile,
BackendOptions {
allow_degraded: true,
..BackendOptions::default()
},
);
assert!(res.is_err());
}
#[test]
fn test_should_not_lint_baseline_anchor_overlap() {
let mut profile = SandboxProfile::for_ecosystem(
Ecosystem::Rust,
&PathBuf::from("/home/test"),
&PathBuf::from("/home/test/pwd"),
);
profile.deny_read.clear();
profile.deny_read.push(SandboxPath {
path: PathBuf::from("/etc/ssh"),
kind: PathKind::Subpath,
});
let forbidden = build_forbidden_reads(&profile).unwrap();
let lint =
lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default());
assert!(lint.is_ok(), "baseline anchor overlap must not lint");
}
#[test]
fn test_should_reject_forbidden_read_overlap_with_user_allow_read() {
let mut profile = SandboxProfile::for_ecosystem(
Ecosystem::Rust,
&PathBuf::from("/home/test"),
&PathBuf::from("/home/test/pwd"),
);
profile.deny_read.clear();
profile.deny_read.push(SandboxPath {
path: PathBuf::from("/home/test/.ssh"),
kind: PathKind::Subpath,
});
profile.allow_read.push(SandboxPath {
path: PathBuf::from("/home/test"),
kind: PathKind::Subpath,
});
let forbidden = build_forbidden_reads(&profile).unwrap();
let err =
lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
.unwrap_err();
assert!(format!("{err}").contains("denyRead"));
assert!(format!("{err}").contains("allowRead"));
}
#[test]
fn test_should_reject_forbidden_read_overlap_with_allow_write() {
let mut profile = SandboxProfile::for_ecosystem(
Ecosystem::Rust,
&PathBuf::from("/home/test"),
&PathBuf::from("/home/test/pwd"),
);
profile.deny_read.clear();
profile.deny_read.push(SandboxPath {
path: PathBuf::from("/home/test/.ssh"),
kind: PathKind::Subpath,
});
profile.allow_write.push(SandboxPath {
path: PathBuf::from("/home/test"),
kind: PathKind::Subpath,
});
let forbidden = build_forbidden_reads(&profile).unwrap();
let err =
lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
.unwrap_err();
assert!(format!("{err}").contains("denyRead"));
assert!(format!("{err}").contains("allowWrite"));
}
#[test]
fn test_should_reject_forbidden_read_overlap_with_allow_exec() {
let mut profile = SandboxProfile::for_ecosystem(
Ecosystem::Rust,
&PathBuf::from("/home/test"),
&PathBuf::from("/home/test/pwd"),
);
profile.deny_read.clear();
profile.deny_read.push(SandboxPath {
path: PathBuf::from("/home/test/.aws/credentials"),
kind: PathKind::Literal,
});
profile.allow_exec.push(SandboxPath {
path: PathBuf::from("/home/test/.aws"),
kind: PathKind::Subpath,
});
let forbidden = build_forbidden_reads(&profile).unwrap();
let err =
lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
.unwrap_err();
assert!(format!("{err}").contains("allowExec"));
}
#[test]
fn test_should_reject_user_grants_nested_beneath_forbidden_read() {
for field in ["allowRead", "allowWrite", "allowExec"] {
let mut profile = SandboxProfile::for_ecosystem(
Ecosystem::Rust,
&PathBuf::from("/home/test"),
&PathBuf::from("/home/test/pwd"),
);
profile.deny_read.clear();
profile
.deny_read
.push(SandboxPath::dir(PathBuf::from("/home/test/.ssh")));
let grant = SandboxPath::file(PathBuf::from("/home/test/.ssh/id_rsa"));
match field {
"allowRead" => profile.allow_read.push(grant),
"allowWrite" => profile.allow_write.push(grant),
"allowExec" => profile.allow_exec.push(grant),
_ => unreachable!(),
}
let forbidden = build_forbidden_reads(&profile).unwrap();
let error = lint_forbidden_reads_against_grants(
&profile,
&forbidden,
BackendOptions::default(),
)
.unwrap_err();
assert!(format!("{error}").contains(field));
}
}
#[test]
#[allow(
clippy::disallowed_methods,
reason = "synchronous filesystem setup is isolated to this Linux policy unit test"
)]
fn forbidden_read_symlink_fails_closed() {
use std::os::unix::fs::symlink;
let project = tempfile::tempdir().unwrap();
let target = project.path().join("config.env");
std::fs::write(&target, "secret").unwrap();
let denied = project.path().join(".env");
symlink(&target, &denied).unwrap();
let mut profile =
SandboxProfile::for_ecosystem(Ecosystem::Rust, project.path(), project.path());
profile.deny_read = vec![SandboxPath::file(denied)];
let error = build_forbidden_reads(&profile).unwrap_err();
assert!(format!("{error}").contains("traverses a symlink"));
}
#[test]
#[allow(
clippy::disallowed_methods,
reason = "synchronous filesystem setup is isolated to this Linux policy unit test"
)]
fn forbidden_read_hard_link_fails_closed() {
let project = tempfile::tempdir().unwrap();
let denied = project.path().join(".env");
let alias = project.path().join("config.env");
std::fs::write(&denied, "secret").unwrap();
std::fs::hard_link(&denied, &alias).unwrap();
let mut profile =
SandboxProfile::for_ecosystem(Ecosystem::Rust, project.path(), project.path());
profile.deny_read = vec![SandboxPath::file(denied)];
let error = build_forbidden_reads(&profile).unwrap_err();
assert!(format!("{error}").contains("hard links"));
}
#[test]
#[allow(
clippy::disallowed_methods,
reason = "synchronous filesystem setup is isolated to this Linux policy unit test"
)]
fn forbidden_directory_checks_descendant_hard_links() {
let project = tempfile::tempdir().unwrap();
let denied_directory = project.path().join("credentials");
std::fs::create_dir(&denied_directory).unwrap();
let denied = denied_directory.join("token");
std::fs::write(&denied, "secret").unwrap();
std::fs::hard_link(&denied, project.path().join("token-alias")).unwrap();
let mut profile =
SandboxProfile::for_ecosystem(Ecosystem::Rust, project.path(), project.path());
profile.deny_read = vec![SandboxPath::dir(denied_directory)];
let error = build_forbidden_reads(&profile).unwrap_err();
assert!(format!("{error}").contains("hard links"));
}
#[test]
fn test_should_not_bypass_forbidden_read_overlap_under_allow_degraded() {
let mut profile = SandboxProfile::for_ecosystem(
Ecosystem::Rust,
&PathBuf::from("/home/test"),
&PathBuf::from("/home/test/pwd"),
);
profile.deny_read.clear();
profile.deny_read.push(SandboxPath {
path: PathBuf::from("/home/test/.ssh"),
kind: PathKind::Subpath,
});
profile.allow_write.push(SandboxPath {
path: PathBuf::from("/home/test"),
kind: PathKind::Subpath,
});
let forbidden = build_forbidden_reads(&profile).unwrap();
let res = lint_forbidden_reads_against_grants(
&profile,
&forbidden,
BackendOptions {
allow_degraded: true,
..BackendOptions::default()
},
);
assert!(res.is_err(), "allow_degraded must not bypass the seal lint");
}
#[test]
fn test_open_or_create_directory_refuses_symlink_ancestor() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), temp.path().join("redirect")).unwrap();
let target = temp.path().join("redirect/cache");
assert!(open_or_create_directory(&target).is_err());
assert!(!outside.path().join("cache").exists());
}
}