mod child;
mod classes;
mod probe;
mod seccomp;
mod services;
mod spawn;
mod sys;
mod violations;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::io;
use std::os::fd::{AsRawFd, BorrowedFd};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use procage_policy::linux::RuleTarget;
use procage_policy::{
Construct, HardDenyClass, LinuxPlan, LowerError, LowerOptions, Plan, Policy, Relax, Relaxed,
Target, Tier, Tty, lift, lower,
};
pub use crate::unix::TMPDIR_GRANT;
pub use crate::violation::{Coverage, DeniedBy, Violation, Violations};
pub use child::Child;
pub use probe::{Probe, probe};
use crate::unix::TmpdirGuard;
pub const BACKEND_MAX_ABI: u8 = 11;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Step {
Session,
Lifetime,
Namespaces,
IdMap,
Mounts,
Capabilities,
Rlimit,
Fds,
NoNewPrivs,
Landlock,
Seccomp,
Verify,
Listener,
}
impl Step {
fn from_code(code: u32) -> Option<Step> {
use sys::steps::*;
Some(match code {
SESSION => Step::Session,
LIFETIME => Step::Lifetime,
NAMESPACES => Step::Namespaces,
ID_MAP => Step::IdMap,
MOUNTS => Step::Mounts,
CAPABILITIES => Step::Capabilities,
RLIMIT => Step::Rlimit,
FDS => Step::Fds,
NO_NEW_PRIVS => Step::NoNewPrivs,
LANDLOCK => Step::Landlock,
SECCOMP => Step::Seccomp,
VERIFY => Step::Verify,
LISTENER => Step::Listener,
_ => return None,
})
}
}
impl fmt::Display for Step {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Step::Session => "new session",
Step::Lifetime => "parent-death signal",
Step::Namespaces => "create namespaces",
Step::IdMap => "write id maps",
Step::Mounts => "mounts",
Step::Capabilities => "drop capabilities",
Step::Rlimit => "resource limits",
Step::Fds => "close inherited descriptors",
Step::NoNewPrivs => "no_new_privs",
Step::Landlock => "apply Landlock",
Step::Seccomp => "install seccomp filter",
Step::Verify => "verify sandbox",
Step::Listener => "hand over the seccomp listener",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unsupported {
Landlock {
abi: u8,
needed: u8,
},
Tier {
requested: Tier,
userns_errno: Option<i32>,
},
Syscall {
name: String,
},
Setting {
setting: &'static str,
},
ExecThreads {
threads: usize,
},
EnvClear,
}
impl fmt::Display for Unsupported {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Unsupported::Landlock { abi: 0, needed } => {
write!(f, "Landlock is not available (ABI {needed} needed)")
}
Unsupported::Landlock { abi, needed } => {
write!(f, "Landlock ABI {abi} is older than the {needed} needed")
}
Unsupported::Tier {
requested,
userns_errno,
} => {
write!(f, "tier {requested} is not available on this host")?;
match userns_errno {
Some(e) => write!(
f,
" (user namespaces: {})",
io::Error::from_raw_os_error(*e)
),
None => Ok(()),
}
}
Unsupported::Syscall { name } => {
write!(f, "syscall `{name}` has no number on this architecture")
}
Unsupported::ExecThreads { threads } => write!(
f,
"Cage::exec on tier T1 needs a single-threaded process (this one has {threads} \
threads, which user namespaces refuse): call it before starting threads, or \
choose tier T2 with CageOptions::with_tier"
),
Unsupported::EnvClear => write!(
f,
"EnvPolicy::Passthrough needs to see whether Command::env_clear was called, \
which this Rust standard library does not show; use EnvPolicy::Allowlist"
),
Unsupported::Setting { setting } => {
write!(f, "{setting} is not supported in this release")
}
}
}
}
#[derive(Debug)]
pub enum GrantError {
Undeclared {
name: String,
},
Missing {
name: String,
path: PathBuf,
error: io::Error,
},
Reserved {
name: String,
},
TmpdirWithExec,
Covers {
name: String,
hidden: PathBuf,
},
CoversClass {
name: String,
class: HardDenyClass,
},
}
impl fmt::Display for GrantError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GrantError::Undeclared { name } => {
write!(f, "the policy declares no grant named {name:?}")
}
GrantError::Missing { name, path, error } => {
write!(
f,
"grant {name}: cannot resolve {}: {error}",
path.display()
)
}
GrantError::Covers { name, hidden } => write!(
f,
"grant {name} gives write access around {}, which tier T2 cannot keep hidden \
inside a writable tree: bind the grant elsewhere, or use tier T1",
hidden.display()
),
GrantError::TmpdirWithExec => write!(
f,
"the tmpdir grant needs a handle that removes the directory, and Cage::exec \
returns none: use Cage::spawn, or bind a directory of your own to another grant"
),
GrantError::Reserved { name } => {
write!(
f,
"grant {name} is bound by procage itself (a private temporary directory)"
)
}
GrantError::CoversClass { name, class } => write!(
f,
"grant {name} reaches paths of the hard-deny class {class}, which tier T2 cannot deny inside it"
),
}
}
}
#[cfg(feature = "tokio")]
pub use crate::async_child::AsyncChild;
#[derive(Debug)]
#[non_exhaustive]
pub enum SpawnError {
Unsupported(Unsupported),
Lower(LowerError),
Path {
path: String,
error: io::Error,
},
Grant(GrantError),
Fd {
fd: i32,
},
Tmpdir(io::Error),
Walk {
root: PathBuf,
limit: usize,
},
Apply {
step: Step,
errno: i32,
},
Io(io::Error),
HardLinks(Vec<crate::LinkFinding>),
Explain(procage_policy::explain::ExplainError),
#[cfg(feature = "tokio")]
NoRuntime,
}
impl fmt::Display for SpawnError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SpawnError::Unsupported(u) => write!(f, "unsupported: {u}"),
SpawnError::Lower(e) => write!(f, "{e}"),
SpawnError::Path { path, error } => write!(f, "cannot use {path}: {error}"),
SpawnError::Grant(e) => write!(f, "{e}"),
SpawnError::Fd { fd } => write!(f, "descriptor {fd} handed to the spawn is not open"),
SpawnError::Tmpdir(e) => {
write!(f, "cannot create the private temporary directory: {e}")
}
SpawnError::Walk { root, limit } => write!(
f,
"{} has more than {limit} entries to search for hard-deny paths",
root.display()
),
SpawnError::Apply { step, errno } => write!(
f,
"the child failed at step `{step}`: {}; the target did not run",
io::Error::from_raw_os_error(*errno)
),
SpawnError::Io(e) => write!(f, "{e}"),
SpawnError::HardLinks(found) => f.write_str(&crate::audit::refusal(found)),
SpawnError::Explain(e) => write!(f, "{e}"),
#[cfg(feature = "tokio")]
SpawnError::NoRuntime => {
f.write_str("spawn_async needs a tokio runtime on the calling thread")
}
}
}
}
impl std::error::Error for SpawnError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SpawnError::Lower(e) => Some(e),
SpawnError::Path { error, .. } | SpawnError::Io(error) | SpawnError::Tmpdir(error) => {
Some(error)
}
SpawnError::Grant(GrantError::Missing { error, .. }) => Some(error),
_ => None,
}
}
}
impl From<LowerError> for SpawnError {
fn from(e: LowerError) -> Self {
SpawnError::Lower(e)
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CageOptions {
pub lower: LowerOptions,
pub tier: Option<Tier>,
pub walk_limit: usize,
pub homes: Vec<PathBuf>,
pub landlock_abi: Option<u8>,
pub violations: bool,
pub runtime_dirs: Vec<PathBuf>,
pub policy_in_receipt: bool,
pub link_audit: crate::LinkAudit,
pub link_exempt: Vec<PathBuf>,
pub handoff_audit: bool,
#[cfg(feature = "proxy")]
pub proxy: Option<procage_proxy::Proxy>,
}
impl Default for CageOptions {
fn default() -> Self {
Self {
lower: LowerOptions::default(),
tier: None,
walk_limit: 200_000,
homes: Vec::new(),
landlock_abi: None,
violations: false,
runtime_dirs: Vec::new(),
policy_in_receipt: false,
link_audit: crate::LinkAudit::Refuse,
link_exempt: Vec::new(),
handoff_audit: false,
#[cfg(feature = "proxy")]
proxy: None,
}
}
}
impl CageOptions {
pub fn with_lower(mut self, lower: LowerOptions) -> Self {
self.lower = lower;
self
}
pub fn with_tier(mut self, tier: Tier) -> Self {
self.tier = Some(tier);
self
}
pub fn with_walk_limit(mut self, limit: usize) -> Self {
self.walk_limit = limit;
self
}
pub fn with_home(mut self, home: impl AsRef<Path>) -> Self {
self.homes.push(home.as_ref().to_path_buf());
self
}
pub fn with_link_audit(mut self, mode: crate::LinkAudit) -> Self {
self.link_audit = mode;
self
}
pub fn with_link_exemption(mut self, path: impl AsRef<Path>) -> Self {
self.link_exempt.push(path.as_ref().to_path_buf());
self
}
pub fn with_handoff_audit(mut self) -> Self {
self.handoff_audit = true;
self
}
pub fn with_policy_in_receipt(mut self) -> Self {
self.policy_in_receipt = true;
self
}
pub fn with_runtime_dir(mut self, dir: impl AsRef<Path>) -> Self {
self.runtime_dirs.push(dir.as_ref().to_path_buf());
self
}
pub fn with_landlock_abi(mut self, abi: u8) -> Self {
self.landlock_abi = Some(abi);
self
}
pub fn with_violations(mut self) -> Self {
self.violations = true;
self
}
#[cfg(feature = "proxy")]
pub fn with_proxy(mut self, proxy: procage_proxy::Proxy) -> Self {
self.proxy = Some(proxy);
self
}
}
#[derive(Debug, Clone, Default)]
pub struct Grants {
bindings: Vec<(String, PathBuf)>,
}
impl Grants {
pub fn new() -> Self {
Self::default()
}
pub fn bind(mut self, name: &str, path: impl AsRef<Path>) -> Self {
self.bindings
.push((name.to_string(), path.as_ref().to_path_buf()));
self
}
}
#[derive(Debug, Clone)]
pub struct Cage {
plan: LinuxPlan,
filter: Arc<[seccomp::Insn]>,
homes: Vec<PathBuf>,
walk_limit: usize,
notifying: Option<Arc<[seccomp::Insn]>>,
exec_filter: Option<Arc<[seccomp::Insn]>>,
runtime: Vec<PathBuf>,
allowed: BTreeSet<Construct>,
facts: Arc<crate::receipt::CageFacts>,
audit: Arc<crate::audit::CageAudit>,
explainer: Arc<crate::explain::Lazy>,
#[cfg(feature = "proxy")]
proxied: Option<crate::proxied::Proxied>,
}
impl Cage {
pub fn new(policy: &Policy) -> Result<Cage, SpawnError> {
Cage::with_options(policy, &CageOptions::default())
}
pub fn fit(
policy: &Policy,
options: &CageOptions,
relax: &Relax,
) -> Result<(Cage, Vec<Relaxed>), SpawnError> {
let mut fitted = policy.clone();
let mut relaxed = Vec::new();
for _ in 0..4 {
let gaps = match Cage::with_options(&fitted, options) {
Ok(cage) => return Ok(cage.relaxed(&fitted, options, relaxed)),
Err(SpawnError::Lower(LowerError::Gaps(gaps))) => gaps,
Err(e) => return Err(e),
};
let done = relax
.apply(&mut fitted, &gaps)
.map_err(|rest| SpawnError::Lower(LowerError::Gaps(rest)))?;
relaxed.extend(done.into_iter().map(|(gap, relaxation)| {
Relaxed {
lift: socket_lift(&gap)
.unwrap_or_else(|| lift(policy, &gap, &options.lower, BACKEND_MAX_ABI)),
gap,
relaxation,
}
}));
}
Cage::with_options(&fitted, options).map(|cage| cage.relaxed(&fitted, options, relaxed))
}
fn relaxed(
mut self,
fitted: &Policy,
options: &CageOptions,
relaxed: Vec<Relaxed>,
) -> (Cage, Vec<Relaxed>) {
self.facts = Arc::new(facts(fitted, options, &self.plan, &relaxed));
(self, relaxed)
}
pub fn with_options(policy: &Policy, options: &CageOptions) -> Result<Cage, SpawnError> {
if policy.tty == Tty::NewPty {
return Err(SpawnError::Unsupported(Unsupported::Setting {
setting: "Tty::NewPty",
}));
}
let host = probe();
let tier = match options.tier {
Some(Tier::T1) if host.tier != Some(Tier::T1) => {
return Err(SpawnError::Unsupported(Unsupported::Tier {
requested: Tier::T1,
userns_errno: host.userns_errno,
}));
}
Some(tier) => tier,
None => host.tier.unwrap_or(Tier::T2),
};
let abi = host
.landlock_abi
.min(BACKEND_MAX_ABI)
.min(options.landlock_abi.unwrap_or(u8::MAX));
if abi < tier.min_landlock_abi() {
return Err(SpawnError::Unsupported(Unsupported::Landlock {
abi,
needed: tier.min_landlock_abi(),
}));
}
let target = Target::Linux {
tier,
landlock_abi: abi,
};
let Plan::Linux(mut plan) = lower(policy, target, &options.lower)? else {
unreachable!("lowering for Linux yields a Linux plan");
};
let build = |notify| {
seccomp::build(&plan.seccomp, &seccomp::NATIVE, notify)
.map_err(|name| SpawnError::Unsupported(Unsupported::Syscall { name }))
};
let filter: Arc<[seccomp::Insn]> = build(false)?.into();
let notifying = if options.violations {
Some(Arc::from(build(true)?))
} else {
None
};
let exec_filter = if policy.process.fork
&& policy.process.lifetime == procage_policy::Lifetime::Run
&& policy.tty != Tty::Inherit
{
use procage_policy::linux::{Action, Condition, SeccompRule, Syscall};
let mut rules = plan.seccomp.clone();
rules.push(SeccompRule {
syscall: Syscall::Setpgid,
condition: Condition::Always,
action: Action::Eperm,
reason: Some(procage_policy::DenyReason::Lifetime),
});
Some(Arc::from(
seccomp::build(&rules, &seccomp::NATIVE, false)
.map_err(|name| SpawnError::Unsupported(Unsupported::Syscall { name }))?,
))
} else {
None
};
let homes = classes::homes(&options.homes);
let rules = spawn::rule_paths(&plan);
classes::home_gaps(&mut plan, &rules, &homes, &options.lower, target)?;
proc_links(&mut plan, &rules, &options.lower.allow_degrade, target)?;
let runtime = services::runtime_dirs(&options.runtime_dirs);
if !plan.service_sockets.is_empty() {
let found = services::find(&plan.service_sockets, &runtime, abstract_visible(&plan));
let outcome = services::decide(&plan, &found, &options.lower.allow_degrade);
if !outcome.gaps.is_empty() {
return Err(SpawnError::Lower(LowerError::Gaps(outcome.gaps)));
}
if outcome.scope {
let scopes = &mut plan.landlock.scopes;
*scopes = procage_policy::linux::Scopes(
scopes.0 | procage_policy::linux::Scopes::ABSTRACT_UNIX_SOCKET.0,
);
}
for d in outcome.degradations {
if !plan.degradations.iter().any(|k| k.construct == d.construct) {
plan.degradations.push(d);
}
}
plan.degradations.sort();
}
#[cfg(feature = "proxy")]
let proxied = match &options.proxy {
None => None,
Some(proxy) => Some(crate::proxied::Proxied::new(proxy, &policy.net).ok_or(
SpawnError::Unsupported(Unsupported::Setting {
setting: "CageOptions::with_proxy without Network::Proxy",
}),
)?),
};
#[cfg(not(feature = "proxy"))]
let proxied: Option<()> = None;
if matches!(
plan.network,
procage_policy::linux::LinuxNetwork::ProxyBridge { .. }
) && proxied.is_none()
{
return Err(SpawnError::Unsupported(Unsupported::Setting {
setting: "Network::Proxy on Linux without CageOptions::with_proxy",
}));
}
let facts = Arc::new(facts(policy, options, &plan, &[]));
let audit = Arc::new(cage_audit(policy, options, &plan, &homes)?);
let explainer = Arc::new(crate::explain::Lazy::new(
policy.clone(),
target,
options.lower.clone(),
));
Ok(Cage {
plan,
filter,
homes,
walk_limit: options.walk_limit,
notifying,
exec_filter,
runtime,
allowed: options.lower.allow_degrade.clone(),
facts,
audit,
explainer,
#[cfg(feature = "proxy")]
proxied,
})
}
pub fn explain(
&self,
op: procage_policy::explain::Op,
path: impl AsRef<Path>,
grants: &Grants,
) -> Result<procage_policy::explain::Answer, SpawnError> {
let path = path.as_ref();
let question = crate::explain::question(op, path).map_err(|error| SpawnError::Path {
path: path.display().to_string(),
error,
})?;
let bound = self.bind(grants, &self.declared())?;
let context = crate::explain::context(bound, &self.homes).map_err(SpawnError::Io)?;
self.explainer
.get()?
.explain(&question, &context)
.map_err(SpawnError::Explain)
}
pub fn plan(&self) -> &LinuxPlan {
&self.plan
}
pub fn tier(&self) -> Tier {
self.plan.tier
}
pub fn landlock_abi(&self) -> u8 {
self.plan.landlock_abi
}
fn declared(&self) -> BTreeSet<&str> {
self.plan
.landlock
.rules
.iter()
.filter_map(|r| match &r.target {
RuleTarget::Grant(name) => Some(name.as_str()),
_ => None,
})
.collect()
}
pub fn spawn(&self, command: &mut Command, grants: &Grants) -> Result<Child, SpawnError> {
self.spawn_with_fds(command, grants, &[])
}
pub fn spawn_with_fds(
&self,
command: &mut Command,
grants: &Grants,
fds: &[BorrowedFd<'_>],
) -> Result<Child, SpawnError> {
let launch = self.launch(command, grants, fds)?;
match command.spawn() {
Ok(inner) => Ok(self.adopt(launch, crate::unix::Proc::Std(inner))),
Err(e) => Err(launch.abandon(e)),
}
}
pub(crate) fn launch(
&self,
command: &mut Command,
grants: &Grants,
fds: &[BorrowedFd<'_>],
) -> Result<Launch, SpawnError> {
let session = if self.plan.hygiene.tty == Tty::Inherit {
sys::Session::Group
} else {
sys::Session::New
};
let (parent_end, notify) = match &self.notifying {
Some(filter) => {
let (parent_end, child_end) = sys::socket_pair().map_err(SpawnError::Io)?;
(
Some(parent_end),
Some(sys::Notify::new(filter.to_vec(), child_end)),
)
}
None => (None, None),
};
#[cfg(feature = "proxy")]
let (bridge_end, bridge) = match &self.proxied {
Some(proxied) => {
let (parent_end, child_end) = sys::socket_pair().map_err(SpawnError::Io)?;
let ports: Vec<u16> = proxied.protocols().iter().map(|&(_, p)| p).collect();
let addrs: Vec<_> = proxied
.protocols()
.iter()
.map(|&(protocol, port)| {
(protocol, std::net::SocketAddr::from(([127, 0, 0, 1], port)))
})
.collect();
crate::proxied::set_env(command, &addrs);
(Some(parent_end), Some(sys::Bridge::new(ports, child_end)))
}
None => (None, None),
};
#[cfg(not(feature = "proxy"))]
let bridge = None;
let (dropped, removal, proc) =
self.prepare(command, grants, fds, session, notify, bridge)?;
let run_audit = self.audit.before_spawn().map_err(SpawnError::HardLinks)?;
let names = grants.bindings.iter().map(|(n, _)| n.clone()).collect();
Ok(Launch {
parent_end,
dropped,
removal,
proc,
run_audit,
names,
#[cfg(feature = "proxy")]
bridge_end,
})
}
pub(crate) fn adopt(&self, launch: Launch, inner: crate::unix::Proc) -> Child {
let Launch {
parent_end,
dropped,
removal,
proc,
run_audit,
mut names,
#[cfg(feature = "proxy")]
bridge_end,
} = launch;
let reporting = match parent_end {
None => child::Reporting::Off,
Some(socket) => self.supervise(&socket),
};
#[cfg(feature = "proxy")]
let proxy_run = match (&self.proxied, bridge_end) {
(Some(proxied), Some(socket)) => {
let mut listeners = Vec::new();
for &(protocol, _) in proxied.protocols() {
if let Ok(sys::HandOver::Listener(fd)) = sys::receive(&socket) {
listeners.push((protocol, std::net::TcpListener::from(fd)));
}
}
let record = match &reporting {
child::Reporting::On { record, .. } => Some(record.clone()),
_ => None,
};
proxied.serve(listeners, record)
}
_ => None,
};
let tmpdir = removal.disarm();
if tmpdir.is_some() {
names.push(TMPDIR_GRANT.into());
}
let mut facts = crate::receipt::RunFacts::new(self.facts.clone(), names);
if proc {
facts = facts.with_approximation(crate::receipt::ReceiptApproximation {
construct: Construct::ProcOtherProcesses,
note: procage_policy::linux::PROC_OTHER_PROCESSES_NOTE.into(),
});
}
#[cfg_attr(not(feature = "proxy"), allow(unused_mut))]
let mut child = Child::new(inner, dropped, tmpdir, reporting, facts, run_audit);
#[cfg(feature = "proxy")]
child.set_proxy(proxy_run);
child
}
fn supervise(&self, socket: &std::os::fd::OwnedFd) -> child::Reporting {
let unavailable = |reason: String| child::Reporting::Unavailable(reason);
match sys::receive(socket) {
Ok(sys::HandOver::Listener(listener)) => {
let record = Arc::new(std::sync::Mutex::new(crate::violation::Record::new(
Coverage::SystemCalls,
)));
let rules: Arc<[_]> = self.plan.seccomp.clone().into();
match violations::start(listener, rules, record.clone()) {
Ok(done) => child::Reporting::On { record, done },
Err(e) => unavailable(format!("cannot start the supervisor: {e}")),
}
}
Ok(sys::HandOver::Refused(errno)) => unavailable(format!(
"the kernel refused a seccomp listener ({}); refused calls still fail",
std::io::Error::from_raw_os_error(errno)
)),
Err(errno) => unavailable(format!(
"no listener arrived from the child ({})",
std::io::Error::from_raw_os_error(errno)
)),
}
}
pub fn exec(&self, command: &mut Command, grants: &Grants) -> SpawnError {
self.exec_with_fds(command, grants, &[])
}
pub fn exec_with_fds(
&self,
command: &mut Command,
grants: &Grants,
fds: &[BorrowedFd<'_>],
) -> SpawnError {
use std::os::unix::process::CommandExt;
if self.declared().contains(TMPDIR_GRANT) {
return SpawnError::Grant(GrantError::TmpdirWithExec);
}
let threads = sys::thread_count();
if self.plan.namespaces.is_some() && threads > 1 {
return SpawnError::Unsupported(Unsupported::ExecThreads { threads });
}
#[cfg(feature = "proxy")]
if self.proxied.is_some() {
return SpawnError::Unsupported(Unsupported::Setting {
setting: "Cage::exec with CageOptions::with_proxy",
});
}
match self.prepare(command, grants, fds, sys::Session::Keep, None, None) {
Ok(_) => match self.audit.before_spawn() {
Ok(_) => spawn::decode(command.exec()),
Err(found) => SpawnError::HardLinks(found),
},
Err(e) => e,
}
}
fn prepare(
&self,
command: &mut Command,
grants: &Grants,
fds: &[BorrowedFd<'_>],
session: sys::Session,
notify: Option<sys::Notify>,
bridge: Option<sys::Bridge>,
) -> Result<(Vec<String>, TmpdirGuard, bool), SpawnError> {
let keep = kept_fds(fds.iter().map(AsRawFd::as_raw_fd))?;
if grants.bindings.iter().any(|(name, _)| name == TMPDIR_GRANT) {
return Err(SpawnError::Grant(GrantError::Reserved {
name: TMPDIR_GRANT.into(),
}));
}
let declared = self.declared();
let tmpdir = if declared.contains(TMPDIR_GRANT) {
Some(crate::unix::private_tmpdir().map_err(SpawnError::Tmpdir)?)
} else {
None
};
let removal = TmpdirGuard(tmpdir.clone());
let mut grants = grants.clone();
if let Some(dir) = &tmpdir {
grants = grants.bind(TMPDIR_GRANT, dir);
if !command.get_envs().any(|(k, _)| k == "TMPDIR") {
command.env("TMPDIR", dir);
}
}
let bound = self.bind(&grants, &declared)?;
let service_covers = self.service_covers()?;
let ruleset = spawn::ruleset(
&self.plan,
&bound,
&self.hidden_piecewise(),
self.walk_limit,
)?;
let isolate = match self.plan.namespaces {
Some(ns) => {
let writable = spawn::writable(&self.plan, &bound)?;
let mut covers = classes::t1_covers(
&self.plan.classes,
&self.homes,
&writable,
self.walk_limit,
)?;
if self.plan.mask_unix_sockets {
covers.extend(self.socket_masks(&covers)?);
}
covers.extend(service_covers);
covers.sort();
covers.dedup();
let (uid, gid) = sys::ids();
let net = if ns.net { libc::CLONE_NEWNET } else { 0 };
Some(sys::Isolate {
flags: probe::T1_NAMESPACES | net,
uid_map: spawn::id_map(uid),
gid_map: spawn::id_map(gid),
mounts: spawn::mounts(&self.plan, &covers)?,
})
}
None => None,
};
let dropped = crate::unix::environment(command, &self.plan.hygiene.env)
.map_err(|_| SpawnError::Unsupported(Unsupported::EnvClear))?;
let parent = if session == sys::Session::Keep {
crate::unix::find_program(command).map_err(SpawnError::Io)?;
sys::parent_pid()
} else {
sys::pid()
};
sys::ChildPlan {
session,
parent,
isolate,
limits: crate::unix::limits(&self.plan.hygiene.limits),
keep,
ruleset,
filter: match (&self.exec_filter, session) {
(Some(exec), sys::Session::Keep) => exec.to_vec(),
_ => self.filter.to_vec(),
},
notify,
bridge,
}
.install(command);
let proc = bound.values().any(|p| proc_gap(&self.plan, p).is_some());
Ok((dropped, removal, proc))
}
fn hidden_piecewise(&self) -> Vec<PathBuf> {
use std::os::unix::ffi::OsStrExt;
if self.plan.namespaces.is_some() {
return Vec::new();
}
let to_path =
|p: &procage_policy::AbsPath| PathBuf::from(std::ffi::OsStr::from_bytes(p.as_bytes()));
let mut out: Vec<PathBuf> = self.plan.hide_by_siblings.iter().map(to_path).collect();
for class in self.plan.classes.iter().filter(|c| c.hide) {
for home in &self.homes {
out.extend(class.home.iter().map(|rel| home.join(rel)));
}
out.extend(class.system.iter().map(to_path));
}
out.retain(|p| std::fs::symlink_metadata(p).is_ok());
out.sort();
out.dedup();
out
}
fn service_covers(&self) -> Result<Vec<(PathBuf, classes::Cover)>, SpawnError> {
if self.plan.service_sockets.is_empty() {
return Ok(Vec::new());
}
let found = services::find(
&self.plan.service_sockets,
&self.runtime,
abstract_visible(&self.plan),
);
let mut outcome = services::decide(&self.plan, &found, &self.allowed);
if outcome.scope && !services::scoped(&self.plan) {
outcome.gaps.push(procage_policy::CapabilityGap {
construct: Construct::HdServiceHandoff,
target: Target::Linux {
tier: self.plan.tier,
landlock_abi: self.plan.landlock_abi,
},
kind: procage_policy::GapKind::Unsupported,
subject: Some("an abstract bus that appeared after the cage was made".into()),
});
}
if !outcome.gaps.is_empty() {
outcome.gaps.sort();
return Err(SpawnError::Lower(LowerError::Gaps(outcome.gaps)));
}
Ok(outcome.covers)
}
fn socket_masks(
&self,
covers: &[(PathBuf, classes::Cover)],
) -> Result<Vec<(PathBuf, classes::Cover)>, SpawnError> {
use std::os::unix::ffi::OsStrExt;
let to_path =
|p: &procage_policy::AbsPath| PathBuf::from(std::ffi::OsStr::from_bytes(p.as_bytes()));
let allowed: Vec<PathBuf> = self
.plan
.unix_socket_paths
.iter()
.map(|p| std::fs::canonicalize(to_path(p)).unwrap_or_else(|_| to_path(p)))
.collect();
let mut masked: Vec<PathBuf> = covers
.iter()
.filter(|(_, c)| matches!(c, classes::Cover::Hide))
.map(|(p, _)| p.clone())
.collect();
masked.extend(self.plan.mounts.iter().filter_map(|m| match m {
procage_policy::linux::Mount::Mask(p) => Some(to_path(p)),
_ => None,
}));
Ok(spawn::foreign_sockets(&allowed, &masked)?
.into_iter()
.map(|p| (p, classes::Cover::Hide))
.collect())
}
fn bind(
&self,
grants: &Grants,
declared: &BTreeSet<&str>,
) -> Result<BTreeMap<String, PathBuf>, SpawnError> {
let mut out = BTreeMap::new();
for (name, path) in &grants.bindings {
if !declared.contains(name.as_str()) {
return Err(SpawnError::Grant(GrantError::Undeclared {
name: name.clone(),
}));
}
let resolved = std::fs::canonicalize(path).map_err(|error| {
SpawnError::Grant(GrantError::Missing {
name: name.clone(),
path: path.clone(),
error,
})
})?;
if !self.allowed.contains(&Construct::ProcOtherProcesses)
&& let Some(gap) = proc_gap(&self.plan, &resolved)
{
return Err(SpawnError::Lower(LowerError::Gaps(vec![gap])));
}
if self.plan.tier == Tier::T2 {
let writes = self.plan.landlock.rules.iter().any(|r| {
matches!(&r.target, RuleTarget::Grant(g) if g.as_str() == name)
&& r.access
.intersect(procage_policy::linux::AccessFs::WRITE_FILE)
!= procage_policy::linux::AccessFs::NONE
});
if let Some(class) =
classes::grant_reaches(&self.plan.classes, &self.homes, &resolved, writes)
{
return Err(SpawnError::Grant(GrantError::CoversClass {
name: name.clone(),
class,
}));
}
}
out.insert(name.clone(), resolved);
}
Ok(out)
}
}
fn proc_gap(plan: &LinuxPlan, path: &Path) -> Option<procage_policy::CapabilityGap> {
use std::os::unix::ffi::OsStrExt;
procage_policy::linux::reaches_other_processes(path.as_os_str().as_bytes()).then(|| {
procage_policy::CapabilityGap {
construct: Construct::ProcOtherProcesses,
target: Target::Linux {
tier: plan.tier,
landlock_abi: plan.landlock_abi,
},
kind: procage_policy::GapKind::Approximation,
subject: Some(path.display().to_string()),
}
})
}
fn proc_links(
plan: &mut LinuxPlan,
rules: &[(PathBuf, bool)],
allowed: &BTreeSet<Construct>,
target: Target,
) -> Result<(), SpawnError> {
use std::os::unix::ffi::OsStrExt;
let gaps: Vec<_> = rules
.iter()
.filter(|(p, _)| !procage_policy::linux::reaches_other_processes(p.as_os_str().as_bytes()))
.filter_map(|(p, _)| {
let resolved = std::fs::canonicalize(p).ok()?;
proc_gap(plan, &resolved).map(|mut gap| {
gap.subject = Some(p.display().to_string());
gap
})
})
.collect();
if gaps.is_empty() {
return Ok(());
}
if !allowed.contains(&Construct::ProcOtherProcesses) {
return Err(SpawnError::Lower(LowerError::Gaps(gaps)));
}
if !plan
.degradations
.iter()
.any(|d| d.construct == Construct::ProcOtherProcesses)
{
plan.degradations.push(procage_policy::Degradation {
construct: Construct::ProcOtherProcesses,
target,
note: procage_policy::linux::PROC_OTHER_PROCESSES_NOTE,
});
plan.degradations.sort();
}
Ok(())
}
pub(crate) struct Launch {
parent_end: Option<std::os::fd::OwnedFd>,
dropped: Vec<String>,
removal: TmpdirGuard,
proc: bool,
run_audit: crate::audit::RunAudit,
names: Vec<String>,
#[cfg(feature = "proxy")]
bridge_end: Option<std::os::fd::OwnedFd>,
}
impl Launch {
pub(crate) fn abandon(self, e: std::io::Error) -> SpawnError {
spawn::decode(e)
}
}
fn kept_fds(fds: impl IntoIterator<Item = i32>) -> Result<Vec<i32>, SpawnError> {
let mut keep: Vec<i32> = fds.into_iter().filter(|&fd| fd > 2).collect();
keep.sort_unstable();
keep.dedup();
match keep.iter().find(|&&fd| !sys::fd_is_open(fd)) {
Some(&fd) => Err(SpawnError::Fd { fd }),
None => Ok(keep),
}
}
#[doc(hidden)]
pub mod testing {
use super::sys;
pub fn is_session_leader() -> bool {
sys::leadership().0
}
pub fn is_group_leader() -> bool {
sys::leadership().1
}
pub fn clear_cloexec(fd: i32) -> bool {
sys::clear_cloexec(fd).is_ok()
}
pub fn fd_is_open(fd: i32) -> bool {
sys::fd_is_open(fd)
}
pub fn alive(pid: u32) -> bool {
let zombie = std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|s| {
s.rfind(')')
.and_then(|i| s[i + 1..].split_whitespace().next())
== Some("Z")
});
!zombie && libc::pid_t::try_from(pid).is_ok_and(sys::alive)
}
pub fn spawn_sleeper(setsid: bool) -> std::io::Result<u32> {
sys::spawn_sleeper(setsid)
}
pub fn nofile_and_core() -> Option<(u64, u64)> {
Some((
sys::getrlimit(libc::RLIMIT_NOFILE as libc::c_int)?.0,
sys::getrlimit(libc::RLIMIT_CORE as libc::c_int)?.0,
))
}
pub fn capabilities() -> (u64, u64, u64) {
sys::capabilities()
}
pub fn syscall_errno(name: &str) -> Option<i32> {
let none = [0; 5];
let (nr, args) = match name {
"ptrace" => (
libc::SYS_ptrace,
[7, libc::c_long::from(sys::pid()), 0, 0, 0],
),
"io_uring_setup" => (libc::SYS_io_uring_setup, none),
"bpf" => (libc::SYS_bpf, [-1, 0, 0, 0, 0]),
"keyctl" => (libc::SYS_keyctl, [9999, 0, 0, 0, 0]),
"unshare" => (libc::SYS_unshare, none),
"setsid" => (libc::SYS_setsid, none),
"setpgid" => (libc::SYS_setpgid, none),
"setns" => (libc::SYS_setns, [-1, 0, 0, 0, 0]),
"perf_event_open" => (libc::SYS_perf_event_open, [0, 0, -1, -1, 0]),
"userfaultfd" => (libc::SYS_userfaultfd, [-1, 0, 0, 0, 0]),
"socket_netlink" => (
libc::SYS_socket,
[libc::AF_NETLINK.into(), libc::SOCK_RAW.into(), 0, 0, 0],
),
"socket_vsock" => (
libc::SYS_socket,
[libc::AF_VSOCK.into(), libc::SOCK_STREAM.into(), 0, 0, 0],
),
"unshare_user" => (libc::SYS_unshare, [libc::CLONE_NEWUSER.into(), 0, 0, 0, 0]),
"clone3" => (libc::SYS_clone3, none),
"process_vm_readv" => (
libc::SYS_process_vm_readv,
[libc::c_long::from(sys::parent_pid()), 0, 0, 0, 0],
),
"open_tree" => (
libc::SYS_open_tree,
[
libc::AT_FDCWD.into(),
c"/".as_ptr() as libc::c_long,
0,
0,
0,
],
),
_ => return None,
};
Some(sys::syscall_errno(nr, args))
}
pub fn kill_errno(pid: u32, signal: i32) -> i32 {
sys::syscall_errno(
libc::SYS_kill,
[libc::c_long::from(pid), libc::c_long::from(signal), 0, 0, 0],
)
}
pub fn truncate_errno(route: &str, path: &std::path::Path) -> Option<i32> {
use std::os::unix::ffi::OsStrExt;
let path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
let p = path.as_ptr() as libc::c_long;
let at = libc::c_long::from(libc::AT_FDCWD);
let trunc = libc::c_long::from(libc::O_TRUNC);
let how: [u64; 3] = [(libc::O_RDONLY | libc::O_TRUNC) as u64, 0, 0];
let (nr, args) = match route {
"truncate" => (libc::SYS_truncate, [p, 0, 0, 0, 0]),
"rdonly" => (libc::SYS_openat, [at, p, trunc, 0, 0]),
"noaccess" => (libc::SYS_openat, [at, p, 3 | trunc, 0, 0]),
"openat2" => (
libc::SYS_openat2,
[at, p, how.as_ptr() as libc::c_long, 24, 0],
),
#[cfg(target_arch = "x86_64")]
"open" => (libc::SYS_open, [p, trunc, 0, 0, 0]),
_ => return None,
};
Some(sys::syscall_errno(nr, args))
}
pub fn open_pty() -> std::io::Result<(std::os::fd::OwnedFd, std::path::PathBuf)> {
sys::open_pty()
}
pub fn make_controlling(fd: i32) -> i32 {
sys::tty_ioctl_errno(fd, None)
}
pub fn tiocsti_errno(fd: i32, byte: u8) -> i32 {
sys::tty_ioctl_errno(fd, Some(byte))
}
pub fn net_errno(kind: &str, port: u16) -> i32 {
sys::net_errno(kind, port)
}
pub fn udp_send_errno(fd: i32, port: u16) -> i32 {
sys::udp_send_errno(fd, port)
}
pub fn umount(path: &str) -> i32 {
let Ok(c) = std::ffi::CString::new(path) else {
return libc::EINVAL;
};
sys::syscall_errno(libc::SYS_umount2, [c.as_ptr() as libc::c_long, 0, 0, 0, 0])
}
pub fn remount_rw(path: &str) -> i32 {
let Ok(c) = std::ffi::CString::new(path) else {
return libc::EINVAL;
};
sys::syscall_errno(
libc::SYS_mount,
[
0,
c.as_ptr() as libc::c_long,
0,
(libc::MS_REMOUNT | libc::MS_BIND) as libc::c_long,
0,
],
)
}
pub fn install_outer_listener() -> std::io::Result<std::os::fd::OwnedFd> {
use procage_policy::linux::{Action, Condition, SeccompRule, Syscall};
let rule = SeccompRule {
syscall: Syscall::Kcmp,
condition: Condition::Always,
action: Action::Eperm,
reason: None,
};
let filter = super::seccomp::build(&[rule], &super::seccomp::NATIVE, true)
.map_err(std::io::Error::other)?;
sys::install_outer_listener(filter).map_err(std::io::Error::from_raw_os_error)
}
}
fn abstract_visible(plan: &LinuxPlan) -> bool {
plan.namespaces.is_none_or(|ns| !ns.net)
}
fn socket_lift(gap: &procage_policy::CapabilityGap) -> Option<procage_policy::Lift> {
let socket = matches!(
gap.construct,
Construct::HdServiceHandoff | Construct::HdContainerEngines
);
let t2 = matches!(gap.target, Target::Linux { tier: Tier::T2, .. });
(socket && t2 && !gap.subject.as_deref().unwrap_or("").starts_with('@')).then_some(
procage_policy::Lift {
tier1: true,
landlock_abi: None,
},
)
}
fn facts(
policy: &Policy,
options: &CageOptions,
plan: &LinuxPlan,
relaxed: &[Relaxed],
) -> crate::receipt::CageFacts {
let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|k| k.trim().to_string())
.unwrap_or_else(|_| "unknown".into());
crate::receipt::CageFacts::new(
policy,
options.policy_in_receipt,
crate::ReceiptBackend::Linux {
tier: plan.tier.to_string(),
landlock_abi: plan.landlock_abi,
errata: probe().errata,
kernel,
},
crate::receipt::approximations(&plan.degradations),
relaxed,
)
}
fn cage_audit(
policy: &Policy,
options: &CageOptions,
plan: &LinuxPlan,
homes: &[PathBuf],
) -> Result<crate::audit::CageAudit, SpawnError> {
use std::os::unix::ffi::OsStrExt;
let to_path =
|p: &procage_policy::AbsPath| PathBuf::from(std::ffi::OsStr::from_bytes(p.as_bytes()));
let mut class_paths = Vec::new();
for class in &plan.classes {
let rels: Vec<&str> = class.home.iter().map(String::as_str).collect();
let system: Vec<PathBuf> = class.system.iter().map(to_path).collect();
class_paths.extend(crate::audit::class_paths(homes, &rels, &system, class.hide));
}
let exempt = crate::audit::exemptions(&options.link_exempt, |p| std::fs::canonicalize(p).ok())
.map_err(|setting| SpawnError::Unsupported(Unsupported::Setting { setting }))?;
let targets = crate::audit::Targets::new(policy, class_paths, sys::ids().0).exempting(exempt);
let handoff = options.handoff_audit.then(|| {
let trees = policy
.fs
.iter()
.filter(|r| r.access == procage_policy::Access::Write)
.filter_map(|r| match &r.target {
procage_policy::FsTarget::Subpath(p) => Some(to_path(p)),
_ => None,
})
.collect();
let home = homes
.iter()
.flat_map(|h| {
procage_policy::presets::HANDOFF_HOME
.iter()
.map(move |r| h.join(r))
})
.collect();
(
trees,
procage_policy::presets::HANDOFF_ANYWHERE
.iter()
.map(|n| n.to_string())
.collect(),
home,
)
});
Ok(crate::audit::CageAudit::new(
options.link_audit,
targets,
handoff,
options.walk_limit,
))
}