use std::{
collections::BTreeSet,
path::{Path, PathBuf},
};
use landlock::{
ABI, Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, NetPort, PathBeneath,
PathFd, Ruleset, RulesetAttr, RulesetCreated, RulesetCreatedAttr,
};
use crate::{
config::SandboxPath,
error::CoreError,
profile::SandboxProfile,
sandbox::{
BackendOptions,
linux::probe::{LandlockAbi, ProbeResult},
},
};
pub const READ_ALLOWLIST_ANCHORS: &[&str] = &[
"/etc",
"/lib",
"/lib32",
"/lib64",
"/usr",
"/proc",
"/sys",
"/tmp",
"/var/tmp",
"/dev",
"/run/systemd/resolve/stub-resolv.conf",
"/run/systemd/resolve/resolv.conf",
];
const BASELINE_WRITE_PATHS: &[&str] = &["/tmp", "/var/tmp", "/dev/null", "/dev/zero", "/dev/shm"];
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> {
AccessFs::from_read(abi)
}
fn write_access(abi: ABI) -> BitFlags<AccessFs> {
AccessFs::from_all(abi)
}
fn exec_access(abi: ABI) -> BitFlags<AccessFs> {
BitFlags::from(AccessFs::Execute) | AccessFs::from_read(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,
}
}
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> {
if options.allow_degraded {
tracing::warn!(
"--allow-degraded ACTIVE: the following Linux sandbox checks are DISABLED for this \
run: (1) privilege-escalation subpath lint (allowExec subpaths can include \
sudo/su/pkexec/etc.); (2) denyRead forbidden-list seal \
(allowRead/allowWrite/allowExec may overlap denyRead paths); (3) \
refuse-on-missing-Landlock-ABI-v4 (kernel may run without per-port TCP filter). \
Re-run without --allow-degraded for full enforcement."
);
}
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::BestEffort)
.handle_access(AccessFs::from_all(abi))?;
let ruleset = if probe.abi.supports_net_port_filter() && !profile.allow_all_network {
ruleset.handle_access(AccessNet::ConnectTcp)?
} else {
ruleset
};
let mut created = ruleset.create()?.set_no_new_privs(false);
let baseline_reads: Vec<PathBuf> = READ_ALLOWLIST_ANCHORS.iter().map(PathBuf::from).collect();
for path in &baseline_reads {
let policy = symlink_policy_for(path);
created = add_path_rules(
created,
std::slice::from_ref(path),
read_access(abi),
policy,
)?;
}
for sp in &profile.allow_read {
let policy = symlink_policy_for(&sp.path);
created = add_path_rules(
created,
std::slice::from_ref(&sp.path),
read_access(abi),
policy,
)?;
}
let user_writes: Vec<PathBuf> = profile
.allow_write
.iter()
.map(|sp| sp.path.clone())
.collect();
let baseline_writes: Vec<PathBuf> = BASELINE_WRITE_PATHS.iter().map(PathBuf::from).collect();
let all_writes: Vec<PathBuf> = user_writes
.iter()
.chain(baseline_writes.iter())
.cloned()
.collect();
ensure_writable_dirs(&all_writes);
for path in &all_writes {
let policy = symlink_policy_for(path);
created = add_path_rules(
created,
std::slice::from_ref(path),
write_access(abi),
policy,
)?;
}
for sp in &profile.allow_exec {
let policy = symlink_policy_for(&sp.path);
created = add_path_rules(
created,
std::slice::from_ref(&sp.path),
exec_access(abi),
policy,
)?;
}
if probe.abi.supports_net_port_filter() && !profile.allow_all_network {
if let Some(port) = proxy_port {
created = created.add_rule(NetPort::new(port, AccessNet::ConnectTcp))?;
} else if !profile.enable_proxy {
created = created.add_rule(NetPort::new(443, AccessNet::ConnectTcp))?;
}
}
Ok(CompiledLandlock { ruleset: created })
}
#[derive(Debug, Clone, Copy)]
enum SymlinkPolicy {
Follow,
Refuse,
}
fn add_path_rules(
mut created: RulesetCreated,
paths: &[PathBuf],
access: BitFlags<AccessFs>,
policy: SymlinkPolicy,
) -> Result<RulesetCreated, CoreError> {
for path in paths {
if matches!(policy, SymlinkPolicy::Refuse) && is_symlink(path) {
return Err(CoreError::ProfileLint(format!(
"Landlock allowlist entry '{}' is a symlink. Refusing to open it — a symlink lets \
an attacker redirect the grant onto a target of their choosing. Replace the \
entry with the canonical target path or remove the symlink before re-running sbe.",
path.display(),
)));
}
let fd = match PathFd::new(path) {
Ok(fd) => fd,
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "skipping missing landlock path");
continue;
}
};
created = created.add_rule(PathBeneath::new(fd, access))?;
}
Ok(created)
}
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
fn is_symlink(p: &Path) -> bool {
std::fs::symlink_metadata(p)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
const ROOT_TRUSTED_PREFIXES: &[&str] = &[
"/bin",
"/sbin",
"/lib",
"/lib32",
"/lib64",
"/usr",
"/etc",
"/proc",
"/sys",
"/dev",
"/tmp",
"/var/tmp",
"/var/log",
"/var/cache",
"/var/lib",
"/var/run",
"/run",
"/opt",
"/boot",
"/srv",
];
fn symlink_policy_for(p: &Path) -> SymlinkPolicy {
if ROOT_TRUSTED_PREFIXES
.iter()
.any(|root| p == Path::new(root) || p.starts_with(root))
{
SymlinkPolicy::Follow
} else {
SymlinkPolicy::Refuse
}
}
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
fn ensure_writable_dirs(paths: &[PathBuf]) {
use std::os::unix::fs::PermissionsExt;
let home = std::env::var_os("HOME").map(PathBuf::from);
for p in paths {
match std::fs::symlink_metadata(p) {
Ok(m) if m.file_type().is_symlink() => {
tracing::warn!(
path = %p.display(),
"allow_write entry is a symlink; refusing to materialize. add_path_rules \
will reject this entry."
);
continue;
}
Ok(_) => continue, Err(_) => { }
}
let _ = std::fs::create_dir_all(p);
if let Some(h) = home.as_ref()
&& p.starts_with(h)
&& let Ok(meta) = std::fs::symlink_metadata(p)
&& !meta.file_type().is_symlink()
&& meta.file_type().is_dir()
{
let mut perms = meta.permissions();
perms.set_mode(0o700);
let _ = std::fs::set_permissions(p, perms);
}
}
}
fn build_forbidden_reads(profile: &SandboxProfile) -> Result<BTreeSet<PathBuf>, CoreError> {
let mut set = BTreeSet::new();
for sp in &profile.deny_read {
set.insert(sp.path.clone());
}
Ok(set)
}
fn lint_forbidden_reads_against_grants(
profile: &SandboxProfile,
forbidden: &BTreeSet<PathBuf>,
options: BackendOptions,
) -> Result<(), CoreError> {
if options.allow_degraded {
return Ok(());
}
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) {
return Err(CoreError::ProfileLint(format!(
"denyRead path '{}' is under user-supplied {} entry '{}'. Landlock grants \
on allowWrite and allowExec also imply read, so this would silently \
expose the denied path. Either narrow the {} entry, remove the denyRead \
entry, or pass --allow-degraded if you understand the threat model.",
f.display(),
field,
sp.path.display(),
field,
)));
}
}
}
}
Ok(())
}
fn lint_allow_exec_for_priv_escalation(
profile: &SandboxProfile,
options: BackendOptions,
) -> Result<(), CoreError> {
if options.allow_degraded {
return Ok(());
}
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 with explicit per-binary entries \
or pass --allow-degraded if you know what you are doing.",
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 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_pass_priv_escalation_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,
},
);
assert!(res.is_ok());
}
#[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_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,
},
);
assert!(res.is_ok(), "allow_degraded should bypass the seal lint");
}
}