1#![allow(unsafe_code)] use std::{
16 collections::BTreeSet,
17 ffi::CString,
18 os::{
19 fd::{AsRawFd, FromRawFd, OwnedFd},
20 unix::{ffi::OsStrExt, fs::MetadataExt},
21 },
22 path::{Path, PathBuf},
23};
24
25use landlock::{
26 ABI, Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, NetPort, PathBeneath,
27 Ruleset, RulesetAttr, RulesetCreated, RulesetCreatedAttr, Scope,
28};
29
30use crate::{
31 config::SandboxPath,
32 error::CoreError,
33 profile::{NetworkMode, SandboxProfile},
34 sandbox::{
35 BackendOptions,
36 linux::probe::{LandlockAbi, ProbeResult},
37 },
38};
39
40pub const READ_ALLOWLIST_ANCHORS: &[&str] = &[
47 "/etc",
49 "/lib",
50 "/lib32",
51 "/lib64",
52 "/usr",
53 "/sys",
54 "/dev/null",
56 "/dev/zero",
57 "/dev/random",
58 "/dev/urandom",
59 "/dev/tty",
60 "/run/systemd/resolve/stub-resolv.conf",
74 "/run/systemd/resolve/resolv.conf",
75];
76
77pub const PROC_READ_ALLOWLIST_ANCHORS: &[&str] = &[
81 "/proc/cpuinfo",
82 "/proc/filesystems",
83 "/proc/loadavg",
84 "/proc/meminfo",
85 "/proc/stat",
86 "/proc/sys",
87 "/proc/uptime",
88 "/proc/version",
89];
90
91const BASELINE_WRITE_PATHS: &[&str] = &["/dev/null", "/dev/zero"];
96const MAX_CARVED_READ_ENTRIES: usize = 100_000;
97
98#[derive(Clone, Copy, Debug, Eq, PartialEq)]
99enum UntrustedSymlinkBehavior {
100 Reject,
101 Skip,
102}
103
104const PRIVILEGE_ESCALATION_BINARIES: &[&str] = &[
108 "/usr/bin/sudo",
110 "/bin/sudo",
111 "/usr/bin/su",
112 "/bin/su",
113 "/usr/bin/runuser",
114 "/usr/sbin/runuser",
115 "/usr/bin/gosu",
116 "/usr/local/bin/gosu",
117 "/usr/bin/doas",
118 "/usr/local/bin/doas",
119 "/usr/bin/pkexec",
120 "/usr/bin/chsh",
122 "/usr/bin/chfn",
123 "/usr/bin/newgrp",
124 "/usr/bin/sg",
125 "/usr/bin/passwd",
126 "/usr/bin/gpasswd",
127 "/usr/bin/capsh",
130 "/usr/sbin/capsh",
131 "/usr/bin/setpriv",
132 "/usr/bin/nsenter",
133 "/usr/bin/unshare",
134 "/usr/sbin/unshare",
135 "/usr/bin/systemd-run",
137 "/usr/bin/machinectl",
138 "/usr/bin/pkttyagent",
139 "/usr/bin/dbus-launch",
140 "/usr/bin/mount",
142 "/usr/bin/umount",
143 "/bin/mount",
144 "/bin/umount",
145 "/usr/bin/fusermount",
146 "/usr/bin/fusermount3",
147];
148
149fn read_access(_abi: ABI) -> BitFlags<AccessFs> {
153 BitFlags::from(AccessFs::ReadFile) | AccessFs::ReadDir
154}
155
156fn read_directory_access(_abi: ABI) -> BitFlags<AccessFs> {
157 BitFlags::from(AccessFs::ReadDir)
158}
159
160fn write_access(abi: ABI) -> BitFlags<AccessFs> {
162 let mut access = AccessFs::from_write(abi);
167 access.remove(AccessFs::IoctlDev | AccessFs::ResolveUnix);
168 access
169}
170
171fn ephemeral_write_access(abi: ABI) -> BitFlags<AccessFs> {
175 write_access(abi) | (AccessFs::from_all(abi) & AccessFs::ResolveUnix)
176}
177
178fn exec_access(abi: ABI) -> BitFlags<AccessFs> {
180 BitFlags::from(AccessFs::Execute) | read_access(abi)
181}
182
183fn highest_abi(probe: &ProbeResult) -> ABI {
184 match probe.abi {
188 LandlockAbi::Unsupported => ABI::V1,
189 LandlockAbi::V1 => ABI::V1,
190 LandlockAbi::V2 => ABI::V2,
191 LandlockAbi::V3 => ABI::V3,
192 LandlockAbi::V4 => ABI::V4,
193 LandlockAbi::V5 => ABI::V5,
194 LandlockAbi::V6 => ABI::V6,
195 LandlockAbi::V7 => ABI::V7,
196 LandlockAbi::V8 => ABI::V8,
197 LandlockAbi::V9 => ABI::V9,
198 }
199}
200
201fn handled_fs_access(abi: ABI, mode: NetworkMode) -> BitFlags<AccessFs> {
202 let mut access = AccessFs::from_all(abi);
203 if mode == NetworkMode::AllowAll {
204 access.remove(AccessFs::ResolveUnix);
208 }
209 access
210}
211
212pub struct CompiledLandlock {
215 pub ruleset: RulesetCreated,
217}
218
219impl std::fmt::Debug for CompiledLandlock {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 f.debug_struct("CompiledLandlock").finish_non_exhaustive()
222 }
223}
224
225pub fn compile(
228 profile: &SandboxProfile,
229 proxy_port: Option<u16>,
230 probe: &ProbeResult,
231 options: BackendOptions,
232) -> Result<CompiledLandlock, CoreError> {
233 lint_allow_exec_for_priv_escalation(profile, options)?;
235 let forbidden_reads = build_forbidden_reads(profile)?;
236 lint_forbidden_reads_against_grants(profile, &forbidden_reads, options)?;
237
238 let abi = highest_abi(probe);
239 let ruleset = Ruleset::default()
240 .set_compatibility(CompatLevel::HardRequirement)
241 .handle_access(handled_fs_access(abi, profile.network_mode))?;
242 let ruleset = if probe.abi.supports_scopes() {
248 let ruleset = ruleset.scope(Scope::Signal)?;
249 if profile.network_mode == NetworkMode::AllowAll {
250 ruleset
251 } else {
252 ruleset.scope(Scope::AbstractUnixSocket)?
253 }
254 } else {
255 ruleset
256 };
257 let ruleset =
258 if probe.abi.supports_net_port_filter() && profile.network_mode != NetworkMode::AllowAll {
259 ruleset.handle_access(AccessNet::ConnectTcp | AccessNet::BindTcp)?
260 } else {
261 ruleset
262 };
263
264 let mut created = ruleset.create()?.no_new_privs(false);
267
268 let baseline_reads: Vec<PathBuf> = READ_ALLOWLIST_ANCHORS
272 .iter()
273 .chain(PROC_READ_ALLOWLIST_ANCHORS)
274 .map(PathBuf::from)
275 .collect();
276 let mut carved_entries = 0_usize;
277 for path in &baseline_reads {
278 let sandbox_path = if path.is_dir() {
279 SandboxPath::dir(path.clone())
280 } else {
281 SandboxPath::file(path.clone())
282 };
283 created = add_read_rule(
284 created,
285 &sandbox_path,
286 &forbidden_reads,
287 abi,
288 &mut carved_entries,
289 )?;
290 }
291 for sp in &profile.allow_read {
292 created = add_read_rule(created, sp, &forbidden_reads, abi, &mut carved_entries)?;
293 }
294
295 for sp in &profile.allow_write {
296 let access = if profile
297 .ephemeral_write_exec
298 .iter()
299 .any(|root| sp.path.starts_with(root))
300 {
301 ephemeral_write_access(abi)
302 } else {
303 write_access(abi)
304 };
305 created = add_write_rule(created, sp, access, abi)?;
306 created = add_path_rules(
309 created,
310 std::slice::from_ref(&sp.path),
311 read_access(abi),
312 abi,
313 UntrustedSymlinkBehavior::Reject,
314 )?;
315 }
316 for path in BASELINE_WRITE_PATHS {
317 created = add_write_rule(
318 created,
319 &SandboxPath::file(PathBuf::from(path)),
320 write_access(abi),
321 abi,
322 )?;
323 }
324
325 for (index, sp) in profile.allow_exec.iter().enumerate() {
332 let symlink_behavior = if index < profile.first_user_allow_exec {
338 UntrustedSymlinkBehavior::Skip
339 } else {
340 UntrustedSymlinkBehavior::Reject
341 };
342 created = add_path_rules(
343 created,
344 std::slice::from_ref(&sp.path),
345 exec_access(abi),
346 abi,
347 symlink_behavior,
348 )?;
349 }
350
351 if probe.abi.supports_net_port_filter() {
353 match profile.network_mode {
354 NetworkMode::Proxy => {
355 let port = proxy_port.ok_or_else(|| {
356 CoreError::Backend("proxy network mode has no live proxy port".to_owned())
357 })?;
358 created = created.add_rule(NetPort::new(port, AccessNet::ConnectTcp))?;
359 }
360 NetworkMode::DirectHttps443 => {
361 created = created.add_rule(NetPort::new(443, AccessNet::ConnectTcp))?;
362 }
363 NetworkMode::DenyAll | NetworkMode::AllowAll => {}
364 }
365 }
366
367 Ok(CompiledLandlock { ruleset: created })
368}
369
370fn add_read_rule(
375 created: RulesetCreated,
376 path: &SandboxPath,
377 forbidden: &BTreeSet<PathBuf>,
378 abi: ABI,
379 visited: &mut usize,
380) -> Result<RulesetCreated, CoreError> {
381 use crate::config::PathKind;
382
383 if forbidden
384 .iter()
385 .any(|denied| path_is_under(&path.path, denied))
386 {
387 return Ok(created);
388 }
389 let has_denied_descendant = forbidden
390 .iter()
391 .any(|denied| denied != &path.path && path_is_under(denied, &path.path));
392 if !has_denied_descendant {
393 return add_path_rules(
394 created,
395 std::slice::from_ref(&path.path),
396 read_access(abi),
397 abi,
398 UntrustedSymlinkBehavior::Reject,
399 );
400 }
401 if !matches!(path.kind, PathKind::Subpath) {
402 return Err(CoreError::ProfileLint(format!(
403 "literal read grant '{}' contains a denied descendant",
404 path.path.display()
405 )));
406 }
407 let Some(fd) = open_existing_safely(&path.path)? else {
408 return Ok(created);
409 };
410 add_carved_read_directory(created, fd, &path.path, forbidden, abi, visited)
411}
412
413#[allow(
414 clippy::disallowed_methods,
415 reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
416)]
417fn add_carved_read_directory(
418 mut created: RulesetCreated,
419 directory: OwnedFd,
420 logical_path: &Path,
421 forbidden: &BTreeSet<PathBuf>,
422 abi: ABI,
423 visited: &mut usize,
424) -> Result<RulesetCreated, CoreError> {
425 let proc_path = PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd()));
426 let mut entries = Vec::new();
427 for entry in std::fs::read_dir(&proc_path).map_err(CoreError::Io)? {
428 let entry = entry.map_err(CoreError::Io)?;
429 *visited = visited.saturating_add(1);
430 if *visited > MAX_CARVED_READ_ENTRIES {
431 return Err(CoreError::ProfileLint(format!(
432 "read policy under '{}' exceeds {MAX_CARVED_READ_ENTRIES} entries",
433 logical_path.display()
434 )));
435 }
436 entries.push(entry.file_name());
437 }
438
439 created = created.add_rule(PathBeneath::new(
442 duplicate_fd(directory.as_raw_fd())?,
443 read_directory_access(abi),
444 ))?;
445
446 for name in entries {
447 let child_path = logical_path.join(&name);
448 if forbidden
449 .iter()
450 .any(|denied| path_is_under(&child_path, denied))
451 {
452 continue;
453 }
454 let nested_hole = forbidden
455 .iter()
456 .any(|denied| denied != &child_path && path_is_under(denied, &child_path));
457 let name = CString::new(name.as_bytes()).map_err(|_| {
458 CoreError::ProfileLint(format!(
459 "sandbox path contains NUL below '{}'",
460 logical_path.display()
461 ))
462 })?;
463 let child = match openat2_component(directory.as_raw_fd(), &name, nested_hole) {
464 Ok(child) => child,
465 Err(error) if error.raw_os_error() == Some(libc::ELOOP) => continue,
469 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
470 Err(error) => return Err(CoreError::Io(error)),
471 };
472 if nested_hole {
473 created =
474 add_carved_read_directory(created, child, &child_path, forbidden, abi, visited)?;
475 } else {
476 let compatible = access_for_fd(&child, read_access(abi), abi)?;
477 created = created.add_rule(PathBeneath::new(child, compatible))?;
478 }
479 }
480 Ok(created)
481}
482
483fn duplicate_fd(fd: libc::c_int) -> Result<OwnedFd, CoreError> {
484 let duplicated = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 3) };
485 if duplicated < 0 {
486 return Err(CoreError::Io(std::io::Error::last_os_error()));
487 }
488 Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
489}
490
491fn add_path_rules(
492 mut created: RulesetCreated,
493 paths: &[PathBuf],
494 access: BitFlags<AccessFs>,
495 abi: ABI,
496 symlink_behavior: UntrustedSymlinkBehavior,
497) -> Result<RulesetCreated, CoreError> {
498 for path in paths {
499 if let Some(fd) = open_existing_safely_with(path, symlink_behavior)? {
500 let compatible = access_for_fd(&fd, access, abi)?;
501 created = created.add_rule(PathBeneath::new(fd, compatible))?;
502 }
503 }
504 Ok(created)
505}
506
507fn add_write_rule(
508 mut created: RulesetCreated,
509 path: &SandboxPath,
510 access: BitFlags<AccessFs>,
511 abi: ABI,
512) -> Result<RulesetCreated, CoreError> {
513 use crate::config::PathKind;
514 let fd = match path.kind {
515 PathKind::Subpath => Some(open_or_create_directory(&path.path)?),
516 PathKind::Literal => open_existing_safely(&path.path)?,
517 PathKind::Regex => {
518 return Err(CoreError::ProfileLint(format!(
519 "regex write grants are not safely enforceable on Linux: '{}'",
520 path.path.display()
521 )));
522 }
523 };
524 if let Some(fd) = fd {
525 let compatible = access_for_fd(&fd, access, abi)?;
526 created = created.add_rule(PathBeneath::new(fd, compatible))?;
527 } else {
528 tracing::debug!(
529 path = %path.path.display(),
530 "literal write target does not exist; refusing to broaden its parent"
531 );
532 }
533 Ok(created)
534}
535
536fn access_for_fd(
541 fd: &OwnedFd,
542 access: BitFlags<AccessFs>,
543 abi: ABI,
544) -> Result<BitFlags<AccessFs>, CoreError> {
545 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
546 if unsafe { libc::fstat(fd.as_raw_fd(), &mut stat) } != 0 {
547 return Err(CoreError::Io(std::io::Error::last_os_error()));
548 }
549 if stat.st_mode & libc::S_IFMT == libc::S_IFDIR {
550 Ok(access)
551 } else {
552 Ok(access & AccessFs::from_file(abi))
553 }
554}
555
556fn open_existing_safely(path: &Path) -> Result<Option<OwnedFd>, CoreError> {
560 open_existing_safely_with(path, UntrustedSymlinkBehavior::Reject)
561}
562
563#[allow(
564 clippy::disallowed_methods,
565 reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
566)]
567fn open_existing_safely_with(
568 path: &Path,
569 symlink_behavior: UntrustedSymlinkBehavior,
570) -> Result<Option<OwnedFd>, CoreError> {
571 match open_no_symlinks(path, false) {
572 Ok(fd) => Ok(Some(fd)),
573 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
574 Err(error) if error.raw_os_error() == Some(libc::ELOOP) => {
575 let canonical = match std::fs::canonicalize(path) {
581 Ok(canonical) => canonical,
582 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
583 Err(error) => return Err(CoreError::Io(error)),
584 };
585 if let Err(reason) = root_owned_chain(path) {
586 return handle_untrusted_symlink(path, &reason, symlink_behavior);
587 }
588 if let Err(reason) = root_owned_chain(&canonical) {
589 let reason = format!("canonical target is not immutable: {reason}");
590 return handle_untrusted_symlink(path, &reason, symlink_behavior);
591 }
592 open_no_symlinks(&canonical, false)
593 .map(Some)
594 .map_err(CoreError::Io)
595 }
596 Err(error) => Err(CoreError::Io(error)),
597 }
598}
599
600fn handle_untrusted_symlink(
601 path: &Path,
602 reason: &str,
603 behavior: UntrustedSymlinkBehavior,
604) -> Result<Option<OwnedFd>, CoreError> {
605 match behavior {
606 UntrustedSymlinkBehavior::Reject => Err(CoreError::ProfileLint(format!(
607 "allowlist path '{}' traverses an untrusted symlink: {reason}",
608 path.display()
609 ))),
610 UntrustedSymlinkBehavior::Skip => {
611 tracing::warn!(
612 path = %path.display(),
613 reason,
614 "skipping unsafe optional built-in executable"
615 );
616 Ok(None)
617 }
618 }
619}
620
621fn open_or_create_directory(path: &Path) -> Result<OwnedFd, CoreError> {
625 if !path.is_absolute() {
626 return Err(CoreError::ProfileLint(format!(
627 "sandbox path must be absolute: '{}'",
628 path.display()
629 )));
630 }
631 let mut current = open_root().map_err(CoreError::Io)?;
632 for component in path.components() {
633 use std::path::Component;
634 let name = match component {
635 Component::RootDir => continue,
636 Component::Normal(name) => name,
637 _ => {
638 return Err(CoreError::ProfileLint(format!(
639 "sandbox path contains traversal: '{}'",
640 path.display()
641 )));
642 }
643 };
644 let name = CString::new(name.as_bytes()).map_err(|_| {
645 CoreError::ProfileLint(format!("sandbox path contains NUL: '{}'", path.display()))
646 })?;
647 let next = match openat2_component(current.as_raw_fd(), &name, true) {
648 Ok(fd) => fd,
649 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
650 let rc = unsafe { libc::mkdirat(current.as_raw_fd(), name.as_ptr(), 0o700) };
651 if rc != 0 {
652 let mkdir_error = std::io::Error::last_os_error();
653 if mkdir_error.kind() != std::io::ErrorKind::AlreadyExists {
654 return Err(CoreError::Io(mkdir_error));
655 }
656 }
657 openat2_component(current.as_raw_fd(), &name, true).map_err(CoreError::Io)?
658 }
659 Err(error) => return Err(CoreError::Io(error)),
660 };
661 current = next;
662 }
663 Ok(current)
664}
665
666fn open_no_symlinks(path: &Path, directory: bool) -> std::io::Result<OwnedFd> {
667 if !path.is_absolute() {
668 return Err(std::io::Error::new(
669 std::io::ErrorKind::InvalidInput,
670 "path is not absolute",
671 ));
672 }
673 if path == Path::new("/") {
674 return open_root();
675 }
676 let relative = path.strip_prefix("/").expect("absolute path has root");
677 let relative = CString::new(relative.as_os_str().as_bytes())
678 .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL"))?;
679 let root = open_root()?;
680 openat2_component(root.as_raw_fd(), &relative, directory)
681}
682
683fn open_root() -> std::io::Result<OwnedFd> {
684 let root = c"/";
685 let fd = unsafe { libc::open(root.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
686 if fd < 0 {
687 Err(std::io::Error::last_os_error())
688 } else {
689 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
690 }
691}
692
693fn openat2_component(
694 directory_fd: libc::c_int,
695 path: &CString,
696 directory: bool,
697) -> std::io::Result<OwnedFd> {
698 let mut flags = (libc::O_PATH | libc::O_CLOEXEC) as u64;
699 if directory {
700 flags |= libc::O_DIRECTORY as u64;
701 }
702 let mut how: libc::open_how = unsafe { std::mem::zeroed() };
703 how.flags = flags;
704 how.mode = 0;
705 how.resolve = libc::RESOLVE_BENEATH | libc::RESOLVE_NO_SYMLINKS | libc::RESOLVE_NO_MAGICLINKS;
706 let fd = unsafe {
707 libc::syscall(
708 libc::SYS_openat2,
709 directory_fd,
710 path.as_ptr(),
711 &how,
712 std::mem::size_of::<libc::open_how>(),
713 ) as libc::c_int
714 };
715 if fd < 0 {
716 Err(std::io::Error::last_os_error())
717 } else {
718 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
719 }
720}
721
722#[allow(
723 clippy::disallowed_methods,
724 reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
725)]
726fn root_owned_chain(path: &Path) -> Result<(), String> {
727 let mut current = PathBuf::from("/");
728 for component in path.components() {
729 use std::path::Component;
730 match component {
731 Component::RootDir => continue,
732 Component::Normal(name) => current.push(name),
733 _ => {
734 return Err(format!(
735 "'{}' contains a non-normal path component",
736 path.display()
737 ));
738 }
739 }
740 let metadata = std::fs::symlink_metadata(¤t)
741 .map_err(|error| format!("cannot inspect '{}': {error}", current.display()))?;
742 if metadata.file_type().is_symlink() {
750 continue;
751 }
752 if metadata.uid() != 0 {
753 return Err(format!(
754 "'{}' is owned by UID {}, not root",
755 current.display(),
756 metadata.uid()
757 ));
758 }
759 if metadata.mode() & 0o022 != 0 {
760 return Err(format!(
761 "'{}' is group/world writable (mode {:o})",
762 current.display(),
763 metadata.mode() & 0o7777
764 ));
765 }
766 }
767 Ok(())
768}
769
770fn build_forbidden_reads(profile: &SandboxProfile) -> Result<BTreeSet<PathBuf>, CoreError> {
771 let mut set = BTreeSet::new();
772 let mut inspected = 0_usize;
773 for sp in &profile.deny_read {
774 match open_no_symlinks(&sp.path, false) {
775 Ok(fd) => reject_aliased_forbidden_tree(&fd, &sp.path, &mut inspected)?,
776 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
777 Err(error) if error.raw_os_error() == Some(libc::ELOOP) => {
778 return Err(CoreError::ProfileLint(format!(
779 "denyRead path '{}' traverses a symlink; refusing a policy whose canonical \
780 target could receive a read grant",
781 sp.path.display()
782 )));
783 }
784 Err(error) => return Err(CoreError::Io(error)),
785 }
786 set.insert(sp.path.clone());
787 }
788 Ok(set)
789}
790
791#[allow(
796 clippy::disallowed_methods,
797 reason = "the policy compiler runs synchronously in the pre-runtime Linux launcher"
798)]
799fn reject_aliased_forbidden_tree(
800 fd: &OwnedFd,
801 logical_path: &Path,
802 inspected: &mut usize,
803) -> Result<(), CoreError> {
804 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
805 if unsafe { libc::fstat(fd.as_raw_fd(), &mut stat) } != 0 {
806 return Err(CoreError::Io(std::io::Error::last_os_error()));
807 }
808 let file_type = stat.st_mode & libc::S_IFMT;
809 if file_type == libc::S_IFREG {
810 if stat.st_nlink > 1 {
811 return Err(CoreError::ProfileLint(format!(
812 "denyRead file '{}' has {} hard links; Landlock cannot deny one pathname while \
813 an alias grants the same inode",
814 logical_path.display(),
815 stat.st_nlink,
816 )));
817 }
818 return Ok(());
819 }
820 if file_type != libc::S_IFDIR {
821 return Ok(());
822 }
823
824 let proc_path = PathBuf::from(format!("/proc/self/fd/{}", fd.as_raw_fd()));
825 for entry in std::fs::read_dir(proc_path).map_err(CoreError::Io)? {
826 let entry = entry.map_err(CoreError::Io)?;
827 *inspected = inspected.saturating_add(1);
828 if *inspected > MAX_CARVED_READ_ENTRIES {
829 return Err(CoreError::ProfileLint(format!(
830 "denyRead policy under '{}' exceeds {MAX_CARVED_READ_ENTRIES} entries",
831 logical_path.display()
832 )));
833 }
834 let name = entry.file_name();
835 let child_path = logical_path.join(&name);
836 let name = CString::new(name.as_bytes()).map_err(|_| {
837 CoreError::ProfileLint(format!(
838 "sandbox path contains NUL below '{}'",
839 logical_path.display()
840 ))
841 })?;
842 let child = match openat2_component(fd.as_raw_fd(), &name, false) {
843 Ok(child) => child,
844 Err(error) if error.raw_os_error() == Some(libc::ELOOP) => {
845 return Err(CoreError::ProfileLint(format!(
846 "denyRead path '{}' traverses a symlink; refusing a policy whose canonical \
847 target could receive a read grant",
848 child_path.display()
849 )));
850 }
851 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
852 Err(error) => return Err(CoreError::Io(error)),
853 };
854 reject_aliased_forbidden_tree(&child, &child_path, inspected)?;
855 }
856 Ok(())
857}
858
859fn lint_forbidden_reads_against_grants(
871 profile: &SandboxProfile,
872 forbidden: &BTreeSet<PathBuf>,
873 options: BackendOptions,
874) -> Result<(), CoreError> {
875 let _ = options;
876 let user_slices: [(&str, &[SandboxPath]); 3] = [
877 (
878 "allowWrite",
879 &profile.allow_write[profile.first_user_allow_write..],
880 ),
881 (
882 "allowExec",
883 &profile.allow_exec[profile.first_user_allow_exec..],
884 ),
885 (
886 "allowRead",
887 &profile.allow_read[profile.first_user_allow_read..],
888 ),
889 ];
890 for (field, paths) in user_slices {
891 for sp in paths {
892 for f in forbidden {
893 if path_is_under(f, &sp.path) || path_is_under(&sp.path, f) {
894 return Err(CoreError::ProfileLint(format!(
895 "denyRead path '{}' overlaps user-supplied {} entry '{}'. Landlock grants \
896 on allowWrite and allowExec include data-read, so this would silently \
897 expose the denied path. Remove or relocate the {} entry, or remove the \
898 denyRead entry.",
899 f.display(),
900 field,
901 sp.path.display(),
902 field,
903 )));
904 }
905 }
906 }
907 }
908 Ok(())
909}
910
911fn lint_allow_exec_for_priv_escalation(
912 profile: &SandboxProfile,
913 options: BackendOptions,
914) -> Result<(), CoreError> {
915 let _ = options;
916
917 for sp in &profile.allow_exec {
918 if !is_subpath(sp) {
919 continue;
920 }
921 for binary in PRIVILEGE_ESCALATION_BINARIES {
922 let bin_path = Path::new(binary);
923 if path_is_under(bin_path, &sp.path) {
924 return Err(CoreError::ProfileLint(format!(
925 "allowExec entry '{}' (directory) covers privilege-escalation binary '{}'. \
926 This would defeat the threat model. Replace it with explicit per-binary \
927 entries.",
928 sp.path.display(),
929 binary,
930 )));
931 }
932 }
933 }
934 Ok(())
935}
936
937fn is_subpath(sp: &SandboxPath) -> bool {
938 use crate::config::PathKind;
939 matches!(sp.kind, PathKind::Subpath)
940}
941
942fn path_is_under(candidate: &Path, anchor: &Path) -> bool {
943 candidate == anchor || candidate.starts_with(anchor)
944}
945
946impl From<landlock::RulesetError> for CoreError {
947 fn from(err: landlock::RulesetError) -> Self {
948 CoreError::Backend(format!("landlock ruleset error: {err}"))
949 }
950}
951
952impl From<landlock::AddRulesError> for CoreError {
953 fn from(err: landlock::AddRulesError) -> Self {
954 CoreError::Backend(format!("landlock add_rules error: {err}"))
955 }
956}
957
958impl From<landlock::AddRuleError<AccessFs>> for CoreError {
959 fn from(err: landlock::AddRuleError<AccessFs>) -> Self {
960 CoreError::Backend(format!("landlock add_rule (fs) error: {err}"))
961 }
962}
963
964impl From<landlock::AddRuleError<AccessNet>> for CoreError {
965 fn from(err: landlock::AddRuleError<AccessNet>) -> Self {
966 CoreError::Backend(format!("landlock add_rule (net) error: {err}"))
967 }
968}
969
970#[cfg(test)]
971mod tests {
972 use std::path::PathBuf;
973
974 use super::*;
975 use crate::{
976 config::{PathKind, SandboxPath},
977 detect::Ecosystem,
978 };
979
980 #[test]
981 fn data_read_grants_never_include_execute() {
982 let access = read_access(ABI::V9);
983 assert!(access.contains(AccessFs::ReadFile));
984 assert!(access.contains(AccessFs::ReadDir));
985 assert!(!access.contains(AccessFs::Execute));
986 }
987
988 #[test]
989 fn persistent_write_grants_exclude_execute_ioctl_and_unix_resolution() {
990 for abi in [ABI::V1, ABI::V4, ABI::V5, ABI::V9] {
991 let access = write_access(abi);
992 assert!(!access.contains(AccessFs::Execute));
993 assert!(!access.contains(AccessFs::IoctlDev));
994 assert!(!access.contains(AccessFs::ResolveUnix));
995 }
996 assert!(ephemeral_write_access(ABI::V9).contains(AccessFs::ResolveUnix));
997 assert!(!ephemeral_write_access(ABI::V9).contains(AccessFs::IoctlDev));
998 }
999
1000 #[test]
1001 fn allow_all_does_not_handle_unix_socket_resolution() {
1002 assert!(handled_fs_access(ABI::V9, NetworkMode::Proxy).contains(AccessFs::ResolveUnix));
1003 assert!(!handled_fs_access(ABI::V9, NetworkMode::AllowAll).contains(AccessFs::ResolveUnix));
1004 }
1005
1006 #[test]
1007 fn literal_file_rules_drop_directory_only_rights() {
1008 let temp = tempfile::tempdir().unwrap();
1009 let file = tempfile::NamedTempFile::new_in(temp.path()).unwrap();
1010
1011 let directory = open_no_symlinks(temp.path(), true).unwrap();
1012 let file = open_no_symlinks(file.path(), false).unwrap();
1013 let requested = read_access(ABI::V9) | write_access(ABI::V9);
1014
1015 assert_eq!(
1016 access_for_fd(&directory, requested, ABI::V9).unwrap(),
1017 requested
1018 );
1019 let file_access = access_for_fd(&file, requested, ABI::V9).unwrap();
1020 assert_eq!(file_access, requested & AccessFs::from_file(ABI::V9));
1021 assert!(file_access.contains(AccessFs::ReadFile));
1022 assert!(file_access.contains(AccessFs::WriteFile));
1023 assert!(!file_access.contains(AccessFs::ReadDir));
1024 assert!(!file_access.contains(AccessFs::MakeReg));
1025 }
1026
1027 #[test]
1028 #[allow(
1029 clippy::disallowed_methods,
1030 reason = "the Linux-only policy test is synchronous"
1031 )]
1032 fn immutable_system_symlinks_are_trusted() {
1033 for path in [
1034 Path::new("/bin/sh"),
1035 Path::new("/lib64/ld-linux-x86-64.so.2"),
1036 Path::new("/lib/ld-linux-x86-64.so.2"),
1037 Path::new("/lib/ld-linux-aarch64.so.1"),
1038 ] {
1039 if path.exists() {
1040 root_owned_chain(path).unwrap();
1041 let canonical = std::fs::canonicalize(path).unwrap();
1042 root_owned_chain(&canonical).unwrap();
1043 }
1044 }
1045 }
1046
1047 #[test]
1048 fn missing_target_through_symlink_is_skipped_without_a_rule() {
1049 use std::os::unix::fs::symlink;
1050
1051 let temp = tempfile::tempdir().unwrap();
1052 let outside = tempfile::tempdir().unwrap();
1053 symlink(outside.path(), temp.path().join("redirect")).unwrap();
1054
1055 let missing = temp.path().join("redirect/missing");
1056 assert!(open_existing_safely(&missing).unwrap().is_none());
1057 }
1058
1059 #[test]
1060 fn existing_target_through_untrusted_symlink_is_rejected() {
1061 use std::os::unix::fs::symlink;
1062
1063 let temp = tempfile::tempdir().unwrap();
1064 let outside = tempfile::tempdir().unwrap();
1065 let target = tempfile::NamedTempFile::new_in(outside.path()).unwrap();
1066 symlink(target.path(), temp.path().join("redirect")).unwrap();
1067
1068 let redirect = temp.path().join("redirect");
1069 assert!(open_existing_safely(&redirect).is_err());
1070 assert!(
1071 open_existing_safely_with(&redirect, UntrustedSymlinkBehavior::Skip)
1072 .unwrap()
1073 .is_none()
1074 );
1075 }
1076
1077 #[test]
1078 fn test_should_reject_priv_escalation_subpath() {
1079 let mut profile = SandboxProfile::for_ecosystem(
1080 Ecosystem::Rust,
1081 &PathBuf::from("/home/test"),
1082 &PathBuf::from("/home/test/pwd"),
1083 );
1084 profile.allow_exec.push(SandboxPath {
1085 path: PathBuf::from("/usr/bin"),
1086 kind: PathKind::Subpath,
1087 });
1088 let err =
1089 lint_allow_exec_for_priv_escalation(&profile, BackendOptions::default()).unwrap_err();
1090 assert!(format!("{err}").contains("privilege-escalation"));
1091 }
1092
1093 #[test]
1094 fn test_should_not_bypass_priv_escalation_lint_with_allow_degraded() {
1095 let mut profile = SandboxProfile::for_ecosystem(
1096 Ecosystem::Rust,
1097 &PathBuf::from("/home/test"),
1098 &PathBuf::from("/home/test/pwd"),
1099 );
1100 profile.allow_exec.push(SandboxPath {
1101 path: PathBuf::from("/usr/bin"),
1102 kind: PathKind::Subpath,
1103 });
1104 let res = lint_allow_exec_for_priv_escalation(
1105 &profile,
1106 BackendOptions {
1107 allow_degraded: true,
1108 ..BackendOptions::default()
1109 },
1110 );
1111 assert!(res.is_err());
1112 }
1113
1114 #[test]
1115 fn test_should_not_lint_baseline_anchor_overlap() {
1116 let mut profile = SandboxProfile::for_ecosystem(
1122 Ecosystem::Rust,
1123 &PathBuf::from("/home/test"),
1124 &PathBuf::from("/home/test/pwd"),
1125 );
1126 profile.deny_read.clear();
1127 profile.deny_read.push(SandboxPath {
1128 path: PathBuf::from("/etc/ssh"),
1129 kind: PathKind::Subpath,
1130 });
1131 let forbidden = build_forbidden_reads(&profile).unwrap();
1132 let lint =
1133 lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default());
1134 assert!(lint.is_ok(), "baseline anchor overlap must not lint");
1135 }
1136
1137 #[test]
1138 fn test_should_reject_forbidden_read_overlap_with_user_allow_read() {
1139 let mut profile = SandboxProfile::for_ecosystem(
1140 Ecosystem::Rust,
1141 &PathBuf::from("/home/test"),
1142 &PathBuf::from("/home/test/pwd"),
1143 );
1144 profile.deny_read.clear();
1145 profile.deny_read.push(SandboxPath {
1146 path: PathBuf::from("/home/test/.ssh"),
1147 kind: PathKind::Subpath,
1148 });
1149 profile.allow_read.push(SandboxPath {
1151 path: PathBuf::from("/home/test"),
1152 kind: PathKind::Subpath,
1153 });
1154 let forbidden = build_forbidden_reads(&profile).unwrap();
1155 let err =
1156 lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
1157 .unwrap_err();
1158 assert!(format!("{err}").contains("denyRead"));
1159 assert!(format!("{err}").contains("allowRead"));
1160 }
1161
1162 #[test]
1167 fn test_should_reject_forbidden_read_overlap_with_allow_write() {
1168 let mut profile = SandboxProfile::for_ecosystem(
1169 Ecosystem::Rust,
1170 &PathBuf::from("/home/test"),
1171 &PathBuf::from("/home/test/pwd"),
1172 );
1173 profile.deny_read.clear();
1174 profile.deny_read.push(SandboxPath {
1175 path: PathBuf::from("/home/test/.ssh"),
1176 kind: PathKind::Subpath,
1177 });
1178 profile.allow_write.push(SandboxPath {
1179 path: PathBuf::from("/home/test"),
1180 kind: PathKind::Subpath,
1181 });
1182 let forbidden = build_forbidden_reads(&profile).unwrap();
1183 let err =
1184 lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
1185 .unwrap_err();
1186 assert!(format!("{err}").contains("denyRead"));
1187 assert!(format!("{err}").contains("allowWrite"));
1188 }
1189
1190 #[test]
1193 fn test_should_reject_forbidden_read_overlap_with_allow_exec() {
1194 let mut profile = SandboxProfile::for_ecosystem(
1195 Ecosystem::Rust,
1196 &PathBuf::from("/home/test"),
1197 &PathBuf::from("/home/test/pwd"),
1198 );
1199 profile.deny_read.clear();
1200 profile.deny_read.push(SandboxPath {
1201 path: PathBuf::from("/home/test/.aws/credentials"),
1202 kind: PathKind::Literal,
1203 });
1204 profile.allow_exec.push(SandboxPath {
1205 path: PathBuf::from("/home/test/.aws"),
1206 kind: PathKind::Subpath,
1207 });
1208 let forbidden = build_forbidden_reads(&profile).unwrap();
1209 let err =
1210 lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
1211 .unwrap_err();
1212 assert!(format!("{err}").contains("allowExec"));
1213 }
1214
1215 #[test]
1216 fn test_should_reject_user_grants_nested_beneath_forbidden_read() {
1217 for field in ["allowRead", "allowWrite", "allowExec"] {
1218 let mut profile = SandboxProfile::for_ecosystem(
1219 Ecosystem::Rust,
1220 &PathBuf::from("/home/test"),
1221 &PathBuf::from("/home/test/pwd"),
1222 );
1223 profile.deny_read.clear();
1224 profile
1225 .deny_read
1226 .push(SandboxPath::dir(PathBuf::from("/home/test/.ssh")));
1227 let grant = SandboxPath::file(PathBuf::from("/home/test/.ssh/id_rsa"));
1228 match field {
1229 "allowRead" => profile.allow_read.push(grant),
1230 "allowWrite" => profile.allow_write.push(grant),
1231 "allowExec" => profile.allow_exec.push(grant),
1232 _ => unreachable!(),
1233 }
1234
1235 let forbidden = build_forbidden_reads(&profile).unwrap();
1236 let error = lint_forbidden_reads_against_grants(
1237 &profile,
1238 &forbidden,
1239 BackendOptions::default(),
1240 )
1241 .unwrap_err();
1242 assert!(format!("{error}").contains(field));
1243 }
1244 }
1245
1246 #[test]
1247 #[allow(
1248 clippy::disallowed_methods,
1249 reason = "synchronous filesystem setup is isolated to this Linux policy unit test"
1250 )]
1251 fn forbidden_read_symlink_fails_closed() {
1252 use std::os::unix::fs::symlink;
1253
1254 let project = tempfile::tempdir().unwrap();
1255 let target = project.path().join("config.env");
1256 std::fs::write(&target, "secret").unwrap();
1257 let denied = project.path().join(".env");
1258 symlink(&target, &denied).unwrap();
1259 let mut profile =
1260 SandboxProfile::for_ecosystem(Ecosystem::Rust, project.path(), project.path());
1261 profile.deny_read = vec![SandboxPath::file(denied)];
1262
1263 let error = build_forbidden_reads(&profile).unwrap_err();
1264 assert!(format!("{error}").contains("traverses a symlink"));
1265 }
1266
1267 #[test]
1268 #[allow(
1269 clippy::disallowed_methods,
1270 reason = "synchronous filesystem setup is isolated to this Linux policy unit test"
1271 )]
1272 fn forbidden_read_hard_link_fails_closed() {
1273 let project = tempfile::tempdir().unwrap();
1274 let denied = project.path().join(".env");
1275 let alias = project.path().join("config.env");
1276 std::fs::write(&denied, "secret").unwrap();
1277 std::fs::hard_link(&denied, &alias).unwrap();
1278 let mut profile =
1279 SandboxProfile::for_ecosystem(Ecosystem::Rust, project.path(), project.path());
1280 profile.deny_read = vec![SandboxPath::file(denied)];
1281
1282 let error = build_forbidden_reads(&profile).unwrap_err();
1283 assert!(format!("{error}").contains("hard links"));
1284 }
1285
1286 #[test]
1287 #[allow(
1288 clippy::disallowed_methods,
1289 reason = "synchronous filesystem setup is isolated to this Linux policy unit test"
1290 )]
1291 fn forbidden_directory_checks_descendant_hard_links() {
1292 let project = tempfile::tempdir().unwrap();
1293 let denied_directory = project.path().join("credentials");
1294 std::fs::create_dir(&denied_directory).unwrap();
1295 let denied = denied_directory.join("token");
1296 std::fs::write(&denied, "secret").unwrap();
1297 std::fs::hard_link(&denied, project.path().join("token-alias")).unwrap();
1298 let mut profile =
1299 SandboxProfile::for_ecosystem(Ecosystem::Rust, project.path(), project.path());
1300 profile.deny_read = vec![SandboxPath::dir(denied_directory)];
1301
1302 let error = build_forbidden_reads(&profile).unwrap_err();
1303 assert!(format!("{error}").contains("hard links"));
1304 }
1305
1306 #[test]
1307 fn test_should_not_bypass_forbidden_read_overlap_under_allow_degraded() {
1308 let mut profile = SandboxProfile::for_ecosystem(
1309 Ecosystem::Rust,
1310 &PathBuf::from("/home/test"),
1311 &PathBuf::from("/home/test/pwd"),
1312 );
1313 profile.deny_read.clear();
1314 profile.deny_read.push(SandboxPath {
1315 path: PathBuf::from("/home/test/.ssh"),
1316 kind: PathKind::Subpath,
1317 });
1318 profile.allow_write.push(SandboxPath {
1319 path: PathBuf::from("/home/test"),
1320 kind: PathKind::Subpath,
1321 });
1322 let forbidden = build_forbidden_reads(&profile).unwrap();
1323 let res = lint_forbidden_reads_against_grants(
1324 &profile,
1325 &forbidden,
1326 BackendOptions {
1327 allow_degraded: true,
1328 ..BackendOptions::default()
1329 },
1330 );
1331 assert!(res.is_err(), "allow_degraded must not bypass the seal lint");
1332 }
1333
1334 #[test]
1335 fn test_open_or_create_directory_refuses_symlink_ancestor() {
1336 use std::os::unix::fs::symlink;
1337 let temp = tempfile::tempdir().unwrap();
1338 let outside = tempfile::tempdir().unwrap();
1339 symlink(outside.path(), temp.path().join("redirect")).unwrap();
1340 let target = temp.path().join("redirect/cache");
1341 assert!(open_or_create_directory(&target).is_err());
1342 assert!(!outside.path().join("cache").exists());
1343 }
1344}