ferroday_cage/hardening.rs
1//! The hardening layer: opt-in Landlock filesystem and network rules, seccomp
2//! syscall filters, and capability drops applied to the sandboxed command.
3//!
4//! Everything here is configured on the [`CageBuilder`] and takes effect only
5//! when requested; the default sandbox profile is a rootless convenience, not
6//! a boundary against hostile code. The public types describe *what* to
7//! restrict; [`CageBuilder::build`] lowers them into the frozen plan the
8//! command stage applies, after the pivot and immediately before `execve`, so
9//! the restrictions bind the command and every process it starts.
10//!
11//! The restrictions are inherited across `execve` and apply only to the
12//! command, never to the sandbox init or the supervisor. A missing kernel
13//! facility is reported: a request for Landlock on a kernel without the LSM,
14//! or seccomp on an architecture the compiler does not target, is a build or
15//! setup error rather than a silent no-op.
16//!
17//! With the `serde` feature, a hardening request is part of the profile
18//! format: it serializes and deserializes as the `[hardening]` table on the
19//! [`CageBuilder`] profile, so a Landlock, seccomp, and capability posture is
20//! a versionable, reviewable artifact like the rest of the specification.
21//!
22//! [`CageBuilder`]: crate::CageBuilder
23//! [`CageBuilder::build`]: crate::CageBuilder::build
24
25use std::collections::{BTreeMap, BTreeSet};
26use std::ffi::CString;
27use std::fmt;
28use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Not};
29use std::path::{Path, PathBuf};
30use std::str::FromStr;
31
32use rustix::thread::CapabilitySet;
33
34use crate::error::ConfigError;
35use crate::mechanism::{HardeningPlan, LandlockNetRule, LandlockRule, SockFilter};
36use crate::roster::roster_enum;
37
38/// Declares the text side of a letter-set access type: its
39/// [`Display`](fmt::Display), its [`FromStr`], the parse error it produces, and
40/// the serde adapters that go through both.
41///
42/// [`FsAccess`] and [`NetAccess`] are the same type twice — a bitflag newtype
43/// whose textual form is an unordered set of single letters — differing only in
44/// the letters, the flags, and their names. Everything that follows from the
45/// letter table is generated from the table.
46///
47/// Letters are declared in the order [`Display`](fmt::Display) writes them, and
48/// parsing accepts them in any order and any case. A letter outside the table,
49/// or a letter given twice, is a parse error; the empty string is the empty
50/// set.
51macro_rules! letter_set_text {
52 (
53 $set:ident, $expected:literal,
54 $( $letter:literal => $flag:ident, )+
55 $(#[$error_meta:meta])*
56 $error:ident
57 ) => {
58 impl fmt::Display for $set {
59 #[doc = concat!("Writes the rights as the letters ", $expected, ", in that order; the empty set writes nothing.")]
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 $(
62 if self.contains($set::$flag) {
63 write!(f, "{}", $letter)?;
64 }
65 )+
66 Ok(())
67 }
68 }
69
70 impl FromStr for $set {
71 type Err = $error;
72
73 #[doc = concat!("Parses an access set from the letters ", $expected, ", in any order and any case. A letter outside that set, or a letter given twice, is an error; the empty string is the empty set.")]
74 fn from_str(s: &str) -> Result<$set, $error> {
75 let mut access = $set::empty();
76 for ch in s.chars() {
77 let right = match ch.to_ascii_lowercase() {
78 $( $letter => $set::$flag, )+
79 _ => return Err($error { text: s.to_string() }),
80 };
81 if access.contains(right) {
82 return Err($error { text: s.to_string() });
83 }
84 access |= right;
85 }
86 Ok(access)
87 }
88 }
89
90 $(#[$error_meta])*
91 #[derive(Debug, Clone, PartialEq, Eq)]
92 pub struct $error {
93 text: String,
94 }
95
96 impl $error {
97 /// The access string that failed to parse, as it was given.
98 ///
99 /// Named `text` on every parse error in this crate, so a caller
100 /// moving between them does not have to check.
101 pub fn text(&self) -> &str {
102 &self.text
103 }
104 }
105
106 impl fmt::Display for $error {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 write!(
109 f,
110 concat!("invalid access set {:?}: expected some combination of the letters ", $expected),
111 self.text,
112 )
113 }
114 }
115
116 impl std::error::Error for $error {}
117
118 #[cfg(feature = "serde")]
119 impl serde::Serialize for $set {
120 /// Serializes as the letter string, through [`Display`](fmt::Display).
121 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
122 serializer.collect_str(self)
123 }
124 }
125
126 #[cfg(feature = "serde")]
127 impl<'de> serde::Deserialize<'de> for $set {
128 /// Deserializes from the letter string, through [`FromStr`].
129 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<$set, D::Error> {
130 let text = <String as serde::Deserialize>::deserialize(deserializer)?;
131 text.parse().map_err(serde::de::Error::custom)
132 }
133 }
134 };
135}
136
137/// Declares the bitwise operators of a bitflag newtype over an integer.
138///
139/// `!` complements within `MASK`, so a bit outside the modeled set is never
140/// produced.
141macro_rules! bitflag_ops {
142 ($set:ident) => {
143 impl BitOr for $set {
144 type Output = $set;
145
146 fn bitor(self, rhs: $set) -> $set {
147 $set(self.0 | rhs.0)
148 }
149 }
150
151 impl BitOrAssign for $set {
152 fn bitor_assign(&mut self, rhs: $set) {
153 self.0 |= rhs.0;
154 }
155 }
156
157 impl BitAnd for $set {
158 type Output = $set;
159
160 fn bitand(self, rhs: $set) -> $set {
161 $set(self.0 & rhs.0)
162 }
163 }
164
165 impl BitAndAssign for $set {
166 fn bitand_assign(&mut self, rhs: $set) {
167 self.0 &= rhs.0;
168 }
169 }
170
171 impl Not for $set {
172 type Output = $set;
173
174 /// The modeled rights this set does not hold; bits outside the
175 /// modeled set are never produced.
176 fn not(self) -> $set {
177 $set(!self.0 & $set::MASK)
178 }
179 }
180 };
181}
182
183/// Kernel `LANDLOCK_ACCESS_FS_*` rights. Named here rather than pulled from a
184/// binding so the ABI contract is visible in one place; re-verify against the
185/// kernel uapi when the Landlock ABI grows.
186pub(crate) mod fs_access {
187 pub(crate) const EXECUTE: u64 = 1 << 0;
188 pub(crate) const WRITE_FILE: u64 = 1 << 1;
189 pub(crate) const READ_FILE: u64 = 1 << 2;
190 pub(crate) const READ_DIR: u64 = 1 << 3;
191 pub(crate) const REMOVE_DIR: u64 = 1 << 4;
192 pub(crate) const REMOVE_FILE: u64 = 1 << 5;
193 pub(crate) const MAKE_CHAR: u64 = 1 << 6;
194 pub(crate) const MAKE_DIR: u64 = 1 << 7;
195 pub(crate) const MAKE_REG: u64 = 1 << 8;
196 pub(crate) const MAKE_SOCK: u64 = 1 << 9;
197 pub(crate) const MAKE_FIFO: u64 = 1 << 10;
198 pub(crate) const MAKE_BLOCK: u64 = 1 << 11;
199 pub(crate) const MAKE_SYM: u64 = 1 << 12;
200 pub(crate) const REFER: u64 = 1 << 13;
201 pub(crate) const TRUNCATE: u64 = 1 << 14;
202 pub(crate) const IOCTL_DEV: u64 = 1 << 15;
203
204 /// Every filesystem right this crate models — the ABI 5 set. The command
205 /// stage masks this down to the running kernel's supported ABI.
206 pub(crate) const ALL: u64 = EXECUTE
207 | WRITE_FILE
208 | READ_FILE
209 | READ_DIR
210 | REMOVE_DIR
211 | REMOVE_FILE
212 | MAKE_CHAR
213 | MAKE_DIR
214 | MAKE_REG
215 | MAKE_SOCK
216 | MAKE_FIFO
217 | MAKE_BLOCK
218 | MAKE_SYM
219 | REFER
220 | TRUNCATE
221 | IOCTL_DEV;
222}
223
224/// Kernel `LANDLOCK_ACCESS_NET_*` rights, keyed by TCP port rather than by
225/// path. Named here alongside the filesystem rights so the ABI contract lives
226/// in one place; re-verify against the kernel uapi when the Landlock ABI grows.
227pub(crate) mod net_access {
228 pub(crate) const BIND_TCP: u64 = 1 << 0;
229 pub(crate) const CONNECT_TCP: u64 = 1 << 1;
230
231 /// Every network right this crate models — the ABI 4 set. The command
232 /// stage masks this down to the running kernel's supported ABI.
233 pub(crate) const ALL: u64 = BIND_TCP | CONNECT_TCP;
234}
235
236/// Masks a filesystem access set down to the rights a Landlock ABI version
237/// defines, so a ruleset built for a newer ABI still enforces on an older
238/// kernel — narrowing what is enforced, never widening it.
239///
240/// A right absent from the running ABI cannot be part of a ruleset that
241/// ABI's kernel will accept; dropping it leaves the access unrestricted for
242/// that right, the same best-effort downgrade the reference library performs.
243pub(crate) fn fs_access_for_abi(abi: i32) -> u64 {
244 use fs_access::*;
245 // v1: EXECUTE through MAKE_SYM. Each later version adds the rights below.
246 let mut mask = EXECUTE
247 | WRITE_FILE
248 | READ_FILE
249 | READ_DIR
250 | REMOVE_DIR
251 | REMOVE_FILE
252 | MAKE_CHAR
253 | MAKE_DIR
254 | MAKE_REG
255 | MAKE_SOCK
256 | MAKE_FIFO
257 | MAKE_BLOCK
258 | MAKE_SYM;
259 if abi >= 2 {
260 mask |= REFER;
261 }
262 if abi >= 3 {
263 mask |= TRUNCATE;
264 }
265 // ABI 4 adds only network rights, masked separately by net_access_for_abi.
266 if abi >= 5 {
267 mask |= IOCTL_DEV;
268 }
269 mask
270}
271
272/// Masks a network access set down to the rights a Landlock ABI version
273/// defines, so a ruleset built for a newer ABI still enforces on an older
274/// kernel — narrowing what is enforced, never widening it.
275///
276/// The TCP bind and connect rights are the whole modeled set and arrive
277/// together in ABI 4; a kernel older than that defines no network rights, so
278/// the mask is empty and network access is left unrestricted, the same
279/// best-effort downgrade [`fs_access_for_abi`] performs for filesystem rights.
280pub(crate) fn net_access_for_abi(abi: i32) -> u64 {
281 // ABI 4 introduced BIND_TCP and CONNECT_TCP together. Later network rights
282 // (the UDP rights of ABI 10) become additional gated additions here.
283 if abi >= 4 { net_access::ALL } else { 0 }
284}
285
286/// Whether a requested filesystem grant carrying this handled mask falls
287/// entirely outside `abi`, so a ruleset built at `abi` would govern none of it.
288/// A zero mask means no filesystem grant was requested, so nothing is dropped.
289///
290/// A filesystem grant keeps its ABI-1 base rights on every Landlock kernel, so
291/// in practice this is only ever true when the LSM reports an ABI of zero,
292/// which cannot occur; it exists for symmetry with the network predicate and to
293/// state the invariant explicitly.
294pub(crate) fn fs_grant_masked_away(fs_handled: u64, abi: i32) -> bool {
295 fs_handled != 0 && fs_handled & fs_access_for_abi(abi) == 0
296}
297
298/// Whether a requested network grant carrying this handled mask falls entirely
299/// outside `abi`, so a ruleset built at `abi` would govern none of it. A zero
300/// mask means no network grant was requested, so nothing is dropped.
301///
302/// Network rights first appear in Landlock ABI 4 (Linux 6.7), so a network
303/// grant is masked away on every older kernel. The grant must be judged per
304/// access kind rather than against the whole ruleset: a filesystem grant that
305/// survives the same ABI must not mask this refusal, or a configured network
306/// boundary would silently vanish while the filesystem side enforced.
307///
308/// Callers that treat a masked-away grant as a build error rather than run
309/// unconfined: `HardeningPlan::ensure_landlock_enforceable` (the restriction
310/// fallback) and `HardeningPlan::ensure_host_net_enforceable` (a host-network
311/// cage, whose only network boundary is Landlock).
312pub(crate) fn net_grant_masked_away(net_handled: u64, abi: i32) -> bool {
313 net_handled != 0 && net_handled & net_access_for_abi(abi) == 0
314}
315
316/// A set of filesystem access rights, granted on a path by
317/// [`CageBuilder::landlock_fs`].
318///
319/// The three primitives — [`READ`](Self::READ), [`WRITE`](Self::WRITE), and
320/// [`EXECUTE`](Self::EXECUTE) — combine with `|`, mask with `&`, and
321/// complement with `!` (within the modeled rights):
322///
323/// ```
324/// use ferroday_cage::FsAccess;
325///
326/// let read_execute = FsAccess::READ | FsAccess::EXECUTE;
327/// assert!(read_execute.contains(FsAccess::READ));
328/// assert_eq!(read_execute & FsAccess::WRITE, FsAccess::empty());
329/// assert_eq!(!FsAccess::WRITE, FsAccess::READ | FsAccess::EXECUTE);
330/// ```
331///
332/// [`CageBuilder::landlock_fs`]: crate::CageBuilder::landlock_fs
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
334pub struct FsAccess(u8);
335
336impl FsAccess {
337 /// Read files and list directories beneath the path.
338 pub const READ: FsAccess = FsAccess(0b001);
339 /// Write files, and create, remove, and rename entries beneath the path.
340 pub const WRITE: FsAccess = FsAccess(0b010);
341 /// Execute files beneath the path.
342 pub const EXECUTE: FsAccess = FsAccess(0b100);
343
344 /// The bits every modeled right occupies, for masking a complement.
345 const MASK: u8 = 0b111;
346
347 /// The empty access set — no rights. Equivalent to [`FsAccess::default`].
348 pub const fn empty() -> FsAccess {
349 FsAccess(0)
350 }
351
352 /// Every modeled right: [`READ`](Self::READ), [`WRITE`](Self::WRITE), and
353 /// [`EXECUTE`](Self::EXECUTE).
354 pub const fn all() -> FsAccess {
355 FsAccess(FsAccess::MASK)
356 }
357
358 /// Returns `true` if no right is present.
359 pub const fn is_empty(self) -> bool {
360 self.0 == 0
361 }
362
363 /// Returns `true` if every right in `other` is present.
364 pub const fn contains(self, other: FsAccess) -> bool {
365 self.0 & other.0 == other.0
366 }
367
368 /// The kernel `LANDLOCK_ACCESS_FS_*` rights this access set grants.
369 ///
370 /// `WRITE` maps to the full set of content and directory-mutation rights
371 /// so a read-write grant behaves as a caller expects; rights the mount
372 /// makes impossible anyway (device-node creation) are harmless to grant.
373 ///
374 /// `IOCTL_DEV` (device-specific `ioctl`, an ABI-5 right) is granted with
375 /// either `READ` or `WRITE`: a caller who can read or write a device file
376 /// expects its ioctls to work, and the right has no effect on a regular
377 /// file or directory. Because it is in the handled set, a grant that omits
378 /// both — an `EXECUTE`-only grant — still denies device ioctls. Were it not
379 /// granted here at all, every filesystem grant would deny device ioctls on
380 /// an ABI-5 kernel, since a handled right absent from a path's allowed set
381 /// is denied there.
382 pub(crate) fn to_landlock(self) -> u64 {
383 use fs_access::*;
384 let mut bits = 0;
385 if self.contains(FsAccess::READ) {
386 bits |= READ_FILE | READ_DIR;
387 }
388 if self.contains(FsAccess::EXECUTE) {
389 bits |= EXECUTE;
390 }
391 if self.contains(FsAccess::WRITE) {
392 bits |= WRITE_FILE
393 | REMOVE_DIR
394 | REMOVE_FILE
395 | MAKE_CHAR
396 | MAKE_DIR
397 | MAKE_REG
398 | MAKE_SOCK
399 | MAKE_FIFO
400 | MAKE_BLOCK
401 | MAKE_SYM
402 | REFER
403 | TRUNCATE;
404 }
405 if self.contains(FsAccess::READ) || self.contains(FsAccess::WRITE) {
406 bits |= IOCTL_DEV;
407 }
408 bits
409 }
410}
411
412bitflag_ops!(FsAccess);
413
414letter_set_text! {
415 FsAccess, "r, w, and x",
416 'r' => READ,
417 'w' => WRITE,
418 'x' => EXECUTE,
419 /// The error from parsing an [`FsAccess`] from an invalid access string.
420 ///
421 /// Produced when the string holds a character other than `r`, `w`, or `x`,
422 /// or repeats one of them.
423 ParseFsAccessError
424}
425
426/// A set of Landlock network access rights, granted on a TCP port by
427/// [`CageBuilder::landlock_net`].
428///
429/// Landlock's network control covers TCP alone: these rights govern TCP bind
430/// and connect and nothing else. UDP, raw sockets, and other socket families
431/// — `AF_UNIX` among them, including abstract sockets to host IPC — are
432/// outside a network grant's reach, and a restriction that must deny them
433/// needs a seccomp filter or a cage's network namespace instead.
434///
435/// The kernel keys network rights by port, so these are the network
436/// counterpart of [`FsAccess`]: the two primitives — [`BIND`](Self::BIND) and
437/// [`CONNECT`](Self::CONNECT) — combine with `|`, mask with `&`, and complement
438/// with `!` (within the modeled rights):
439///
440/// ```
441/// use ferroday_cage::NetAccess;
442///
443/// let bind_connect = NetAccess::BIND | NetAccess::CONNECT;
444/// assert!(bind_connect.contains(NetAccess::BIND));
445/// assert_eq!(bind_connect & NetAccess::CONNECT, NetAccess::CONNECT);
446/// assert_eq!(!NetAccess::BIND, NetAccess::CONNECT);
447/// ```
448///
449/// [`CageBuilder::landlock_net`]: crate::CageBuilder::landlock_net
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
451pub struct NetAccess(u8);
452
453impl NetAccess {
454 /// Bind a TCP socket to the port (`LANDLOCK_ACCESS_NET_BIND_TCP`).
455 pub const BIND: NetAccess = NetAccess(0b01);
456 /// Connect a TCP socket to the port (`LANDLOCK_ACCESS_NET_CONNECT_TCP`).
457 pub const CONNECT: NetAccess = NetAccess(0b10);
458
459 /// The bits every modeled right occupies, for masking a complement.
460 const MASK: u8 = 0b11;
461
462 /// The empty access set — no rights. Equivalent to [`NetAccess::default`].
463 pub const fn empty() -> NetAccess {
464 NetAccess(0)
465 }
466
467 /// Every modeled right: [`BIND`](Self::BIND) and [`CONNECT`](Self::CONNECT).
468 pub const fn all() -> NetAccess {
469 NetAccess(NetAccess::MASK)
470 }
471
472 /// Returns `true` if no right is present.
473 pub const fn is_empty(self) -> bool {
474 self.0 == 0
475 }
476
477 /// Returns `true` if every right in `other` is present.
478 pub const fn contains(self, other: NetAccess) -> bool {
479 self.0 & other.0 == other.0
480 }
481
482 /// The kernel `LANDLOCK_ACCESS_NET_*` rights this access set grants.
483 pub(crate) fn to_landlock(self) -> u64 {
484 use net_access::*;
485 let mut bits = 0;
486 if self.contains(NetAccess::BIND) {
487 bits |= BIND_TCP;
488 }
489 if self.contains(NetAccess::CONNECT) {
490 bits |= CONNECT_TCP;
491 }
492 bits
493 }
494}
495
496bitflag_ops!(NetAccess);
497
498letter_set_text! {
499 NetAccess, "b and c",
500 'b' => BIND,
501 'c' => CONNECT,
502 /// The error from parsing a [`NetAccess`] from an invalid access string.
503 ///
504 /// Produced when the string holds a character other than `b` or `c`, or
505 /// repeats one of them.
506 ParseNetAccessError
507}
508
509/// One Landlock filesystem grant, as configured on the builder.
510#[derive(Debug, Clone)]
511struct Grant {
512 access: FsAccess,
513 path: PathBuf,
514}
515
516/// One Landlock network grant, as configured on the builder: an access set
517/// bound to a single TCP port.
518#[derive(Debug, Clone)]
519struct NetGrant {
520 access: NetAccess,
521 port: u16,
522}
523
524/// The action a seccomp filter takes for a syscall.
525///
526/// Mirrors the kernel's seccomp return actions. `Errno` is the gentlest —
527/// the syscall fails with the given error number and the command continues;
528/// `KillProcess` is the strictest.
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
530#[non_exhaustive]
531pub enum SeccompAction {
532 /// Allow the syscall.
533 Allow,
534 /// Fail the syscall with this error number, without running it.
535 Errno(u16),
536 /// Log the syscall (audit), then allow it.
537 Log,
538 /// Kill the calling thread.
539 KillThread,
540 /// Kill the whole process.
541 KillProcess,
542}
543
544impl SeccompAction {
545 fn to_seccompiler(self) -> seccompiler::SeccompAction {
546 match self {
547 SeccompAction::Allow => seccompiler::SeccompAction::Allow,
548 SeccompAction::Errno(errno) => seccompiler::SeccompAction::Errno(u32::from(errno)),
549 SeccompAction::Log => seccompiler::SeccompAction::Log,
550 SeccompAction::KillThread => seccompiler::SeccompAction::KillThread,
551 SeccompAction::KillProcess => seccompiler::SeccompAction::KillProcess,
552 }
553 }
554
555 /// Whether the action lets the syscall run. `Allow` runs it and `Log` runs
556 /// it after auditing; the rest deny it. Used to decide whether a policy
557 /// restricts anything at all.
558 fn is_permissive(self) -> bool {
559 matches!(self, SeccompAction::Allow | SeccompAction::Log)
560 }
561}
562
563/// `EPERM`, the error a denied syscall reports by default.
564const EPERM: u16 = 1;
565
566/// `BPF_MAXINSNS`, the kernel's ceiling on a classic BPF filter's length. A
567/// compiled seccomp program is checked against it at build time so an
568/// over-long program is rejected rather than silently truncated when its
569/// length is later narrowed to a `u16` for the kernel's `sock_fprog`.
570const BPF_MAXINSNS: usize = 4096;
571
572/// The width of a syscall argument to compare: the low 32 bits (`Dword`) or
573/// all 64 (`Qword`).
574///
575/// The kernel presents every syscall argument to a seccomp filter as a 64-bit
576/// value, whatever the argument's declared type. The width is a deliberate
577/// choice with a security edge: comparing only the low 32 bits of an argument
578/// that is really 64 bits lets a value hide in the high bits, and comparing
579/// all 64 bits of an argument the kernel treats as a 32-bit `int` lets a value
580/// hide there instead. Match the width to the argument's kernel type — `Dword`
581/// for an `int`, `unsigned int`, or a 32-bit flag set; `Qword` for a pointer,
582/// an `unsigned long`, or any 64-bit value.
583#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
584#[non_exhaustive]
585pub enum SeccompArgLen {
586 /// The low 32 bits of the argument.
587 Dword,
588 /// All 64 bits of the argument.
589 Qword,
590}
591
592/// The comparison a [`SeccompArg`] applies between a syscall argument and a
593/// reference value.
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
595#[non_exhaustive]
596pub enum SeccompCompare {
597 /// The argument equals the value.
598 Eq,
599 /// The argument does not equal the value.
600 Ne,
601 /// The argument is greater than or equal to the value.
602 Ge,
603 /// The argument is greater than the value.
604 Gt,
605 /// The argument is less than or equal to the value.
606 Le,
607 /// The argument is less than the value.
608 Lt,
609 /// The argument, masked with the carried bits, equals the value masked
610 /// with the same bits: `(arg & mask) == (value & mask)`. Tests individual
611 /// flag bits — for example, that `CLONE_NEWUSER` is clear in a `clone`
612 /// flags argument, with `MaskedEq(CLONE_NEWUSER)` against a value of `0`.
613 MaskedEq(u64),
614}
615
616/// A condition on one argument of a syscall.
617///
618/// A condition names an argument by index (0 through 5, the six syscall
619/// registers), a [width](SeccompArgLen) to compare, a [comparison](SeccompCompare),
620/// and a reference value. It narrows which invocations of a listed syscall a
621/// [`SeccompRules`] rule matches — "`ioctl` only when its request equals
622/// `TIOCGWINSZ`," "`socket` only when its domain equals `AF_INET`."
623///
624/// The width-named constructors ([`eq_dword`](Self::eq_dword),
625/// [`eq_qword`](Self::eq_qword), and the rest) are the usual way to build one;
626/// they make the [`Dword`](SeccompArgLen::Dword)/[`Qword`](SeccompArgLen::Qword)
627/// choice explicit at the call site, since it has no safe default. [`new`](Self::new)
628/// takes the parts directly.
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub struct SeccompArg {
631 index: u8,
632 len: SeccompArgLen,
633 compare: SeccompCompare,
634 value: u64,
635}
636
637impl SeccompArg {
638 /// A condition from its parts: the argument `index` (0 through 5), the
639 /// `len` to compare, the `compare` operation, and the reference `value`.
640 pub fn new(index: u8, len: SeccompArgLen, compare: SeccompCompare, value: u64) -> SeccompArg {
641 SeccompArg {
642 index,
643 len,
644 compare,
645 value,
646 }
647 }
648
649 /// The low 32 bits of argument `index` equal `value`.
650 pub fn eq_dword(index: u8, value: u64) -> SeccompArg {
651 SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Eq, value)
652 }
653
654 /// All 64 bits of argument `index` equal `value`.
655 pub fn eq_qword(index: u8, value: u64) -> SeccompArg {
656 SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Eq, value)
657 }
658
659 /// The low 32 bits of argument `index` do not equal `value`.
660 pub fn ne_dword(index: u8, value: u64) -> SeccompArg {
661 SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Ne, value)
662 }
663
664 /// All 64 bits of argument `index` do not equal `value`.
665 pub fn ne_qword(index: u8, value: u64) -> SeccompArg {
666 SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Ne, value)
667 }
668
669 /// The low 32 bits of argument `index` are greater than or equal to
670 /// `value`.
671 pub fn ge_dword(index: u8, value: u64) -> SeccompArg {
672 SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Ge, value)
673 }
674
675 /// All 64 bits of argument `index` are greater than or equal to `value`.
676 pub fn ge_qword(index: u8, value: u64) -> SeccompArg {
677 SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Ge, value)
678 }
679
680 /// The low 32 bits of argument `index` are greater than `value`.
681 pub fn gt_dword(index: u8, value: u64) -> SeccompArg {
682 SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Gt, value)
683 }
684
685 /// All 64 bits of argument `index` are greater than `value`.
686 pub fn gt_qword(index: u8, value: u64) -> SeccompArg {
687 SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Gt, value)
688 }
689
690 /// The low 32 bits of argument `index` are less than or equal to `value`.
691 pub fn le_dword(index: u8, value: u64) -> SeccompArg {
692 SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Le, value)
693 }
694
695 /// All 64 bits of argument `index` are less than or equal to `value`.
696 pub fn le_qword(index: u8, value: u64) -> SeccompArg {
697 SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Le, value)
698 }
699
700 /// The low 32 bits of argument `index` are less than `value`.
701 pub fn lt_dword(index: u8, value: u64) -> SeccompArg {
702 SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Lt, value)
703 }
704
705 /// All 64 bits of argument `index` are less than `value`.
706 pub fn lt_qword(index: u8, value: u64) -> SeccompArg {
707 SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Lt, value)
708 }
709
710 /// The low 32 bits of argument `index`, masked with `mask`, equal `value`
711 /// masked with `mask`.
712 pub fn masked_eq_dword(index: u8, mask: u64, value: u64) -> SeccompArg {
713 SeccompArg::new(
714 index,
715 SeccompArgLen::Dword,
716 SeccompCompare::MaskedEq(mask),
717 value,
718 )
719 }
720
721 /// All 64 bits of argument `index`, masked with `mask`, equal `value`
722 /// masked with `mask`.
723 pub fn masked_eq_qword(index: u8, mask: u64, value: u64) -> SeccompArg {
724 SeccompArg::new(
725 index,
726 SeccompArgLen::Qword,
727 SeccompCompare::MaskedEq(mask),
728 value,
729 )
730 }
731
732 /// Lowers the condition to the compiler backend's representation, mapping a
733 /// rejected argument index to a [`ConfigError`].
734 fn to_seccompiler(self) -> Result<seccompiler::SeccompCondition, ConfigError> {
735 let len = match self.len {
736 SeccompArgLen::Dword => seccompiler::SeccompCmpArgLen::Dword,
737 SeccompArgLen::Qword => seccompiler::SeccompCmpArgLen::Qword,
738 };
739 let op = match self.compare {
740 SeccompCompare::Eq => seccompiler::SeccompCmpOp::Eq,
741 SeccompCompare::Ne => seccompiler::SeccompCmpOp::Ne,
742 SeccompCompare::Ge => seccompiler::SeccompCmpOp::Ge,
743 SeccompCompare::Gt => seccompiler::SeccompCmpOp::Gt,
744 SeccompCompare::Le => seccompiler::SeccompCmpOp::Le,
745 SeccompCompare::Lt => seccompiler::SeccompCmpOp::Lt,
746 SeccompCompare::MaskedEq(mask) => seccompiler::SeccompCmpOp::MaskedEq(mask),
747 };
748 seccompiler::SeccompCondition::new(self.index, len, op, self.value).map_err(|err| {
749 ConfigError::SeccompInvalid {
750 reason: err.to_string(),
751 }
752 })
753 }
754}
755
756/// A listed syscall and the argument conditions, if any, that narrow which of
757/// its invocations the rule matches. Empty conditions match the syscall
758/// regardless of its arguments.
759#[derive(Debug, Clone)]
760struct SyscallRule {
761 syscall: i64,
762 /// Conditions that all must hold (an AND) for the rule to match.
763 conditions: Vec<SeccompArg>,
764}
765
766impl SyscallRule {
767 /// A rule that matches `syscall` regardless of its arguments.
768 fn unconditional(syscall: i64) -> SyscallRule {
769 SyscallRule {
770 syscall,
771 conditions: Vec::new(),
772 }
773 }
774}
775
776/// A caller-authored seccomp policy: a default action for unlisted syscalls,
777/// and the opposite action for a named list.
778///
779/// The two shapes cover the common cases. An allowlist names the syscalls the
780/// command may make and denies the rest — the strict posture, which must
781/// include every syscall the command needs (`execve` among them, since it is
782/// the first syscall the filter governs). A denylist names the syscalls to
783/// refuse and allows the rest.
784///
785/// Syscalls are named by their kernel number for the host architecture; the
786/// `syscalls` crate or `libc` provides them.
787///
788/// A listed syscall may carry [argument conditions](SeccompArg) through
789/// [`rule`](Self::rule), so the listed action applies only to invocations
790/// whose arguments match — an allowlist that permits `ioctl` only for a
791/// specific request, a denylist that refuses `socket` only for `AF_INET`. A
792/// syscall listed both plainly and with conditions matches unconditionally:
793/// the broader listing wins.
794///
795/// A seccomp filter governs syscalls, not the operations an asynchronous
796/// submission interface performs on their behalf. An allowlist that permits
797/// `io_uring_enter` therefore permits the file opens, connects, and reads
798/// submitted through the ring, even where it denies `openat`, `connect`, or
799/// `read` directly. Do not allowlist the io_uring syscalls unless the command
800/// genuinely needs them; the [curated policy](SeccompPolicy::Curated) denies
801/// them for this reason.
802#[derive(Debug, Clone)]
803pub struct SeccompRules {
804 default_action: SeccompAction,
805 listed_action: SeccompAction,
806 rules: Vec<SyscallRule>,
807}
808
809impl SeccompRules {
810 /// A denylist: `syscalls` fail with `EPERM`, everything else is allowed.
811 pub fn denying<I: IntoIterator<Item = i64>>(syscalls: I) -> SeccompRules {
812 SeccompRules {
813 default_action: SeccompAction::Allow,
814 listed_action: SeccompAction::Errno(EPERM),
815 rules: syscalls
816 .into_iter()
817 .map(SyscallRule::unconditional)
818 .collect(),
819 }
820 }
821
822 /// An allowlist: `syscalls` are allowed, everything else fails with
823 /// `EPERM`.
824 pub fn allowing<I: IntoIterator<Item = i64>>(syscalls: I) -> SeccompRules {
825 SeccompRules {
826 default_action: SeccompAction::Errno(EPERM),
827 listed_action: SeccompAction::Allow,
828 rules: syscalls
829 .into_iter()
830 .map(SyscallRule::unconditional)
831 .collect(),
832 }
833 }
834
835 /// Lists `syscall` with argument conditions: the listed action applies
836 /// only when every condition in `conditions` holds (an AND). Call this
837 /// more than once for the same syscall to accept any of several condition
838 /// sets (an OR).
839 ///
840 /// Passing no conditions lists the syscall unconditionally, the same as
841 /// naming it in [`denying`](Self::denying) or [`allowing`](Self::allowing).
842 /// If a syscall is listed both unconditionally and with conditions, the
843 /// unconditional listing wins.
844 pub fn rule<I: IntoIterator<Item = SeccompArg>>(
845 mut self,
846 syscall: i64,
847 conditions: I,
848 ) -> SeccompRules {
849 self.rules.push(SyscallRule {
850 syscall,
851 conditions: conditions.into_iter().collect(),
852 });
853 self
854 }
855
856 /// Sets the action for unlisted syscalls (the default is `Allow` for a
857 /// denylist, `Errno(EPERM)` for an allowlist).
858 pub fn default_action(mut self, action: SeccompAction) -> SeccompRules {
859 self.default_action = action;
860 self
861 }
862
863 /// Sets the action for the listed syscalls.
864 pub fn listed_action(mut self, action: SeccompAction) -> SeccompRules {
865 self.listed_action = action;
866 self
867 }
868
869 /// Whether the policy denies any syscall. A default-deny (allowlist)
870 /// posture always restricts, since it denies every unlisted syscall; a
871 /// default-allow (denylist) posture restricts only when it lists at least
872 /// one syscall under a denying action. An empty `denying([])` restricts
873 /// nothing.
874 fn restricts(&self) -> bool {
875 !self.default_action.is_permissive()
876 || (!self.rules.is_empty() && !self.listed_action.is_permissive())
877 }
878}
879
880/// A seccomp syscall-filtering policy for the sandboxed command.
881#[derive(Clone)]
882#[non_exhaustive]
883pub enum SeccompPolicy {
884 /// A curated default-deny of dangerous and rarely-legitimate syscalls,
885 /// allowing the rest. A convenience posture, not a boundary claim; its
886 /// exact roster is documented and grows only additively.
887 ///
888 /// Two entries are conditioned on an argument rather than naming a whole
889 /// syscall: `ioctl` is denied for the `TIOCSTI` and `TIOCLINUX` requests,
890 /// which write to a terminal's input queue, and allowed for everything
891 /// else.
892 Curated,
893 /// A caller-authored allow- or denylist.
894 Rules(SeccompRules),
895 /// An already-compiled BPF program, installed verbatim — the escape hatch
896 /// for a policy an external compiler produced.
897 Program(Vec<SockFilter>),
898}
899
900impl SeccompPolicy {
901 /// Whether the policy denies any syscall. The curated roster and any
902 /// non-empty caller policy restrict; an empty `denying([])` does not. Used
903 /// to decide whether a restriction confines anything through seccomp, so a
904 /// no-op policy does not pass the empty-restriction guard.
905 pub(crate) fn restricts(&self) -> bool {
906 match self {
907 SeccompPolicy::Curated => true,
908 SeccompPolicy::Rules(rules) => rules.restricts(),
909 // The escape hatch is opaque; a non-empty program is assumed to
910 // restrict, an empty one (which the kernel would reject anyway)
911 // does not.
912 SeccompPolicy::Program(program) => !program.is_empty(),
913 }
914 }
915}
916
917// A hand-written Debug so the escape-hatch program prints its length, not
918// every instruction. `Clone` is derived on the type, being structural.
919impl std::fmt::Debug for SeccompPolicy {
920 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
921 match self {
922 SeccompPolicy::Curated => f.write_str("Curated"),
923 SeccompPolicy::Rules(rules) => f.debug_tuple("Rules").field(rules).finish(),
924 SeccompPolicy::Program(program) => f
925 .debug_tuple("Program")
926 .field(&format_args!("{} instructions", program.len()))
927 .finish(),
928 }
929 }
930}
931
932/// The curated denylist: dangerous or rarely-legitimate syscalls, denied with
933/// `EPERM`. Named through the `syscalls` crate so the numbers are correct for
934/// the host architecture. Not exhaustive — a convenience posture.
935///
936/// The list denies `io_uring_setup`/`io_uring_enter`/`io_uring_register`
937/// because io_uring can carry filesystem and network operations
938/// (`IORING_OP_OPENAT`, `IORING_OP_CONNECT`, ...) that a syscall filter never
939/// sees. That same limitation means an *allowlist* a caller authors is
940/// bypassable if it permits the io_uring syscalls but denies the operations
941/// io_uring can perform: filter the io_uring syscalls too, or leave them
942/// unlisted so the allowlist's default action denies them. See
943/// [`SeccompRules`].
944fn curated_denylist() -> Vec<i64> {
945 use syscalls::Sysno;
946 // Filesystem topology, kernel modules, the keyring, tracing, time, and
947 // other administrative surfaces a sandboxed command has no business
948 // reaching. Every entry of the base roster exists on all three
949 // seccomp-supported architectures; anything that does not is added after it,
950 // under the architecture it belongs to.
951 let base = [
952 Sysno::mount,
953 Sysno::umount2,
954 Sysno::pivot_root,
955 Sysno::mount_setattr,
956 Sysno::move_mount,
957 Sysno::open_tree,
958 Sysno::fsopen,
959 Sysno::fsconfig,
960 Sysno::fsmount,
961 Sysno::fspick,
962 // A file handle names an inode by (mount id, opaque handle) rather than
963 // by path, so it reaches across the mount topology the way the calls
964 // above reshape it. Opening one needs CAP_DAC_READ_SEARCH, which the
965 // sandbox's own user namespace grants.
966 Sysno::name_to_handle_at,
967 Sysno::open_by_handle_at,
968 Sysno::swapon,
969 Sysno::swapoff,
970 Sysno::reboot,
971 Sysno::kexec_load,
972 Sysno::kexec_file_load,
973 Sysno::init_module,
974 Sysno::finit_module,
975 Sysno::delete_module,
976 Sysno::add_key,
977 Sysno::keyctl,
978 Sysno::request_key,
979 Sysno::bpf,
980 Sysno::ptrace,
981 // The natural complements to ptrace: cross-process memory access,
982 // reachable against another process of the same uid in a restriction
983 // (no PID namespace) where ptrace alone would not suffice.
984 Sysno::process_vm_readv,
985 Sysno::process_vm_writev,
986 Sysno::perf_event_open,
987 // Handing page-fault resolution to userspace is a standard primitive
988 // for widening a kernel race into a reliable exploit, and nothing a
989 // sandboxed build needs.
990 Sysno::userfaultfd,
991 // Changing the execution domain can weaken protections the sandbox
992 // relies on (for instance READ_IMPLIES_EXEC, or re-enabling layout
993 // randomization the caller disabled).
994 Sysno::personality,
995 // io_uring is a submission ring for asynchronous syscalls; denying it
996 // keeps operations from reaching the kernel outside the filter's view.
997 Sysno::io_uring_setup,
998 Sysno::io_uring_enter,
999 Sysno::io_uring_register,
1000 Sysno::settimeofday,
1001 Sysno::clock_settime,
1002 Sysno::clock_adjtime,
1003 Sysno::adjtimex,
1004 Sysno::setns,
1005 Sysno::acct,
1006 Sysno::quotactl,
1007 ];
1008
1009 // `modify_ldt` changes the execution domain like `personality` does, and is
1010 // denied for the same reason — but the local descriptor table is an x86
1011 // construct, so the name exists only there.
1012 #[cfg(target_arch = "x86_64")]
1013 let arch_specific = [Sysno::modify_ldt];
1014 #[cfg(not(target_arch = "x86_64"))]
1015 let arch_specific: [Sysno; 0] = [];
1016
1017 base.into_iter()
1018 .chain(arch_specific)
1019 .map(|sysno| sysno.id() as i64)
1020 .collect()
1021}
1022
1023/// `TIOCSTI`: push one character into a terminal's input queue, as though it
1024/// had been typed.
1025const TIOCSTI: u64 = 0x5412;
1026/// `TIOCLINUX`: the Linux console multiplexer, whose `TIOCL_SETSEL`/
1027/// `TIOCL_PASTESEL` subcommands paste a console selection into the input queue
1028/// and so reach the same place `TIOCSTI` does.
1029const TIOCLINUX: u64 = 0x541C;
1030
1031/// The curated denylist's argument-conditioned entries: the two `ioctl`
1032/// requests that write to a terminal's input queue.
1033///
1034/// Denied by request rather than by syscall, because `ioctl` itself is the
1035/// ordinary way to ask anything of a device and a sandboxed command needs it.
1036/// A command that holds a descriptor for the caller's terminal — an inherited
1037/// standard stream, or `/dev/tty` while it shares the caller's session — can
1038/// otherwise use these to enqueue characters that the caller's shell reads and
1039/// runs once the sandbox exits. Modern kernels gate `TIOCSTI` behind the
1040/// `dev.tty.legacy_tiocsti` sysctl, which defaults to off, and grant it only
1041/// for the process's own controlling terminal; this denies it outright, on
1042/// every kernel, whichever terminal is named. See [`Stdio`](crate::Stdio),
1043/// which decides whether the sandbox reaches the *caller's* terminal at all —
1044/// the reach this exists to close. A terminal launch
1045/// ([`Cage::spawn_terminal`](crate::Cage::spawn_terminal)) reaches none of it
1046/// whatever `Stdio` says, since the sandbox has a terminal of its own; the
1047/// denial still applies there, and denies an operation on a terminal nobody
1048/// else can see, which is the price of a denylist that does not ask whose
1049/// terminal it is.
1050///
1051/// The comparison is on the low 32 bits because `ioctl`'s request argument
1052/// reaches the kernel as an `unsigned int`: the high half is discarded before
1053/// dispatch, so comparing all 64 bits would let a request with anything set
1054/// above bit 31 pass the filter and still be honoured.
1055fn curated_ioctl_rules() -> Vec<SyscallRule> {
1056 [TIOCSTI, TIOCLINUX]
1057 .into_iter()
1058 .map(|request| SyscallRule {
1059 syscall: syscalls::Sysno::ioctl.id() as i64,
1060 conditions: vec![SeccompArg::eq_dword(1, request)],
1061 })
1062 .collect()
1063}
1064
1065roster_enum! {
1066 /// A named capability, for [`CageBuilder::keep_capabilities`].
1067 ///
1068 /// In a fresh user namespace the command is mapped to root and holds every
1069 /// capability *within that namespace* — none of which confers authority over
1070 /// the host, but which do grant power inside the sandbox.
1071 /// [`drop_all_capabilities`](crate::CageBuilder::drop_all_capabilities) sheds
1072 /// them; a keep-list retains the named few.
1073 ///
1074 /// Each capability has a canonical kebab-case name (`net-bind-service`),
1075 /// printed by [`Display`](std::fmt::Display) and parsed by
1076 /// [`FromStr`](std::str::FromStr). Parsing also accepts the snake-case form
1077 /// and an optional `cap-`/`cap_` prefix, and is case-insensitive, so
1078 /// `CAP_SYS_ADMIN`, `sys_admin`, and `sys-admin` all parse to
1079 /// [`SysAdmin`](Self::SysAdmin). [`ALL`](Self::ALL) lists every variant.
1080 ///
1081 /// [`CageBuilder::keep_capabilities`]: crate::CageBuilder::keep_capabilities
1082 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1083 #[non_exhaustive]
1084 pub enum Capability {
1085 /// The single-bit capability set naming this capability.
1086 fn to_set -> CapabilitySet;
1087 /// The canonical kebab-case name, without the `cap-` prefix.
1088 fn as_str;
1089
1090 /// `CAP_CHOWN`.
1091 Chown = CapabilitySet::CHOWN => "chown",
1092 /// `CAP_DAC_OVERRIDE`.
1093 DacOverride = CapabilitySet::DAC_OVERRIDE => "dac-override",
1094 /// `CAP_DAC_READ_SEARCH`.
1095 DacReadSearch = CapabilitySet::DAC_READ_SEARCH => "dac-read-search",
1096 /// `CAP_FOWNER`.
1097 Fowner = CapabilitySet::FOWNER => "fowner",
1098 /// `CAP_FSETID`.
1099 Fsetid = CapabilitySet::FSETID => "fsetid",
1100 /// `CAP_KILL`.
1101 Kill = CapabilitySet::KILL => "kill",
1102 /// `CAP_SETGID`.
1103 Setgid = CapabilitySet::SETGID => "setgid",
1104 /// `CAP_SETUID`.
1105 Setuid = CapabilitySet::SETUID => "setuid",
1106 /// `CAP_SETPCAP`.
1107 Setpcap = CapabilitySet::SETPCAP => "setpcap",
1108 /// `CAP_LINUX_IMMUTABLE`.
1109 LinuxImmutable = CapabilitySet::LINUX_IMMUTABLE => "linux-immutable",
1110 /// `CAP_NET_BIND_SERVICE`.
1111 NetBindService = CapabilitySet::NET_BIND_SERVICE => "net-bind-service",
1112 /// `CAP_NET_BROADCAST`.
1113 NetBroadcast = CapabilitySet::NET_BROADCAST => "net-broadcast",
1114 /// `CAP_NET_ADMIN`.
1115 NetAdmin = CapabilitySet::NET_ADMIN => "net-admin",
1116 /// `CAP_NET_RAW`.
1117 NetRaw = CapabilitySet::NET_RAW => "net-raw",
1118 /// `CAP_IPC_LOCK`.
1119 IpcLock = CapabilitySet::IPC_LOCK => "ipc-lock",
1120 /// `CAP_IPC_OWNER`.
1121 IpcOwner = CapabilitySet::IPC_OWNER => "ipc-owner",
1122 /// `CAP_SYS_MODULE`.
1123 SysModule = CapabilitySet::SYS_MODULE => "sys-module",
1124 /// `CAP_SYS_RAWIO`.
1125 SysRawio = CapabilitySet::SYS_RAWIO => "sys-rawio",
1126 /// `CAP_SYS_CHROOT`.
1127 SysChroot = CapabilitySet::SYS_CHROOT => "sys-chroot",
1128 /// `CAP_SYS_PTRACE`.
1129 SysPtrace = CapabilitySet::SYS_PTRACE => "sys-ptrace",
1130 /// `CAP_SYS_PACCT`.
1131 SysPacct = CapabilitySet::SYS_PACCT => "sys-pacct",
1132 /// `CAP_SYS_ADMIN`.
1133 SysAdmin = CapabilitySet::SYS_ADMIN => "sys-admin",
1134 /// `CAP_SYS_BOOT`.
1135 SysBoot = CapabilitySet::SYS_BOOT => "sys-boot",
1136 /// `CAP_SYS_NICE`.
1137 SysNice = CapabilitySet::SYS_NICE => "sys-nice",
1138 /// `CAP_SYS_RESOURCE`.
1139 SysResource = CapabilitySet::SYS_RESOURCE => "sys-resource",
1140 /// `CAP_SYS_TIME`.
1141 SysTime = CapabilitySet::SYS_TIME => "sys-time",
1142 /// `CAP_SYS_TTY_CONFIG`.
1143 SysTtyConfig = CapabilitySet::SYS_TTY_CONFIG => "sys-tty-config",
1144 /// `CAP_MKNOD`.
1145 Mknod = CapabilitySet::MKNOD => "mknod",
1146 /// `CAP_LEASE`.
1147 Lease = CapabilitySet::LEASE => "lease",
1148 /// `CAP_AUDIT_WRITE`.
1149 AuditWrite = CapabilitySet::AUDIT_WRITE => "audit-write",
1150 /// `CAP_AUDIT_CONTROL`.
1151 AuditControl = CapabilitySet::AUDIT_CONTROL => "audit-control",
1152 /// `CAP_SETFCAP`.
1153 Setfcap = CapabilitySet::SETFCAP => "setfcap",
1154 /// `CAP_MAC_OVERRIDE`.
1155 MacOverride = CapabilitySet::MAC_OVERRIDE => "mac-override",
1156 /// `CAP_MAC_ADMIN`.
1157 MacAdmin = CapabilitySet::MAC_ADMIN => "mac-admin",
1158 /// `CAP_SYSLOG`.
1159 Syslog = CapabilitySet::SYSLOG => "syslog",
1160 /// `CAP_WAKE_ALARM`.
1161 WakeAlarm = CapabilitySet::WAKE_ALARM => "wake-alarm",
1162 /// `CAP_BLOCK_SUSPEND`.
1163 BlockSuspend = CapabilitySet::BLOCK_SUSPEND => "block-suspend",
1164 /// `CAP_AUDIT_READ`.
1165 AuditRead = CapabilitySet::AUDIT_READ => "audit-read",
1166 /// `CAP_PERFMON`.
1167 Perfmon = CapabilitySet::PERFMON => "perfmon",
1168 /// `CAP_BPF`.
1169 Bpf = CapabilitySet::BPF => "bpf",
1170 /// `CAP_CHECKPOINT_RESTORE`.
1171 CheckpointRestore = CapabilitySet::CHECKPOINT_RESTORE => "checkpoint-restore",
1172 }
1173}
1174
1175impl fmt::Display for Capability {
1176 /// Writes the canonical kebab-case name, such as `net-bind-service`.
1177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1178 f.write_str(self.as_str())
1179 }
1180}
1181
1182impl FromStr for Capability {
1183 type Err = ParseCapabilityError;
1184
1185 /// Parses a capability name, accepting the kebab or snake form, an
1186 /// optional `cap-`/`cap_` prefix, and any letter case.
1187 fn from_str(s: &str) -> Result<Capability, ParseCapabilityError> {
1188 let lowered = s.to_ascii_lowercase().replace('_', "-");
1189 let normalized = lowered.strip_prefix("cap-").unwrap_or(&lowered);
1190 Capability::ALL
1191 .iter()
1192 .copied()
1193 .find(|cap| cap.as_str() == normalized)
1194 .ok_or_else(|| ParseCapabilityError {
1195 text: s.to_string(),
1196 })
1197 }
1198}
1199
1200/// The error from parsing an unrecognized [`Capability`] name.
1201#[derive(Debug, Clone, PartialEq, Eq)]
1202pub struct ParseCapabilityError {
1203 text: String,
1204}
1205
1206impl ParseCapabilityError {
1207 /// The unrecognized name, as it was given.
1208 ///
1209 /// Named `text` on every parse error in this crate, so a caller moving
1210 /// between them does not have to check.
1211 pub fn text(&self) -> &str {
1212 &self.text
1213 }
1214}
1215
1216impl fmt::Display for ParseCapabilityError {
1217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1218 write!(f, "unknown capability {:?}", self.text)
1219 }
1220}
1221
1222impl std::error::Error for ParseCapabilityError {}
1223
1224/// The capability posture: leave the namespaced set alone, or reduce it to a
1225/// kept few (empty for a full drop).
1226#[derive(Debug, Clone, Default)]
1227enum CapabilityPosture {
1228 /// Do not touch capabilities (the default).
1229 #[default]
1230 Keep,
1231 /// Reduce the bounding, ambient, permitted, effective, and inheritable
1232 /// sets to exactly these capabilities.
1233 Reduce(Vec<Capability>),
1234}
1235
1236/// The hardening request accumulated on the builder, lowered by `build` into
1237/// the frozen [`HardeningPlan`].
1238///
1239/// One field on [`CageBuilder`] rather than several, so the builder and the
1240/// lowering each touch a single place.
1241///
1242/// [`CageBuilder`]: crate::CageBuilder
1243#[derive(Debug, Clone, Default)]
1244pub(crate) struct Request {
1245 landlock: Vec<Grant>,
1246 landlock_net: Vec<NetGrant>,
1247 seccomp: Option<SeccompPolicy>,
1248 capabilities: CapabilityPosture,
1249}
1250
1251impl Request {
1252 /// Adds a Landlock grant of `access` beneath `path`.
1253 pub(crate) fn grant_fs(&mut self, access: FsAccess, path: &Path) {
1254 self.landlock.push(Grant {
1255 access,
1256 path: path.to_path_buf(),
1257 });
1258 }
1259
1260 /// Adds a Landlock grant of network `access` on the TCP `port`.
1261 pub(crate) fn grant_net(&mut self, access: NetAccess, port: u16) {
1262 self.landlock_net.push(NetGrant { access, port });
1263 }
1264
1265 /// Sets the seccomp policy.
1266 pub(crate) fn set_seccomp(&mut self, policy: SeccompPolicy) {
1267 self.seccomp = Some(policy);
1268 }
1269
1270 /// Whether the request restricts nothing: no Landlock grant — filesystem
1271 /// or network — and no seccomp policy that denies a syscall. A seccomp
1272 /// policy that denies nothing (an empty `denying([])`) does not count, so a
1273 /// restriction whose only "confinement" is a no-op filter is caught by the
1274 /// empty-restriction guard rather than run unconfined. The capability
1275 /// posture does not count — the restriction fallback, the check's consumer,
1276 /// cannot express one.
1277 pub(crate) fn is_unrestricted(&self) -> bool {
1278 self.landlock.is_empty()
1279 && self.landlock_net.is_empty()
1280 && self
1281 .seccomp
1282 .as_ref()
1283 .is_none_or(|policy| !policy.restricts())
1284 }
1285
1286 /// Whether the request carries no hardening at all: no Landlock grant, no
1287 /// seccomp policy, and the default, untouched capability posture. Unlike
1288 /// [`is_unrestricted`](Self::is_unrestricted), this also requires the
1289 /// default capability posture, so a profile that only drops capabilities
1290 /// still serializes its `[hardening]` table. Serde skips the field when
1291 /// this holds.
1292 #[cfg(feature = "serde")]
1293 pub(crate) fn has_no_hardening(&self) -> bool {
1294 self.landlock.is_empty()
1295 && self.landlock_net.is_empty()
1296 && self.seccomp.is_none()
1297 && matches!(self.capabilities, CapabilityPosture::Keep)
1298 }
1299
1300 /// Drops every capability.
1301 pub(crate) fn drop_all_capabilities(&mut self) {
1302 self.capabilities = CapabilityPosture::Reduce(Vec::new());
1303 }
1304
1305 /// Keeps only the named capabilities, dropping the rest.
1306 pub(crate) fn keep_capabilities<I: IntoIterator<Item = Capability>>(&mut self, caps: I) {
1307 self.capabilities = CapabilityPosture::Reduce(caps.into_iter().collect());
1308 }
1309
1310 /// Lowers the request into the frozen plan, compiling the seccomp policy
1311 /// and validating the Landlock grant paths. Called from the builders'
1312 /// `build`, where allocation and failure are free.
1313 pub(crate) fn lower(&self) -> Result<HardeningPlan, ConfigError> {
1314 // An empty access set is a trap rather than a no-op: enrolling any
1315 // grant denies everything ungranted, while the kernel skips a rule that
1316 // permits nothing. A grant of `FsAccess::empty()` therefore denies the
1317 // entire filesystem — including the command's own binary, which fails
1318 // the launch at `execve` with nothing pointing at the cause — and an
1319 // empty network grant denies all TCP. Neither is what naming a path or
1320 // a port means, so both are refused here.
1321 let landlock = self
1322 .landlock
1323 .iter()
1324 .map(|grant| {
1325 if grant.access.is_empty() {
1326 return Err(ConfigError::LandlockGrantEmpty {
1327 grant: grant.path.display().to_string(),
1328 });
1329 }
1330 Ok(LandlockRule {
1331 path: grant_path_cstring(&grant.path)?,
1332 access: grant.access.to_landlock(),
1333 })
1334 })
1335 .collect::<Result<Vec<_>, ConfigError>>()?;
1336
1337 let landlock_net = self
1338 .landlock_net
1339 .iter()
1340 .map(|grant| {
1341 if grant.access.is_empty() {
1342 return Err(ConfigError::LandlockGrantEmpty {
1343 grant: format!("tcp:{}", grant.port),
1344 });
1345 }
1346 Ok(LandlockNetRule {
1347 port: grant.port,
1348 access: grant.access.to_landlock(),
1349 })
1350 })
1351 .collect::<Result<Vec<_>, ConfigError>>()?;
1352
1353 let seccomp = match &self.seccomp {
1354 Some(policy) => Some(compile_seccomp(policy)?),
1355 None => None,
1356 };
1357
1358 let keep_caps = match &self.capabilities {
1359 CapabilityPosture::Keep => None,
1360 CapabilityPosture::Reduce(caps) => {
1361 let mut set = CapabilitySet::empty();
1362 for cap in caps {
1363 set |= cap.to_set();
1364 }
1365 Some(set.bits())
1366 }
1367 };
1368
1369 Ok(HardeningPlan {
1370 landlock,
1371 landlock_handled: if self.landlock.is_empty() {
1372 0
1373 } else {
1374 fs_access::ALL
1375 },
1376 landlock_net,
1377 landlock_net_handled: if self.landlock_net.is_empty() {
1378 0
1379 } else {
1380 net_access::ALL
1381 },
1382 seccomp,
1383 keep_caps,
1384 // Whether the identity switch needs securebits is decided by
1385 // the builder, which alone knows the run-as identity.
1386 set_securebits: false,
1387 })
1388 }
1389}
1390
1391/// Validates a Landlock grant path — absolute, ordinary components only, the
1392/// bare root allowed so a grant can cover a whole tree — and freezes it as a
1393/// `CString`.
1394///
1395/// The bare root is the one way this differs from a mount target's check: a
1396/// grant over `/` is a coherent request, where a mount target of `/` is not.
1397/// In a cage the path resolves after the pivot; in a restriction it resolves
1398/// against the host filesystem.
1399fn grant_path_cstring(path: &Path) -> Result<CString, ConfigError> {
1400 crate::path::absolute_components(path).map_err(|_| ConfigError::LandlockPathInvalid {
1401 path: path.to_path_buf(),
1402 })?;
1403 crate::spec::cstring(path.as_os_str())
1404}
1405
1406/// Compiles a seccomp policy to a BPF program, rejecting one that exceeds the
1407/// kernel's instruction ceiling.
1408fn compile_seccomp(policy: &SeccompPolicy) -> Result<Vec<SockFilter>, ConfigError> {
1409 let program = match policy {
1410 SeccompPolicy::Curated => {
1411 let rules: Vec<SyscallRule> = curated_denylist()
1412 .into_iter()
1413 .map(SyscallRule::unconditional)
1414 .chain(curated_ioctl_rules())
1415 .collect();
1416 compile_filter(SeccompAction::Allow, SeccompAction::Errno(EPERM), &rules)?
1417 }
1418 SeccompPolicy::Rules(rules) => {
1419 compile_filter(rules.default_action, rules.listed_action, &rules.rules)?
1420 }
1421 SeccompPolicy::Program(program) => program.clone(),
1422 };
1423 // The kernel rejects a filter longer than BPF_MAXINSNS, but only if the
1424 // length reaching it is honest; the install path narrows the length to a
1425 // u16, so an over-long program (a caller's precompiled escape hatch) must
1426 // be caught here, before that cast can wrap it into a different filter.
1427 if program.len() > BPF_MAXINSNS {
1428 return Err(ConfigError::SeccompProgramTooLong {
1429 len: program.len(),
1430 max: BPF_MAXINSNS,
1431 });
1432 }
1433 Ok(program)
1434}
1435
1436/// Classic-BPF opcodes and `seccomp_data` field offsets for the hand-emitted
1437/// x32 guard. Named here rather than pulled from a binding so the one raw-BPF
1438/// fragment in the crate is self-contained and reviewable.
1439mod bpf {
1440 /// `BPF_LD | BPF_W | BPF_ABS`: load a 32-bit word from a fixed offset in
1441 /// `seccomp_data` into the accumulator.
1442 pub(super) const LD_W_ABS: u16 = 0x20;
1443 /// `BPF_JMP | BPF_JEQ | BPF_K`: branch on the accumulator equalling `k`.
1444 pub(super) const JMP_JEQ_K: u16 = 0x15;
1445 /// `BPF_JMP | BPF_JSET | BPF_K`: branch on the accumulator sharing any bit
1446 /// with `k`.
1447 pub(super) const JMP_JSET_K: u16 = 0x45;
1448 /// `BPF_RET | BPF_K`: return `k` as the seccomp action.
1449 pub(super) const RET_K: u16 = 0x06;
1450
1451 /// Byte offset of `seccomp_data.nr`, the syscall number.
1452 pub(super) const OFF_NR: u32 = 0;
1453 /// Byte offset of `seccomp_data.arch`, the AUDIT_ARCH of the syscall.
1454 pub(super) const OFF_ARCH: u32 = 4;
1455}
1456
1457/// `AUDIT_ARCH_X86_64`: the audit architecture the x32 ABI shares with 64-bit
1458/// x86_64, which is why an x32 syscall passes an x86_64 arch gate.
1459///
1460/// Only a denylist needs to care: an allowlist's default-deny already refuses
1461/// the x32 numbers, so the guard this constant serves — [`x32_deny_prologue`] —
1462/// is emitted for a permissive default alone, in [`compile_filter`].
1463const AUDIT_ARCH_X86_64: u32 = 0xC000_003E;
1464
1465/// `__X32_SYSCALL_BIT`: set on every syscall number issued through the x32 ABI.
1466///
1467/// Adding it to a syscall number gives the x32 alias of the same call, which is
1468/// what lets an x32 caller slip past a denylist that names only the 64-bit
1469/// numbers; see [`AUDIT_ARCH_X86_64`].
1470const X32_SYSCALL_BIT: u32 = 0x4000_0000;
1471
1472/// The raw `SECCOMP_RET_*` value a BPF `ret` returns for `action`.
1473///
1474/// The x32 guard emits a `ret` directly, below seccompiler's compilation, so
1475/// it needs the same encoding seccompiler produces from a
1476/// [`seccompiler::SeccompAction`]. The action words are the kernel's, from
1477/// `<linux/seccomp.h>`.
1478fn seccomp_ret_value(action: SeccompAction) -> u32 {
1479 const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000;
1480 const SECCOMP_RET_KILL_THREAD: u32 = 0x0000_0000;
1481 const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
1482 const SECCOMP_RET_LOG: u32 = 0x7ffc_0000;
1483 const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
1484 const SECCOMP_RET_DATA: u32 = 0x0000_ffff;
1485 match action {
1486 SeccompAction::Allow => SECCOMP_RET_ALLOW,
1487 SeccompAction::Errno(errno) => SECCOMP_RET_ERRNO | (u32::from(errno) & SECCOMP_RET_DATA),
1488 SeccompAction::Log => SECCOMP_RET_LOG,
1489 SeccompAction::KillThread => SECCOMP_RET_KILL_THREAD,
1490 SeccompAction::KillProcess => SECCOMP_RET_KILL_PROCESS,
1491 }
1492}
1493
1494/// A five-instruction classic-BPF prologue applying `deny` to every syscall
1495/// carrying `__X32_SYSCALL_BIT`, for prepending to a denylist compiled for
1496/// x86_64.
1497///
1498/// A denylist gates on AUDIT_ARCH_X86_64 and then matches raw syscall numbers.
1499/// The x32 ABI shares that arch but sets `__X32_SYSCALL_BIT` on every number,
1500/// so a denied syscall (`ptrace`, `mount`, ...) issued through x32 matches no
1501/// rule and falls through to the allowing default. Denying the whole x32 range
1502/// closes that path; the crate does not otherwise support x32. The fragment
1503/// falls through to seccompiler's program (which reloads the arch word) when
1504/// the arch is not x86_64, or when the bit is clear:
1505///
1506/// ```text
1507/// [0] A = seccomp_data.arch
1508/// [1] if A != AUDIT_ARCH_X86_64: jump past the guard
1509/// [2] A = seccomp_data.nr
1510/// [3] if A & __X32_SYSCALL_BIT == 0: jump past the guard
1511/// [4] return `deny`
1512/// ```
1513fn x32_deny_prologue(deny: SeccompAction) -> [SockFilter; 5] {
1514 let ret = seccomp_ret_value(deny);
1515 [
1516 SockFilter {
1517 code: bpf::LD_W_ABS,
1518 jt: 0,
1519 jf: 0,
1520 k: bpf::OFF_ARCH,
1521 },
1522 SockFilter {
1523 code: bpf::JMP_JEQ_K,
1524 jt: 0,
1525 jf: 3,
1526 k: AUDIT_ARCH_X86_64,
1527 },
1528 SockFilter {
1529 code: bpf::LD_W_ABS,
1530 jt: 0,
1531 jf: 0,
1532 k: bpf::OFF_NR,
1533 },
1534 SockFilter {
1535 code: bpf::JMP_JSET_K,
1536 jt: 0,
1537 jf: 1,
1538 k: X32_SYSCALL_BIT,
1539 },
1540 SockFilter {
1541 code: bpf::RET_K,
1542 jt: 0,
1543 jf: 0,
1544 k: ret,
1545 },
1546 ]
1547}
1548
1549/// Compiles a single-match-action filter: `listed_action` for the listed
1550/// syscalls (subject to any argument conditions), `default_action` for the
1551/// rest.
1552///
1553/// A syscall's argument conditions decide whether an invocation counts as
1554/// matched; a matched invocation takes the `listed_action`, an unmatched one
1555/// the `default_action`. So conditions refine which invocations of a listed
1556/// syscall the listed action governs, without any per-syscall action.
1557fn compile_filter(
1558 default_action: SeccompAction,
1559 listed_action: SeccompAction,
1560 rules: &[SyscallRule],
1561) -> Result<Vec<SockFilter>, ConfigError> {
1562 let arch = host_target_arch()?;
1563 // An unconditional listing matches the syscall regardless of its
1564 // arguments and dominates any conditioned listing of the same syscall, so
1565 // the two are gathered separately before the map is built: unconditional
1566 // syscalls map to an empty rule vector (the backend's match-anything
1567 // form), conditioned ones to a rule per condition set (an OR).
1568 let mut unconditional: BTreeSet<i64> = BTreeSet::new();
1569 let mut conditioned: BTreeMap<i64, Vec<seccompiler::SeccompRule>> = BTreeMap::new();
1570 for rule in rules {
1571 if rule.conditions.is_empty() {
1572 unconditional.insert(rule.syscall);
1573 continue;
1574 }
1575 let conditions = rule
1576 .conditions
1577 .iter()
1578 .map(|condition| condition.to_seccompiler())
1579 .collect::<Result<Vec<_>, _>>()?;
1580 let seccomp_rule = seccompiler::SeccompRule::new(conditions).map_err(|err| {
1581 ConfigError::SeccompInvalid {
1582 reason: err.to_string(),
1583 }
1584 })?;
1585 conditioned
1586 .entry(rule.syscall)
1587 .or_default()
1588 .push(seccomp_rule);
1589 }
1590 let mut map: BTreeMap<i64, Vec<seccompiler::SeccompRule>> = BTreeMap::new();
1591 for &syscall in &unconditional {
1592 map.insert(syscall, Vec::new());
1593 }
1594 for (syscall, seccomp_rules) in conditioned {
1595 if !unconditional.contains(&syscall) {
1596 map.insert(syscall, seccomp_rules);
1597 }
1598 }
1599 let filter = seccompiler::SeccompFilter::new(
1600 map,
1601 default_action.to_seccompiler(),
1602 listed_action.to_seccompiler(),
1603 arch,
1604 )
1605 .map_err(|err| ConfigError::SeccompInvalid {
1606 reason: err.to_string(),
1607 })?;
1608 let program: seccompiler::BpfProgram =
1609 filter
1610 .try_into()
1611 .map_err(
1612 |err: seccompiler::BackendError| ConfigError::SeccompInvalid {
1613 reason: err.to_string(),
1614 },
1615 )?;
1616 let compiled = program.into_iter().map(|insn| SockFilter {
1617 code: insn.code,
1618 jt: insn.jt,
1619 jf: insn.jf,
1620 k: insn.k,
1621 });
1622
1623 // On x86_64 a denylist (a permissive default) is bypassable through the x32
1624 // ABI: it shares AUDIT_ARCH_X86_64 but tags every syscall number with
1625 // __X32_SYSCALL_BIT, so a denied number matches no rule and falls through
1626 // to the allowing default. Prepend a guard denying the whole x32 range. An
1627 // allowlist needs none — an unmatched x32 number already takes its denying
1628 // default — and only x86_64 aliases arch and ABI this way. seccompiler's
1629 // per-syscall map cannot express the guard, so it is emitted as raw BPF
1630 // ahead of seccompiler's program, which reloads the arch word itself.
1631 let needs_x32_guard =
1632 matches!(arch, seccompiler::TargetArch::x86_64) && default_action.is_permissive();
1633 let program: Vec<SockFilter> = if needs_x32_guard {
1634 x32_deny_prologue(listed_action)
1635 .into_iter()
1636 .chain(compiled)
1637 .collect()
1638 } else {
1639 compiled.collect()
1640 };
1641 Ok(program)
1642}
1643
1644/// The seccomp target architecture for the host, or an error on an
1645/// architecture the compiler does not target.
1646fn host_target_arch() -> Result<seccompiler::TargetArch, ConfigError> {
1647 match std::env::consts::ARCH {
1648 "x86_64" => Ok(seccompiler::TargetArch::x86_64),
1649 "aarch64" => Ok(seccompiler::TargetArch::aarch64),
1650 "riscv64" => Ok(seccompiler::TargetArch::riscv64),
1651 other => Err(ConfigError::SeccompUnsupportedArch {
1652 arch: other.to_string(),
1653 }),
1654 }
1655}
1656
1657/// The serde adapter bridging the in-memory [`Request`] and a profile's
1658/// hardening representation.
1659///
1660/// The programmatic API works in the request's own terms — real
1661/// [`SeccompPolicy`] values (raw syscall numbers, the precompiled escape
1662/// hatch), [`FsAccess`] bitsets, and [`Capability`] values. A profile is
1663/// written in a reviewable form instead: `rwx` access strings, syscalls and
1664/// capabilities by name, and no place for a precompiled program. This module
1665/// converts between the two through a mirror struct, so [`Request`] stays the
1666/// single in-memory representation and serde is only an adapter, as the
1667/// `serde_os` adapters in [`crate::spec`] are for the builder's `OsString`
1668/// fields.
1669///
1670/// The `[hardening]` table this produces is:
1671///
1672/// ```toml
1673/// [hardening]
1674/// drop-caps = true # or: keep-caps = ["net-bind-service"]
1675/// seccomp = "curated" # or a [hardening.seccomp] deny/allow table
1676///
1677/// [[hardening.landlock-fs]]
1678/// access = "rx"
1679/// path = "/usr"
1680///
1681/// [[hardening.landlock-net]]
1682/// access = "c"
1683/// port = 443
1684/// ```
1685///
1686/// A seccomp allow- or denylist may narrow a syscall by its arguments through
1687/// an `allow-rule` or `deny-rule` array-of-tables, each naming a syscall and
1688/// the argument conditions its listed action requires:
1689///
1690/// ```toml
1691/// [hardening.seccomp]
1692/// allow = ["read", "write", "exit-group"]
1693///
1694/// [[hardening.seccomp.allow-rule]]
1695/// syscall = "ioctl"
1696/// [[hardening.seccomp.allow-rule.arg]]
1697/// index = 1
1698/// len = "dword"
1699/// op = "eq"
1700/// value = 21523 # TIOCGWINSZ
1701/// ```
1702///
1703/// Names resolve for the host architecture: syscall names through the
1704/// `syscalls` crate, capability names through [`Capability`]'s `FromStr`. An
1705/// unknown name is a deserialization error; a policy a profile cannot express
1706/// — a precompiled program, or rules with a custom action — is a
1707/// serialization error, never a silent omission.
1708#[cfg(feature = "serde")]
1709pub(crate) mod serde_request {
1710 use std::path::PathBuf;
1711
1712 use serde::de::Error as _;
1713 use serde::ser::Error as _;
1714 use serde::{Deserialize, Deserializer, Serialize, Serializer};
1715 use syscalls::Sysno;
1716
1717 use super::{
1718 Capability, CapabilityPosture, EPERM, FsAccess, Grant, NetAccess, NetGrant, Request,
1719 SeccompAction, SeccompArg, SeccompArgLen, SeccompCompare, SeccompPolicy, SeccompRules,
1720 };
1721
1722 /// The profile form of a hardening request: the `[hardening]` table.
1723 ///
1724 /// The two capability keys and the seccomp key precede the `landlock-fs`
1725 /// and `landlock-net` arrays so the serialized table is valid TOML in field
1726 /// order — a bare key after an array-of-tables header would bind to the
1727 /// wrong table, the same ordering constraint the top-level builder
1728 /// observes.
1729 #[derive(Serialize, Deserialize, Default)]
1730 #[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1731 struct HardeningProfile {
1732 /// Drop every capability. Mutually exclusive with `keep-caps`.
1733 #[serde(skip_serializing_if = "std::ops::Not::not")]
1734 drop_caps: bool,
1735 /// Keep only these capabilities, by name, dropping the rest. Mutually
1736 /// exclusive with `drop-caps`. An empty list is rejected rather than
1737 /// read as a drop-all, which `drop-caps` expresses.
1738 #[serde(skip_serializing_if = "Option::is_none")]
1739 keep_caps: Option<Vec<String>>,
1740 /// The seccomp policy, when one is configured.
1741 #[serde(skip_serializing_if = "Option::is_none")]
1742 seccomp: Option<SeccompProfile>,
1743 /// The Landlock filesystem grants.
1744 #[serde(skip_serializing_if = "Vec::is_empty")]
1745 landlock_fs: Vec<GrantProfile>,
1746 /// The Landlock network (TCP port) grants.
1747 #[serde(skip_serializing_if = "Vec::is_empty")]
1748 landlock_net: Vec<NetGrantProfile>,
1749 }
1750
1751 /// One Landlock filesystem grant in a profile: an `rwx` access string and a
1752 /// path.
1753 #[derive(Serialize, Deserialize)]
1754 #[serde(rename_all = "kebab-case", deny_unknown_fields)]
1755 struct GrantProfile {
1756 access: FsAccess,
1757 path: PathBuf,
1758 }
1759
1760 /// One Landlock network grant in a profile: a `bc` access string and a TCP
1761 /// port.
1762 #[derive(Serialize, Deserialize)]
1763 #[serde(rename_all = "kebab-case", deny_unknown_fields)]
1764 struct NetGrantProfile {
1765 access: NetAccess,
1766 port: u16,
1767 }
1768
1769 /// The seccomp policy in a profile. The precompiled-program escape hatch is
1770 /// deliberately absent: a profile is declarative and reviewable.
1771 ///
1772 /// Written either as the string `"curated"` or as a table naming syscalls
1773 /// to allow or deny. The two forms are different TOML types — a string and
1774 /// a table — so the (de)serialization is written by hand rather than
1775 /// derived: the derive for a mixed string-or-table shape (an untagged enum)
1776 /// would silently drop the `deny_unknown_fields` strictness the rest of the
1777 /// profile keeps.
1778 enum SeccompProfile {
1779 /// The curated denylist. Written as the string `"curated"`.
1780 Curated,
1781 /// An allow- or denylist, with optional per-syscall argument rules.
1782 Lists(SeccompLists),
1783 }
1784
1785 impl Serialize for SeccompProfile {
1786 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1787 match self {
1788 SeccompProfile::Curated => serializer.serialize_str("curated"),
1789 SeccompProfile::Lists(lists) => lists.serialize(serializer),
1790 }
1791 }
1792 }
1793
1794 impl<'de> Deserialize<'de> for SeccompProfile {
1795 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1796 use serde::de::{self, MapAccess, Visitor};
1797
1798 struct SeccompProfileVisitor;
1799
1800 impl<'de> Visitor<'de> for SeccompProfileVisitor {
1801 type Value = SeccompProfile;
1802
1803 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1804 f.write_str("the string \"curated\" or a seccomp allow/deny table")
1805 }
1806
1807 fn visit_str<E: de::Error>(self, value: &str) -> Result<SeccompProfile, E> {
1808 match value {
1809 "curated" => Ok(SeccompProfile::Curated),
1810 other => Err(E::custom(format!(
1811 "unknown seccomp policy {other:?}, expected \"curated\" or a table"
1812 ))),
1813 }
1814 }
1815
1816 fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<SeccompProfile, A::Error> {
1817 SeccompLists::deserialize(de::value::MapAccessDeserializer::new(map))
1818 .map(SeccompProfile::Lists)
1819 }
1820 }
1821
1822 deserializer.deserialize_any(SeccompProfileVisitor)
1823 }
1824 }
1825
1826 /// The table form of a seccomp policy: bare syscall names in `allow` or
1827 /// `deny`, and argument-conditioned entries in `allow-rule` or `deny-rule`.
1828 /// The allow and deny sides are mutually exclusive; a table must set one
1829 /// side.
1830 #[derive(Serialize, Deserialize, Default)]
1831 #[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1832 struct SeccompLists {
1833 #[serde(skip_serializing_if = "Vec::is_empty")]
1834 allow: Vec<String>,
1835 #[serde(skip_serializing_if = "Vec::is_empty")]
1836 allow_rule: Vec<RuleProfile>,
1837 #[serde(skip_serializing_if = "Vec::is_empty")]
1838 deny: Vec<String>,
1839 #[serde(skip_serializing_if = "Vec::is_empty")]
1840 deny_rule: Vec<RuleProfile>,
1841 }
1842
1843 /// One argument-conditioned syscall entry: a syscall name and the argument
1844 /// conditions its listed action requires (all of which must hold).
1845 #[derive(Serialize, Deserialize)]
1846 #[serde(rename_all = "kebab-case", deny_unknown_fields)]
1847 struct RuleProfile {
1848 syscall: String,
1849 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1850 arg: Vec<ArgProfile>,
1851 }
1852
1853 /// One argument condition in a profile: an argument index, the width to
1854 /// compare, the comparison, and a value. `mask` is set only for the
1855 /// `masked-eq` comparison. The width is required rather than defaulted,
1856 /// since neither width is a safe default.
1857 #[derive(Serialize, Deserialize)]
1858 #[serde(rename_all = "kebab-case", deny_unknown_fields)]
1859 struct ArgProfile {
1860 index: u8,
1861 len: ArgLenProfile,
1862 op: ArgOp,
1863 value: u64,
1864 #[serde(default, skip_serializing_if = "Option::is_none")]
1865 mask: Option<u64>,
1866 }
1867
1868 /// The argument width in a profile.
1869 #[derive(Serialize, Deserialize, Clone, Copy)]
1870 #[serde(rename_all = "kebab-case")]
1871 enum ArgLenProfile {
1872 Dword,
1873 Qword,
1874 }
1875
1876 /// The comparison operator in a profile. `MaskedEq` takes its mask from the
1877 /// entry's `mask` field.
1878 #[derive(Serialize, Deserialize, Clone, Copy)]
1879 #[serde(rename_all = "kebab-case")]
1880 enum ArgOp {
1881 Eq,
1882 Ne,
1883 Ge,
1884 Gt,
1885 Le,
1886 Lt,
1887 MaskedEq,
1888 }
1889
1890 /// Serializes a request through its profile form.
1891 pub(crate) fn serialize<S: serde::Serializer>(
1892 request: &Request,
1893 serializer: S,
1894 ) -> Result<S::Ok, S::Error> {
1895 HardeningProfile::from_request(request)
1896 .map_err(S::Error::custom)?
1897 .serialize(serializer)
1898 }
1899
1900 /// Deserializes a request from its profile form.
1901 pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>(
1902 deserializer: D,
1903 ) -> Result<Request, D::Error> {
1904 HardeningProfile::deserialize(deserializer)?
1905 .into_request()
1906 .map_err(D::Error::custom)
1907 }
1908
1909 impl HardeningProfile {
1910 /// Lowers the profile into the in-memory request, resolving capability
1911 /// names for the host and rejecting a contradictory capability posture.
1912 fn into_request(self) -> Result<Request, String> {
1913 let capabilities = match (self.drop_caps, self.keep_caps) {
1914 (true, Some(_)) => {
1915 return Err(
1916 "drop-caps and keep-caps cannot both be set in a hardening profile"
1917 .to_string(),
1918 );
1919 }
1920 (true, None) => CapabilityPosture::Reduce(Vec::new()),
1921 (false, Some(names)) if names.is_empty() => {
1922 return Err("keep-caps is empty; use drop-caps = true to drop every \
1923 capability"
1924 .to_string());
1925 }
1926 (false, Some(names)) => CapabilityPosture::Reduce(
1927 names
1928 .iter()
1929 .map(|name| name.parse::<Capability>().map_err(|err| err.to_string()))
1930 .collect::<Result<Vec<_>, _>>()?,
1931 ),
1932 (false, None) => CapabilityPosture::Keep,
1933 };
1934
1935 let seccomp = self.seccomp.map(SeccompProfile::into_policy).transpose()?;
1936
1937 let landlock = self
1938 .landlock_fs
1939 .into_iter()
1940 .map(|grant| Grant {
1941 access: grant.access,
1942 path: grant.path,
1943 })
1944 .collect();
1945
1946 let landlock_net = self
1947 .landlock_net
1948 .into_iter()
1949 .map(|grant| NetGrant {
1950 access: grant.access,
1951 port: grant.port,
1952 })
1953 .collect();
1954
1955 Ok(Request {
1956 landlock,
1957 landlock_net,
1958 seccomp,
1959 capabilities,
1960 })
1961 }
1962
1963 /// Raises the in-memory request into its profile form, rejecting the
1964 /// parts a profile cannot represent.
1965 fn from_request(request: &Request) -> Result<HardeningProfile, String> {
1966 let (drop_caps, keep_caps) = match &request.capabilities {
1967 CapabilityPosture::Keep => (false, None),
1968 CapabilityPosture::Reduce(caps) if caps.is_empty() => (true, None),
1969 CapabilityPosture::Reduce(caps) => (
1970 false,
1971 Some(caps.iter().map(|cap| cap.to_string()).collect()),
1972 ),
1973 };
1974
1975 let seccomp = request
1976 .seccomp
1977 .as_ref()
1978 .map(SeccompProfile::from_policy)
1979 .transpose()?;
1980
1981 let landlock_fs = request
1982 .landlock
1983 .iter()
1984 .map(|grant| GrantProfile {
1985 access: grant.access,
1986 path: grant.path.clone(),
1987 })
1988 .collect();
1989
1990 let landlock_net = request
1991 .landlock_net
1992 .iter()
1993 .map(|grant| NetGrantProfile {
1994 access: grant.access,
1995 port: grant.port,
1996 })
1997 .collect();
1998
1999 Ok(HardeningProfile {
2000 drop_caps,
2001 keep_caps,
2002 seccomp,
2003 landlock_fs,
2004 landlock_net,
2005 })
2006 }
2007 }
2008
2009 impl SeccompProfile {
2010 /// Resolves the profile's seccomp policy to a [`SeccompPolicy`],
2011 /// translating syscall names to numbers for the host architecture.
2012 fn into_policy(self) -> Result<SeccompPolicy, String> {
2013 let lists = match self {
2014 SeccompProfile::Curated => return Ok(SeccompPolicy::Curated),
2015 SeccompProfile::Lists(lists) => lists,
2016 };
2017 Ok(SeccompPolicy::Rules(lists.into_rules()?))
2018 }
2019
2020 /// Raises a [`SeccompPolicy`] into its profile form, rejecting a
2021 /// precompiled program and any rules a profile cannot express — a
2022 /// custom action, or a shape that is neither a plain allow- nor
2023 /// denylist.
2024 fn from_policy(policy: &SeccompPolicy) -> Result<SeccompProfile, String> {
2025 match policy {
2026 SeccompPolicy::Curated => Ok(SeccompProfile::Curated),
2027 SeccompPolicy::Rules(rules) => {
2028 Ok(SeccompProfile::Lists(SeccompLists::from_rules(rules)?))
2029 }
2030 SeccompPolicy::Program(_) => Err(
2031 "a precompiled seccomp program cannot be represented in a profile".to_string(),
2032 ),
2033 }
2034 }
2035 }
2036
2037 impl SeccompLists {
2038 /// Lowers the table into a [`SeccompRules`], choosing the allow or deny
2039 /// side and attaching each entry's argument conditions. A table that
2040 /// sets both sides, or neither, is rejected.
2041 fn into_rules(self) -> Result<SeccompRules, String> {
2042 let allow_side = !self.allow.is_empty() || !self.allow_rule.is_empty();
2043 let deny_side = !self.deny.is_empty() || !self.deny_rule.is_empty();
2044 let (mut rules, rule_profiles) = match (allow_side, deny_side) {
2045 (true, true) => {
2046 return Err(
2047 "a seccomp profile cannot set both an allow and a deny side".to_string()
2048 );
2049 }
2050 (false, false) => {
2051 return Err("a seccomp table lists no syscalls; set allow, allow-rule, \
2052 deny, or deny-rule"
2053 .to_string());
2054 }
2055 (true, false) => (
2056 SeccompRules::allowing(resolve_syscalls(&self.allow)?),
2057 self.allow_rule,
2058 ),
2059 (false, true) => (
2060 SeccompRules::denying(resolve_syscalls(&self.deny)?),
2061 self.deny_rule,
2062 ),
2063 };
2064 for profile in rule_profiles {
2065 let syscall = resolve_syscall(&profile.syscall)?;
2066 let conditions = profile
2067 .arg
2068 .iter()
2069 .map(ArgProfile::to_arg)
2070 .collect::<Result<Vec<_>, _>>()?;
2071 rules = rules.rule(syscall, conditions);
2072 }
2073 Ok(rules)
2074 }
2075
2076 /// Raises a [`SeccompRules`] into its table form, partitioning the
2077 /// listed syscalls into bare names and argument-conditioned entries.
2078 /// Rejects a policy with a custom action, which a profile cannot name.
2079 ///
2080 /// A rule set listing no syscall at all is rejected here for the same
2081 /// reason. The table tells its allow side from its deny side by which
2082 /// of them is non-empty, so a policy with neither has no table that
2083 /// says which it was — it renders as a bare `[hardening.seccomp]` that
2084 /// [`into_rules`](SeccompLists::into_rules) then refuses. Reporting it
2085 /// on the way out is what keeps the promise that a policy a profile
2086 /// cannot express is a serialization error rather than a document that
2087 /// will not read back.
2088 fn from_rules(rules: &SeccompRules) -> Result<SeccompLists, String> {
2089 if rules.rules.is_empty() {
2090 return Err(
2091 "a seccomp policy listing no syscall cannot be represented in a \
2092 profile: the table names its side by listing one"
2093 .to_string(),
2094 );
2095 }
2096 let mut bare: Vec<String> = Vec::new();
2097 let mut rule_profiles: Vec<RuleProfile> = Vec::new();
2098 for rule in &rules.rules {
2099 let syscall = name_syscall(rule.syscall)?;
2100 if rule.conditions.is_empty() {
2101 bare.push(syscall);
2102 } else {
2103 rule_profiles.push(RuleProfile {
2104 syscall,
2105 arg: rule.conditions.iter().map(ArgProfile::from_arg).collect(),
2106 });
2107 }
2108 }
2109 match (rules.default_action, rules.listed_action) {
2110 (SeccompAction::Allow, SeccompAction::Errno(EPERM)) => Ok(SeccompLists {
2111 deny: bare,
2112 deny_rule: rule_profiles,
2113 ..SeccompLists::default()
2114 }),
2115 (SeccompAction::Errno(EPERM), SeccompAction::Allow) => Ok(SeccompLists {
2116 allow: bare,
2117 allow_rule: rule_profiles,
2118 ..SeccompLists::default()
2119 }),
2120 _ => Err(
2121 "a seccomp policy with custom actions cannot be represented in a profile"
2122 .to_string(),
2123 ),
2124 }
2125 }
2126 }
2127
2128 impl ArgProfile {
2129 /// Lowers one profile argument condition to a [`SeccompArg`], checking
2130 /// that `mask` is present exactly when the comparison is `masked-eq`.
2131 fn to_arg(&self) -> Result<SeccompArg, String> {
2132 let len = match self.len {
2133 ArgLenProfile::Dword => SeccompArgLen::Dword,
2134 ArgLenProfile::Qword => SeccompArgLen::Qword,
2135 };
2136 let compare = match self.op {
2137 ArgOp::MaskedEq => {
2138 let mask = self
2139 .mask
2140 .ok_or_else(|| "op = \"masked-eq\" requires a mask".to_string())?;
2141 SeccompCompare::MaskedEq(mask)
2142 }
2143 other => {
2144 if self.mask.is_some() {
2145 return Err("mask is only valid with op = \"masked-eq\"".to_string());
2146 }
2147 match other {
2148 ArgOp::Eq => SeccompCompare::Eq,
2149 ArgOp::Ne => SeccompCompare::Ne,
2150 ArgOp::Ge => SeccompCompare::Ge,
2151 ArgOp::Gt => SeccompCompare::Gt,
2152 ArgOp::Le => SeccompCompare::Le,
2153 ArgOp::Lt => SeccompCompare::Lt,
2154 ArgOp::MaskedEq => unreachable!("masked-eq handled above"),
2155 }
2156 }
2157 };
2158 Ok(SeccompArg::new(self.index, len, compare, self.value))
2159 }
2160
2161 /// Raises one [`SeccompArg`] into its profile form.
2162 fn from_arg(arg: &SeccompArg) -> ArgProfile {
2163 let len = match arg.len {
2164 SeccompArgLen::Dword => ArgLenProfile::Dword,
2165 SeccompArgLen::Qword => ArgLenProfile::Qword,
2166 };
2167 let (op, mask) = match arg.compare {
2168 SeccompCompare::Eq => (ArgOp::Eq, None),
2169 SeccompCompare::Ne => (ArgOp::Ne, None),
2170 SeccompCompare::Ge => (ArgOp::Ge, None),
2171 SeccompCompare::Gt => (ArgOp::Gt, None),
2172 SeccompCompare::Le => (ArgOp::Le, None),
2173 SeccompCompare::Lt => (ArgOp::Lt, None),
2174 SeccompCompare::MaskedEq(mask) => (ArgOp::MaskedEq, Some(mask)),
2175 };
2176 ArgProfile {
2177 index: arg.index,
2178 len,
2179 op,
2180 value: arg.value,
2181 mask,
2182 }
2183 }
2184 }
2185
2186 /// Resolves syscall names to their numbers for the host architecture.
2187 fn resolve_syscalls(names: &[String]) -> Result<Vec<i64>, String> {
2188 names.iter().map(|name| resolve_syscall(name)).collect()
2189 }
2190
2191 /// Resolves one syscall name to its number for the host architecture.
2192 fn resolve_syscall(name: &str) -> Result<i64, String> {
2193 name.parse::<Sysno>()
2194 .map(|sysno| i64::from(sysno.id()))
2195 .map_err(|_| format!("unknown syscall {name:?}"))
2196 }
2197
2198 /// Names one syscall number for the host architecture, for serialization.
2199 fn name_syscall(number: i64) -> Result<String, String> {
2200 usize::try_from(number)
2201 .ok()
2202 .and_then(Sysno::new)
2203 .map(|sysno| sysno.name().to_string())
2204 .ok_or_else(|| format!("syscall number {number} has no name on this architecture"))
2205 }
2206}
2207
2208#[cfg(test)]
2209mod tests {
2210 use super::*;
2211
2212 #[test]
2213 fn fs_access_composes_and_contains() {
2214 let rx = FsAccess::READ | FsAccess::EXECUTE;
2215 assert!(rx.contains(FsAccess::READ));
2216 assert!(rx.contains(FsAccess::EXECUTE));
2217 assert!(!rx.contains(FsAccess::WRITE));
2218 }
2219
2220 #[test]
2221 fn fs_access_bit_operations() {
2222 assert!(FsAccess::empty().is_empty());
2223 assert_eq!(FsAccess::default(), FsAccess::empty());
2224 assert_eq!(
2225 FsAccess::all(),
2226 FsAccess::READ | FsAccess::WRITE | FsAccess::EXECUTE
2227 );
2228
2229 let mut set = FsAccess::READ;
2230 set |= FsAccess::WRITE;
2231 assert!(set.contains(FsAccess::WRITE));
2232
2233 // Masking keeps only the shared rights.
2234 assert_eq!(set & FsAccess::EXECUTE, FsAccess::empty());
2235 assert_eq!(set & FsAccess::READ, FsAccess::READ);
2236 set &= FsAccess::READ;
2237 assert_eq!(set, FsAccess::READ);
2238
2239 // Complement stays within the modeled rights.
2240 assert_eq!(!FsAccess::WRITE, FsAccess::READ | FsAccess::EXECUTE);
2241 assert_eq!(!FsAccess::all(), FsAccess::empty());
2242 assert_eq!(!FsAccess::empty(), FsAccess::all());
2243 }
2244
2245 #[test]
2246 fn read_lowers_to_file_and_dir_reads() {
2247 let bits = FsAccess::READ.to_landlock();
2248 // Read maps to the file and directory read rights, plus device ioctls
2249 // (see `a_read_or_write_grant_allows_device_ioctls`).
2250 assert_eq!(
2251 bits,
2252 fs_access::READ_FILE | fs_access::READ_DIR | fs_access::IOCTL_DEV
2253 );
2254 }
2255
2256 #[test]
2257 fn fs_access_displays_and_parses_in_rwx_order() {
2258 assert_eq!(FsAccess::empty().to_string(), "");
2259 assert_eq!(FsAccess::READ.to_string(), "r");
2260 assert_eq!((FsAccess::READ | FsAccess::EXECUTE).to_string(), "rx");
2261 assert_eq!(FsAccess::all().to_string(), "rwx");
2262 // Display and FromStr round-trip across every subset.
2263 for access in [
2264 FsAccess::empty(),
2265 FsAccess::READ,
2266 FsAccess::WRITE,
2267 FsAccess::EXECUTE,
2268 FsAccess::READ | FsAccess::WRITE,
2269 FsAccess::READ | FsAccess::EXECUTE,
2270 FsAccess::WRITE | FsAccess::EXECUTE,
2271 FsAccess::all(),
2272 ] {
2273 assert_eq!(access.to_string().parse::<FsAccess>(), Ok(access));
2274 }
2275 }
2276
2277 #[test]
2278 fn fs_access_parse_is_order_and_case_insensitive() {
2279 let rwx = FsAccess::all();
2280 assert_eq!("xwr".parse::<FsAccess>(), Ok(rwx));
2281 assert_eq!("RWX".parse::<FsAccess>(), Ok(rwx));
2282 }
2283
2284 #[test]
2285 fn fs_access_parse_rejects_unknown_and_repeated_letters() {
2286 let err = "rq".parse::<FsAccess>().unwrap_err();
2287 assert_eq!(err.text(), "rq");
2288 assert!(err.to_string().contains("r, w, and x"));
2289 assert!("rr".parse::<FsAccess>().is_err());
2290 }
2291
2292 #[test]
2293 fn net_access_composes_and_contains() {
2294 let bc = NetAccess::BIND | NetAccess::CONNECT;
2295 assert!(bc.contains(NetAccess::BIND));
2296 assert!(bc.contains(NetAccess::CONNECT));
2297 assert_eq!(NetAccess::all(), bc);
2298
2299 let mut set = NetAccess::BIND;
2300 set |= NetAccess::CONNECT;
2301 assert!(set.contains(NetAccess::CONNECT));
2302
2303 // Masking keeps only the shared rights; complement stays in the set.
2304 assert_eq!(set & NetAccess::BIND, NetAccess::BIND);
2305 set &= NetAccess::BIND;
2306 assert_eq!(set, NetAccess::BIND);
2307 assert_eq!(!NetAccess::BIND, NetAccess::CONNECT);
2308 assert_eq!(!NetAccess::all(), NetAccess::empty());
2309 assert_eq!(!NetAccess::empty(), NetAccess::all());
2310 assert!(NetAccess::empty().is_empty());
2311 assert_eq!(NetAccess::default(), NetAccess::empty());
2312 }
2313
2314 #[test]
2315 fn net_access_lowers_to_kernel_rights() {
2316 assert_eq!(NetAccess::BIND.to_landlock(), net_access::BIND_TCP);
2317 assert_eq!(NetAccess::CONNECT.to_landlock(), net_access::CONNECT_TCP);
2318 assert_eq!(NetAccess::all().to_landlock(), net_access::ALL);
2319 }
2320
2321 #[test]
2322 fn net_access_displays_and_parses_in_bc_order() {
2323 assert_eq!(NetAccess::empty().to_string(), "");
2324 assert_eq!(NetAccess::BIND.to_string(), "b");
2325 assert_eq!(NetAccess::CONNECT.to_string(), "c");
2326 assert_eq!(NetAccess::all().to_string(), "bc");
2327 // Display and FromStr round-trip across every subset.
2328 for access in [
2329 NetAccess::empty(),
2330 NetAccess::BIND,
2331 NetAccess::CONNECT,
2332 NetAccess::all(),
2333 ] {
2334 assert_eq!(access.to_string().parse::<NetAccess>(), Ok(access));
2335 }
2336 // Order- and case-insensitive, like FsAccess.
2337 assert_eq!("cb".parse::<NetAccess>(), Ok(NetAccess::all()));
2338 assert_eq!("BC".parse::<NetAccess>(), Ok(NetAccess::all()));
2339 }
2340
2341 #[test]
2342 fn net_access_parse_rejects_unknown_and_repeated_letters() {
2343 let err = "bz".parse::<NetAccess>().unwrap_err();
2344 assert_eq!(err.text(), "bz");
2345 assert!(err.to_string().contains("b and c"));
2346 assert!("bb".parse::<NetAccess>().is_err());
2347 }
2348
2349 #[test]
2350 fn net_abi_mask_narrows_by_version() {
2351 // Network rights arrive together in ABI 4; nothing below it.
2352 assert_eq!(net_access_for_abi(1), 0);
2353 assert_eq!(net_access_for_abi(3), 0);
2354 assert_eq!(net_access_for_abi(4), net_access::ALL);
2355 assert_eq!(net_access_for_abi(5), net_access::ALL);
2356 }
2357
2358 #[test]
2359 fn net_grant_masked_away_reflects_the_abi() {
2360 // A network grant is masked away below ABI 4, but enforceable at ABI 4
2361 // and above.
2362 assert!(net_grant_masked_away(net_access::ALL, 3));
2363 assert!(!net_grant_masked_away(net_access::ALL, 4));
2364 // No network grant requested (zero mask) drops nothing.
2365 assert!(!net_grant_masked_away(0, 3));
2366 // The judgement is per access kind: a surviving filesystem grant at the
2367 // same ABI must not mask a dropped network grant, or a configured
2368 // network boundary would silently vanish (H2).
2369 assert!(net_grant_masked_away(net_access::ALL, 1));
2370 }
2371
2372 #[test]
2373 fn fs_grant_masked_away_reflects_the_abi() {
2374 // A filesystem grant keeps its ABI-1 base rights, so it is never masked
2375 // away on any Landlock kernel.
2376 assert!(!fs_grant_masked_away(fs_access::ALL, 1));
2377 // No filesystem grant requested (zero mask) drops nothing.
2378 assert!(!fs_grant_masked_away(0, 1));
2379 }
2380
2381 #[test]
2382 fn net_grants_lower_to_rules_and_the_handled_set() {
2383 let mut request = Request::default();
2384 request.grant_net(NetAccess::BIND, 8080);
2385 request.grant_net(NetAccess::CONNECT, 443);
2386 let plan = request.lower().unwrap();
2387 assert_eq!(plan.landlock_net.len(), 2);
2388 assert_eq!(plan.landlock_net[0].port, 8080);
2389 assert_eq!(plan.landlock_net[0].access, net_access::BIND_TCP);
2390 assert_eq!(plan.landlock_net[1].port, 443);
2391 assert_eq!(plan.landlock_net[1].access, net_access::CONNECT_TCP);
2392 // Any network grant enrolls the full modeled network set, so an
2393 // ungranted right is denied rather than left unrestricted.
2394 assert_eq!(plan.landlock_net_handled, net_access::ALL);
2395 // Filesystem rules and their handled set stay empty.
2396 assert!(plan.landlock.is_empty());
2397 assert_eq!(plan.landlock_handled, 0);
2398 }
2399
2400 #[test]
2401 fn a_net_only_request_is_restricting() {
2402 let mut request = Request::default();
2403 request.grant_net(NetAccess::BIND, 0);
2404 assert!(!request.is_unrestricted());
2405 }
2406
2407 #[test]
2408 fn abi_mask_narrows_by_version() {
2409 // v1 excludes REFER, TRUNCATE, and IOCTL_DEV.
2410 let v1 = fs_access_for_abi(1);
2411 assert_eq!(v1 & fs_access::REFER, 0);
2412 assert_eq!(v1 & fs_access::TRUNCATE, 0);
2413 assert_eq!(v1 & fs_access::IOCTL_DEV, 0);
2414 // v3 gains REFER and TRUNCATE but not IOCTL_DEV.
2415 let v3 = fs_access_for_abi(3);
2416 assert_ne!(v3 & fs_access::REFER, 0);
2417 assert_ne!(v3 & fs_access::TRUNCATE, 0);
2418 assert_eq!(v3 & fs_access::IOCTL_DEV, 0);
2419 // v5 is the full set.
2420 assert_eq!(fs_access_for_abi(5), fs_access::ALL);
2421 }
2422
2423 #[test]
2424 fn the_curated_denylist_covers_each_category_it_claims() {
2425 use syscalls::Sysno;
2426
2427 // The roster is not exhaustive, but it grows only additively, so one
2428 // representative of each category it does cover must stay in it.
2429 let list = curated_denylist();
2430 for sysno in [
2431 // Filesystem topology, including the new-mount API in full and the
2432 // file-handle pair that reaches an inode without a path.
2433 Sysno::mount,
2434 Sysno::pivot_root,
2435 Sysno::open_tree,
2436 Sysno::fsopen,
2437 Sysno::fsconfig,
2438 Sysno::fsmount,
2439 Sysno::fspick,
2440 Sysno::move_mount,
2441 Sysno::name_to_handle_at,
2442 Sysno::open_by_handle_at,
2443 // Kernel modules, the keyring, and the administrative surfaces.
2444 Sysno::init_module,
2445 Sysno::keyctl,
2446 Sysno::setns,
2447 // Tracing, cross-process memory, and the exploit primitives beside
2448 // them.
2449 Sysno::ptrace,
2450 Sysno::process_vm_readv,
2451 Sysno::perf_event_open,
2452 Sysno::bpf,
2453 Sysno::userfaultfd,
2454 // Execution domain, and the asynchronous submission ring a filter
2455 // cannot see through.
2456 Sysno::personality,
2457 Sysno::io_uring_setup,
2458 // The clock.
2459 Sysno::clock_settime,
2460 ] {
2461 assert!(
2462 list.contains(&(sysno.id() as i64)),
2463 "{sysno} left the curated denylist",
2464 );
2465 }
2466
2467 #[cfg(target_arch = "x86_64")]
2468 assert!(list.contains(&(Sysno::modify_ldt.id() as i64)));
2469 }
2470
2471 #[test]
2472 fn curated_profile_compiles() {
2473 let program = compile_seccomp(&SeccompPolicy::Curated).unwrap();
2474 assert!(!program.is_empty());
2475 }
2476
2477 #[test]
2478 fn the_curated_denylist_denies_the_terminal_injection_ioctls_by_request() {
2479 use syscalls::Sysno;
2480
2481 // `ioctl` is denied by request, not outright: a sandboxed command asks
2482 // devices for things the ordinary way, and only the two requests that
2483 // write to a terminal's input queue are refused.
2484 let ioctl = Sysno::ioctl.id() as i64;
2485 assert!(
2486 !curated_denylist().contains(&ioctl),
2487 "ioctl is denied by request, so it must not be listed unconditionally",
2488 );
2489 let rules = curated_ioctl_rules();
2490 assert!(rules.iter().all(|rule| rule.syscall == ioctl));
2491 let requests: Vec<u64> = rules
2492 .iter()
2493 .flat_map(|rule| rule.conditions.iter())
2494 .map(|arg| arg.value)
2495 .collect();
2496 assert_eq!(requests, [TIOCSTI, TIOCLINUX]);
2497 // The low 32 bits alone: the kernel takes the request as an
2498 // `unsigned int`, so a 64-bit comparison would miss a request carrying
2499 // anything above bit 31 that is nonetheless dispatched as this one.
2500 for arg in rules.iter().flat_map(|rule| rule.conditions.iter()) {
2501 assert_eq!(arg.index, 1);
2502 assert!(matches!(arg.len, SeccompArgLen::Dword));
2503 assert!(matches!(arg.compare, SeccompCompare::Eq));
2504 }
2505 }
2506
2507 #[test]
2508 #[cfg(target_arch = "x86_64")]
2509 fn a_denylist_is_prefixed_with_the_x32_guard_on_x86_64() {
2510 // A denylist (permissive default) is bypassable through the x32 ABI, so
2511 // the compiled program must begin with the guard denying that range.
2512 let program = compile_seccomp(&SeccompPolicy::Curated).unwrap();
2513 let guard = x32_deny_prologue(SeccompAction::Errno(EPERM));
2514 assert!(program.len() > guard.len());
2515 assert_eq!(&program[..guard.len()], &guard[..]);
2516 }
2517
2518 #[test]
2519 fn the_x32_guard_emits_the_expected_instructions() {
2520 // Five hand-emitted instructions with hand-computed jump offsets: the
2521 // one raw-BPF fragment in the crate, and the one place a wrong constant
2522 // would silently disable a guard rather than fail. Assert them exactly.
2523 //
2524 // Both false branches must land on instruction 5, the first of
2525 // seccompiler's own program: from [1] that is 1 + 1 + jf(3) = 5, and
2526 // from [3] it is 3 + 1 + jf(1) = 5.
2527 let guard = x32_deny_prologue(SeccompAction::Errno(EPERM));
2528 assert_eq!(
2529 guard,
2530 [
2531 // [0] A = seccomp_data.arch
2532 SockFilter {
2533 code: 0x20,
2534 jt: 0,
2535 jf: 0,
2536 k: 4,
2537 },
2538 // [1] if A != AUDIT_ARCH_X86_64: jump past the guard
2539 SockFilter {
2540 code: 0x15,
2541 jt: 0,
2542 jf: 3,
2543 k: 0xC000_003E,
2544 },
2545 // [2] A = seccomp_data.nr
2546 SockFilter {
2547 code: 0x20,
2548 jt: 0,
2549 jf: 0,
2550 k: 0,
2551 },
2552 // [3] if A & __X32_SYSCALL_BIT == 0: jump past the guard
2553 SockFilter {
2554 code: 0x45,
2555 jt: 0,
2556 jf: 1,
2557 k: 0x4000_0000,
2558 },
2559 // [4] return EPERM
2560 SockFilter {
2561 code: 0x06,
2562 jt: 0,
2563 jf: 0,
2564 k: 0x0005_0000 | u32::from(EPERM),
2565 },
2566 ],
2567 );
2568
2569 // The returned action is the caller's, not a constant.
2570 assert_eq!(
2571 x32_deny_prologue(SeccompAction::KillProcess)[4].k,
2572 0x8000_0000,
2573 );
2574 }
2575
2576 #[test]
2577 #[cfg(target_arch = "x86_64")]
2578 fn an_allowlist_has_no_x32_guard() {
2579 // An allowlist's denying default already refuses an unmatched x32
2580 // number, so no guard is prepended.
2581 let program = compile_seccomp(&SeccompPolicy::Rules(SeccompRules::allowing([
2582 libc::SYS_read,
2583 ])))
2584 .unwrap();
2585 assert!(
2586 !program
2587 .iter()
2588 .any(|insn| insn.code == bpf::JMP_JSET_K && insn.k == X32_SYSCALL_BIT)
2589 );
2590 }
2591
2592 #[test]
2593 fn seccomp_policies_report_whether_they_restrict() {
2594 assert!(SeccompPolicy::Curated.restricts());
2595 assert!(SeccompPolicy::Rules(SeccompRules::denying([libc::SYS_ptrace])).restricts());
2596 // An allowlist denies every unlisted syscall, so it restricts even when
2597 // it lists nothing.
2598 assert!(SeccompPolicy::Rules(SeccompRules::allowing([])).restricts());
2599 // An empty denylist denies nothing.
2600 assert!(!SeccompPolicy::Rules(SeccompRules::denying([])).restricts());
2601 // A denylist whose listed action still allows denies nothing.
2602 assert!(
2603 !SeccompPolicy::Rules(
2604 SeccompRules::denying([libc::SYS_ptrace]).listed_action(SeccompAction::Allow)
2605 )
2606 .restricts()
2607 );
2608 }
2609
2610 #[test]
2611 fn a_no_op_seccomp_policy_does_not_save_a_restriction_from_the_empty_guard() {
2612 // A filter that denies nothing leaves a restriction unconfined, so it
2613 // must not count as a restriction.
2614 let mut empty = Request::default();
2615 empty.set_seccomp(SeccompPolicy::Rules(SeccompRules::denying([])));
2616 assert!(empty.is_unrestricted());
2617 // The curated policy, which does deny, counts.
2618 let mut curated = Request::default();
2619 curated.set_seccomp(SeccompPolicy::Curated);
2620 assert!(!curated.is_unrestricted());
2621 }
2622
2623 #[test]
2624 fn a_read_or_write_grant_allows_device_ioctls() {
2625 // IOCTL_DEV is in the handled set, so it must be granted with read or
2626 // write access or a granted device path would deny all driver ioctls.
2627 assert_ne!(FsAccess::READ.to_landlock() & fs_access::IOCTL_DEV, 0);
2628 assert_ne!(FsAccess::WRITE.to_landlock() & fs_access::IOCTL_DEV, 0);
2629 // An execute-only grant does not, so device ioctls stay denied there.
2630 assert_eq!(FsAccess::EXECUTE.to_landlock() & fs_access::IOCTL_DEV, 0);
2631 }
2632
2633 #[test]
2634 fn argument_conditioned_rules_compile() {
2635 // An allowlist that permits ioctl only for a specific request, plus a
2636 // masked-eq condition, compiles to a non-empty program.
2637 let rules = SeccompRules::allowing([libc::SYS_read, libc::SYS_write])
2638 .rule(libc::SYS_ioctl, [SeccompArg::eq_dword(1, 0x5413)])
2639 .rule(
2640 libc::SYS_clone,
2641 [SeccompArg::masked_eq_qword(0, 0x1000_0000, 0)],
2642 );
2643 let program = compile_seccomp(&SeccompPolicy::Rules(rules)).unwrap();
2644 assert!(!program.is_empty());
2645 }
2646
2647 #[test]
2648 fn an_unconditional_listing_dominates_a_condition() {
2649 // A syscall listed both unconditionally and with a condition matches
2650 // unconditionally: the compiled program equals the one from the plain
2651 // listing alone, and the extra condition changes nothing.
2652 let plain = compile_seccomp(&SeccompPolicy::Rules(SeccompRules::allowing([
2653 libc::SYS_ioctl,
2654 ])))
2655 .unwrap();
2656 let with_redundant_condition = compile_seccomp(&SeccompPolicy::Rules(
2657 SeccompRules::allowing([libc::SYS_ioctl])
2658 .rule(libc::SYS_ioctl, [SeccompArg::eq_dword(1, 0x5413)]),
2659 ))
2660 .unwrap();
2661 assert_eq!(plain, with_redundant_condition);
2662 }
2663
2664 #[test]
2665 fn an_out_of_range_argument_index_is_rejected() {
2666 // A syscall has six argument registers (indices 0 through 5); an index
2667 // past them is rejected when the condition is lowered.
2668 let rules = SeccompRules::allowing([libc::SYS_read])
2669 .rule(libc::SYS_ioctl, [SeccompArg::eq_dword(6, 0)]);
2670 assert!(matches!(
2671 compile_seccomp(&SeccompPolicy::Rules(rules)),
2672 Err(ConfigError::SeccompInvalid { .. })
2673 ));
2674 }
2675
2676 #[test]
2677 fn an_over_long_program_is_rejected() {
2678 // A precompiled escape-hatch program longer than the kernel ceiling is
2679 // rejected at build time, so its length cannot wrap when narrowed to a
2680 // u16 for the kernel's sock_fprog.
2681 let instruction = SockFilter {
2682 code: 0,
2683 jt: 0,
2684 jf: 0,
2685 k: 0,
2686 };
2687 let program = vec![instruction; BPF_MAXINSNS + 1];
2688 let mut request = Request::default();
2689 request.set_seccomp(SeccompPolicy::Program(program));
2690 assert!(matches!(
2691 request.lower(),
2692 Err(ConfigError::SeccompProgramTooLong {
2693 len,
2694 max: BPF_MAXINSNS,
2695 }) if len == BPF_MAXINSNS + 1
2696 ));
2697 }
2698
2699 #[test]
2700 fn a_maximal_program_is_accepted() {
2701 // Exactly BPF_MAXINSNS instructions is at the boundary and allowed.
2702 let instruction = SockFilter {
2703 code: 0,
2704 jt: 0,
2705 jf: 0,
2706 k: 0,
2707 };
2708 let program = vec![instruction; BPF_MAXINSNS];
2709 assert_eq!(
2710 compile_seccomp(&SeccompPolicy::Program(program))
2711 .unwrap()
2712 .len(),
2713 BPF_MAXINSNS,
2714 );
2715 }
2716
2717 #[test]
2718 fn capability_names_round_trip() {
2719 for &cap in Capability::ALL {
2720 // Display produces the canonical name, which parses back.
2721 let name = cap.to_string();
2722 assert_eq!(name.parse::<Capability>(), Ok(cap));
2723 // The snake form, the cap- prefix, and upper case all parse too.
2724 let snake = name.replace('-', "_");
2725 assert_eq!(snake.parse::<Capability>(), Ok(cap));
2726 assert_eq!(format!("CAP_{snake}").parse::<Capability>(), Ok(cap));
2727 assert_eq!(name.to_uppercase().parse::<Capability>(), Ok(cap));
2728 }
2729 }
2730
2731 #[test]
2732 fn unknown_capability_name_is_rejected() {
2733 let err = "not-a-cap".parse::<Capability>().unwrap_err();
2734 assert_eq!(err.text(), "not-a-cap");
2735 assert!(err.to_string().contains("not-a-cap"));
2736 }
2737
2738 /// `SeccompPolicy` is `Clone`, and stays so.
2739 ///
2740 /// It was a hand-written impl and is now a derive, which the committed API
2741 /// snapshot omits as noise along with every other derived impl in the
2742 /// crate. Losing the trait would therefore no longer show up in that diff,
2743 /// so it is pinned here instead — where the type is, rather than in the
2744 /// snapshot the change moved it out of.
2745 #[test]
2746 fn a_seccomp_policy_clones() {
2747 let program =
2748 compile_seccomp(&SeccompPolicy::Curated).expect("the curated policy compiles");
2749 for policy in [
2750 SeccompPolicy::Curated,
2751 SeccompPolicy::Rules(SeccompRules::denying([libc::SYS_ptrace])),
2752 SeccompPolicy::Program(program),
2753 ] {
2754 let copy = policy.clone();
2755 assert_eq!(format!("{policy:?}"), format!("{copy:?}"));
2756 }
2757 }
2758
2759 #[test]
2760 fn capability_all_has_no_duplicates() {
2761 let mut names: Vec<&str> = Capability::ALL.iter().map(|cap| cap.as_str()).collect();
2762 names.sort_unstable();
2763 let count = names.len();
2764 names.dedup();
2765 assert_eq!(names.len(), count, "capability names must be unique");
2766 }
2767
2768 /// The roster names every capability bit the kernel defines.
2769 ///
2770 /// `ALL` is derived from the declaration, so a variant cannot be missing
2771 /// from it; what this catches is the other direction, which nothing else
2772 /// can. A capability the kernel gained and this enum has not is one a
2773 /// keep-list cannot name, and it shows up here as a bit no variant claims.
2774 ///
2775 /// Asked against the bits `rustix` gives a name, rather than against every
2776 /// bit of the word: the set is a bitflags type with a catch-all, so its
2777 /// `all()` holds the reserved high bits too, and `iter_names` is the set
2778 /// the kernel actually defines.
2779 #[test]
2780 fn every_capability_bit_has_a_variant() {
2781 let claimed = Capability::ALL
2782 .iter()
2783 .fold(CapabilitySet::empty(), |set, cap| set | cap.to_set());
2784 let unclaimed: Vec<&str> = CapabilitySet::all()
2785 .iter_names()
2786 .filter(|(_, bit)| !claimed.contains(*bit))
2787 .map(|(name, _)| name)
2788 .collect();
2789 assert!(
2790 unclaimed.is_empty(),
2791 "capabilities the kernel defines and this enum does not name: {unclaimed:?}",
2792 );
2793 }
2794
2795 #[test]
2796 fn drop_all_lowers_to_empty_keep_set() {
2797 let mut request = Request::default();
2798 request.drop_all_capabilities();
2799 let plan = request.lower().unwrap();
2800 assert_eq!(plan.keep_caps, Some(0));
2801 }
2802
2803 #[test]
2804 fn keep_list_lowers_to_named_bits() {
2805 let mut request = Request::default();
2806 request.keep_capabilities([Capability::NetBindService, Capability::Chown]);
2807 let plan = request.lower().unwrap();
2808 let expected = (CapabilitySet::NET_BIND_SERVICE | CapabilitySet::CHOWN).bits();
2809 assert_eq!(plan.keep_caps, Some(expected));
2810 }
2811
2812 #[test]
2813 fn no_hardening_lowers_to_an_empty_plan() {
2814 let plan = Request::default().lower().unwrap();
2815 assert!(plan.landlock.is_empty());
2816 assert_eq!(plan.landlock_handled, 0);
2817 assert!(plan.landlock_net.is_empty());
2818 assert_eq!(plan.landlock_net_handled, 0);
2819 assert!(plan.seccomp.is_none());
2820 assert!(plan.keep_caps.is_none());
2821 }
2822}