Skip to main content

text_mirror/runner/
linux.rs

1//! The Linux jail backend: user and network namespaces, Landlock
2//! filesystem rules, and a seccomp allow-list.
3//!
4//! The network boundary is a fresh network namespace created together
5//! with a user namespace, which Linux permits unprivileged when
6//! unprivileged user namespaces are enabled. The new namespace has a
7//! private network stack and no external interface. A seccomp
8//! allow-list is the second layer: it omits every socket, io_uring,
9//! bpf, setsid, and setpgid family syscall, and any syscall outside
10//! the list kills the process. Landlock confines the filesystem to
11//! the jail read-write, the worker binary execute-read, and a fixed
12//! runtime set read-only, with no best-effort mode: a kernel that
13//! cannot fully enforce the policy refuses the run. The kernel floor
14//! is Landlock ABI v3, Linux 6.2.
15
16use std::collections::BTreeMap;
17use std::path::Path;
18use std::process::Command;
19
20use landlock::{
21    ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
22    RulesetCreatedAttr, RulesetStatus,
23};
24use nix::libc;
25use seccompiler::{BpfProgram, SeccompAction, SeccompFilter, SeccompRule, TargetArch};
26
27use super::jail::{JailBackend, RuntimeProfile, SpawnSpec};
28use super::{Runner, RunnerError, RunnerMessage};
29
30/// The Landlock ABI this backend requires in full.
31const LANDLOCK_ABI: ABI = ABI::V3;
32
33/// Runtime paths the adapter may read: the dynamic loader, the shared
34/// library trees, and the loader cache, and nothing else. The shipped
35/// worker is the pure-Rust anydoc PDF path, which links only the
36/// loader, libc, libm, and libgcc_s, so the minimum set is the
37/// standard library directories. The list deliberately excludes the
38/// rest of `/usr`, so `/usr/local`, `/usr/share`, `/usr/bin`, and
39/// site data are not readable. Missing entries are skipped, so one
40/// list serves distributions that place the libraries under `/lib`,
41/// `/lib64`, `/usr/lib`, or `/usr/lib64`.
42const RUNTIME_READ_PATHS: &[&str] = &[
43    "/lib",
44    "/lib64",
45    "/usr/lib",
46    "/usr/lib64",
47    "/etc/ld.so.cache",
48];
49
50/// The namespace, Landlock, and seccomp jail.
51pub struct NamespaceJail;
52
53impl JailBackend for NamespaceJail {
54    fn name(&self) -> &'static str {
55        "linux-namespace"
56    }
57
58    fn command(&self, spec: &SpawnSpec<'_>) -> Result<Command, RunnerError> {
59        // The provider jail is a measured macOS profile. No provider
60        // profile has been measured for this platform, so the class is
61        // refused here rather than silently running the external
62        // components under the base policy.
63        if spec.runtime_profile == RuntimeProfile::Provider {
64            return Err(RunnerError::new(
65                "sandbox_unavailable",
66                RunnerMessage::ProviderPlatformUnsupported,
67            ));
68        }
69        let mut command = Command::new(spec.worker);
70        command
71            .arg("sandbox-helper")
72            .arg("--jail")
73            .arg(spec.jail)
74            .arg("--worker")
75            .arg(spec.worker)
76            .arg("--adapter")
77            .arg(spec.mode)
78            .arg("--rlimit-as")
79            .arg(match spec.limits.address_space_bytes {
80                Some(bytes) => bytes.to_string(),
81                None => "none".to_string(),
82            })
83            .arg("--rlimit-cpu")
84            .arg(spec.limits.cpu_seconds.to_string())
85            .arg("--rlimit-fsize")
86            .arg(spec.limits.file_size_bytes.to_string())
87            .arg("--rlimit-nproc")
88            .arg(spec.limits.max_processes.to_string())
89            .arg("--seccomp")
90            .arg(if spec.seccomp { "kill" } else { "off" });
91        for path in spec.exec_grants {
92            command.arg("--grant-exec").arg(path);
93        }
94        for path in spec.read_grants {
95            command.arg("--grant-read").arg(path);
96        }
97        Ok(command)
98    }
99
100    fn policy_material(&self) -> String {
101        format!(
102            "newuser+newnet landlock-abi-v3 runtime={} seccomp-allow={}",
103            RUNTIME_READ_PATHS.join(","),
104            allowed_syscalls()
105                .iter()
106                .map(|n| n.to_string())
107                .collect::<Vec<_>>()
108                .join(",")
109        )
110    }
111
112    /// Two stages. The first runs the network probe with the syscall
113    /// filter off, so the namespace layer's denial is observed
114    /// directly. The second proves the filter layer by attempting one
115    /// socket creation under it and requiring the kernel to kill the
116    /// process.
117    fn probe(&self, runner: &Runner) -> Result<(), RunnerError> {
118        super::jail::probe_net(runner, false)?;
119        match runner.run_unprobed("probe-seccomp", serde_json::json!({}), &[], true) {
120            Err(e) if e.code == "adapter_crash" => Ok(()),
121            Ok(_) => Err(RunnerError::new(
122                "sandbox_probe_failed",
123                RunnerMessage::SyscallProbeAllowed,
124            )),
125            Err(_) => Err(RunnerError::new(
126                "sandbox_probe_failed",
127                RunnerMessage::SyscallProbeFailed,
128            )),
129        }
130    }
131}
132
133/// Applies the namespace layer: a fresh user namespace with one-ID
134/// maps and a fresh network namespace.
135pub(super) fn unshare_namespaces() -> Result<(), String> {
136    use std::os::unix::fs::MetadataExt;
137    let this = std::fs::metadata("/proc/self")
138        .map_err(|e| format!("cannot read process identity: {:?}", e.kind()))?;
139    let (uid, gid) = (this.uid(), this.gid());
140    nix::sched::unshare(
141        nix::sched::CloneFlags::CLONE_NEWUSER | nix::sched::CloneFlags::CLONE_NEWNET,
142    )
143    .map_err(|e| format!("unshare(newuser|newnet) failed: {e:?}"))?;
144    std::fs::write("/proc/self/setgroups", "deny")
145        .map_err(|e| format!("cannot deny setgroups: {:?}", e.kind()))?;
146    std::fs::write("/proc/self/uid_map", format!("{uid} {uid} 1\n"))
147        .map_err(|e| format!("cannot write the uid map: {:?}", e.kind()))?;
148    std::fs::write("/proc/self/gid_map", format!("{gid} {gid} 1\n"))
149        .map_err(|e| format!("cannot write the gid map: {:?}", e.kind()))?;
150    Ok(())
151}
152
153/// Applies the Landlock filesystem policy and requires it fully
154/// enforced. Best-effort is only acceptable in nothing: a partial
155/// policy refuses the run. Literal grant files, when supplied, gain
156/// execute-and-read or read-only access; a grant that cannot be
157/// opened refuses the run, because a missing pinned artifact must
158/// fail loudly rather than silently narrow the policy.
159pub(super) fn apply_landlock(
160    jail: &Path,
161    worker: &Path,
162    exec_grants: &[std::path::PathBuf],
163    read_grants: &[std::path::PathBuf],
164) -> Result<(), String> {
165    let ruleset_kind = |error: &landlock::RulesetError| match error {
166        landlock::RulesetError::HandleAccesses(_) => "HandleAccesses",
167        landlock::RulesetError::CreateRuleset(_) => "CreateRuleset",
168        landlock::RulesetError::AddRules(_) => "AddRules",
169        landlock::RulesetError::RestrictSelf(_) => "RestrictSelf",
170        landlock::RulesetError::Scope(_) => "Scope",
171        landlock::RulesetError::RestrictSelfFlags(_) => "RestrictSelfFlags",
172        _ => "Unknown",
173    };
174    let path_fd_kind = |error: landlock::PathFdError| match error {
175        landlock::PathFdError::OpenCall { source, .. } => source.kind(),
176    };
177    let fail = |stage: &str, kind: &str| format!("landlock {stage}: {kind}");
178    let mut ruleset = Ruleset::default()
179        .set_compatibility(CompatLevel::HardRequirement)
180        .handle_access(AccessFs::from_all(LANDLOCK_ABI))
181        .map_err(|e| fail("handle_access", ruleset_kind(&e)))?
182        .create()
183        .map_err(|e| fail("create", ruleset_kind(&e)))?;
184    ruleset = ruleset
185        .add_rule(PathBeneath::new(
186            PathFd::new(jail)
187                .map_err(|e| format!("landlock jail directory: {:?}", path_fd_kind(e)))?,
188            AccessFs::from_all(LANDLOCK_ABI),
189        ))
190        .map_err(|e| fail("jail directory rule", ruleset_kind(&e)))?;
191    ruleset = ruleset
192        .add_rule(PathBeneath::new(
193            PathFd::new(worker)
194                .map_err(|e| format!("landlock worker binary: {:?}", path_fd_kind(e)))?,
195            AccessFs::Execute | AccessFs::ReadFile,
196        ))
197        .map_err(|e| fail("worker binary rule", ruleset_kind(&e)))?;
198    for (index, path) in exec_grants.iter().enumerate() {
199        let label = format!("executable grant {} of {}", index + 1, exec_grants.len());
200        ruleset = ruleset
201            .add_rule(PathBeneath::new(
202                PathFd::new(path)
203                    .map_err(|e| format!("landlock {label}: {:?}", path_fd_kind(e)))?,
204                AccessFs::Execute | AccessFs::ReadFile,
205            ))
206            .map_err(|e| fail(&format!("{label} rule"), ruleset_kind(&e)))?;
207    }
208    for (index, path) in read_grants.iter().enumerate() {
209        let label = format!("read grant {} of {}", index + 1, read_grants.len());
210        ruleset = ruleset
211            .add_rule(PathBeneath::new(
212                PathFd::new(path)
213                    .map_err(|e| format!("landlock {label}: {:?}", path_fd_kind(e)))?,
214                AccessFs::ReadFile.into(),
215            ))
216            .map_err(|e| fail(&format!("{label} rule"), ruleset_kind(&e)))?;
217    }
218    let runtime_total = RUNTIME_READ_PATHS.len();
219    for (index, path) in RUNTIME_READ_PATHS.iter().enumerate() {
220        if !Path::new(path).exists() {
221            continue;
222        }
223        let access = if Path::new(path).is_dir() {
224            AccessFs::from_read(LANDLOCK_ABI)
225        } else {
226            AccessFs::ReadFile.into()
227        };
228        ruleset = ruleset
229            .add_rule(PathBeneath::new(
230                PathFd::new(path).map_err(|e| {
231                    format!(
232                        "landlock runtime grant {} of {runtime_total}: {:?}",
233                        index + 1,
234                        path_fd_kind(e)
235                    )
236                })?,
237                access,
238            ))
239            .map_err(|e| {
240                fail(
241                    &format!("runtime grant {} of {runtime_total} rule", index + 1),
242                    ruleset_kind(&e),
243                )
244            })?;
245    }
246    let status = ruleset
247        .restrict_self()
248        .map_err(|e| fail("restrict_self", ruleset_kind(&e)))?;
249    if status.ruleset != RulesetStatus::FullyEnforced {
250        return Err(format!(
251            "landlock policy is {:?}, full enforcement is required, the kernel floor is 6.2",
252            status.ruleset
253        ));
254    }
255    Ok(())
256}
257
258/// Installs the seccomp allow-list with kill-process as the mismatch
259/// action.
260pub(super) fn apply_seccomp() -> Result<(), String> {
261    let arch = TargetArch::try_from(std::env::consts::ARCH)
262        .map_err(|e| format!("seccomp does not support this architecture: {e:?}"))?;
263    let rules: BTreeMap<i64, Vec<SeccompRule>> = allowed_syscalls()
264        .iter()
265        .map(|syscall| (*syscall, Vec::new()))
266        .collect();
267    let filter = SeccompFilter::new(
268        rules,
269        SeccompAction::KillProcess,
270        SeccompAction::Allow,
271        arch,
272    )
273    .map_err(|e| format!("seccomp filter build failed: {e}"))?;
274    let program: BpfProgram = filter
275        .try_into()
276        .map_err(|e: seccompiler::BackendError| format!("seccomp compile failed: {e}"))?;
277    seccompiler::apply_filter(&program).map_err(|e| format!("seccomp install failed: {e}"))
278}
279
280/// The syscall allow-list for the adapter and the loader under it.
281///
282/// The list deliberately omits every socket-family syscall, io_uring,
283/// bpf, setsid, setpgid, setns, mount, and ptrace. Threads are
284/// allowed because they do not weaken the network or filesystem
285/// boundary.
286fn allowed_syscalls() -> Vec<i64> {
287    let mut list = vec![
288        libc::SYS_read,
289        libc::SYS_write,
290        libc::SYS_readv,
291        libc::SYS_writev,
292        libc::SYS_pread64,
293        libc::SYS_pwrite64,
294        libc::SYS_close,
295        libc::SYS_fstat,
296        libc::SYS_newfstatat,
297        libc::SYS_statx,
298        libc::SYS_lseek,
299        libc::SYS_mmap,
300        libc::SYS_mprotect,
301        libc::SYS_munmap,
302        libc::SYS_mremap,
303        libc::SYS_madvise,
304        libc::SYS_brk,
305        libc::SYS_futex,
306        libc::SYS_sched_yield,
307        libc::SYS_sched_getaffinity,
308        libc::SYS_clock_gettime,
309        libc::SYS_clock_getres,
310        libc::SYS_clock_nanosleep,
311        libc::SYS_gettimeofday,
312        libc::SYS_getrandom,
313        libc::SYS_exit,
314        libc::SYS_exit_group,
315        libc::SYS_rt_sigaction,
316        libc::SYS_rt_sigprocmask,
317        libc::SYS_rt_sigreturn,
318        libc::SYS_sigaltstack,
319        libc::SYS_tgkill,
320        libc::SYS_tkill,
321        libc::SYS_getpid,
322        libc::SYS_gettid,
323        libc::SYS_getppid,
324        libc::SYS_getuid,
325        libc::SYS_geteuid,
326        libc::SYS_getgid,
327        libc::SYS_getegid,
328        libc::SYS_getrlimit,
329        libc::SYS_prlimit64,
330        libc::SYS_getrusage,
331        libc::SYS_sysinfo,
332        libc::SYS_times,
333        libc::SYS_uname,
334        libc::SYS_umask,
335        libc::SYS_openat,
336        libc::SYS_faccessat,
337        libc::SYS_faccessat2,
338        libc::SYS_getdents64,
339        libc::SYS_readlinkat,
340        libc::SYS_getcwd,
341        libc::SYS_chdir,
342        libc::SYS_fchdir,
343        libc::SYS_fcntl,
344        libc::SYS_dup,
345        libc::SYS_dup3,
346        libc::SYS_pipe2,
347        libc::SYS_ioctl,
348        libc::SYS_ppoll,
349        libc::SYS_pselect6,
350        libc::SYS_epoll_create1,
351        libc::SYS_epoll_ctl,
352        libc::SYS_epoll_pwait,
353        libc::SYS_eventfd2,
354        libc::SYS_mkdirat,
355        libc::SYS_unlinkat,
356        libc::SYS_renameat,
357        libc::SYS_ftruncate,
358        libc::SYS_fallocate,
359        libc::SYS_fsync,
360        libc::SYS_fdatasync,
361        libc::SYS_flock,
362        libc::SYS_utimensat,
363        libc::SYS_set_tid_address,
364        libc::SYS_set_robust_list,
365        libc::SYS_rseq,
366        libc::SYS_membarrier,
367        libc::SYS_prctl,
368        libc::SYS_clone,
369        libc::SYS_clone3,
370        libc::SYS_execve,
371        libc::SYS_wait4,
372    ];
373    #[cfg(target_arch = "x86_64")]
374    list.extend_from_slice(&[
375        libc::SYS_open,
376        libc::SYS_stat,
377        libc::SYS_lstat,
378        libc::SYS_access,
379        libc::SYS_readlink,
380        libc::SYS_poll,
381        libc::SYS_select,
382        libc::SYS_dup2,
383        libc::SYS_pipe,
384        libc::SYS_epoll_wait,
385        libc::SYS_mkdir,
386        libc::SYS_unlink,
387        libc::SYS_rename,
388        libc::SYS_arch_prctl,
389        libc::SYS_nanosleep,
390        libc::SYS_time,
391        libc::SYS_getdents,
392    ]);
393    list.sort_unstable();
394    list
395}