1use std::{
15 collections::BTreeSet,
16 path::{Path, PathBuf},
17};
18
19use landlock::{
20 ABI, Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, NetPort, PathBeneath,
21 PathFd, Ruleset, RulesetAttr, RulesetCreated, RulesetCreatedAttr,
22};
23
24use crate::{
25 config::SandboxPath,
26 error::CoreError,
27 profile::SandboxProfile,
28 sandbox::{
29 BackendOptions,
30 linux::probe::{LandlockAbi, ProbeResult},
31 },
32};
33
34pub const READ_ALLOWLIST_ANCHORS: &[&str] = &[
41 "/etc",
43 "/lib",
44 "/lib32",
45 "/lib64",
46 "/usr",
47 "/proc",
48 "/sys",
49 "/tmp",
51 "/var/tmp",
52 "/dev",
54 "/run/systemd/resolve/stub-resolv.conf",
68 "/run/systemd/resolve/resolv.conf",
69];
70
71const BASELINE_WRITE_PATHS: &[&str] = &["/tmp", "/var/tmp", "/dev/null", "/dev/zero", "/dev/shm"];
79
80const PRIVILEGE_ESCALATION_BINARIES: &[&str] = &[
84 "/usr/bin/sudo",
86 "/bin/sudo",
87 "/usr/bin/su",
88 "/bin/su",
89 "/usr/bin/runuser",
90 "/usr/sbin/runuser",
91 "/usr/bin/gosu",
92 "/usr/local/bin/gosu",
93 "/usr/bin/doas",
94 "/usr/local/bin/doas",
95 "/usr/bin/pkexec",
96 "/usr/bin/chsh",
98 "/usr/bin/chfn",
99 "/usr/bin/newgrp",
100 "/usr/bin/sg",
101 "/usr/bin/passwd",
102 "/usr/bin/gpasswd",
103 "/usr/bin/capsh",
106 "/usr/sbin/capsh",
107 "/usr/bin/setpriv",
108 "/usr/bin/nsenter",
109 "/usr/bin/unshare",
110 "/usr/sbin/unshare",
111 "/usr/bin/systemd-run",
113 "/usr/bin/machinectl",
114 "/usr/bin/pkttyagent",
115 "/usr/bin/dbus-launch",
116 "/usr/bin/mount",
118 "/usr/bin/umount",
119 "/bin/mount",
120 "/bin/umount",
121 "/usr/bin/fusermount",
122 "/usr/bin/fusermount3",
123];
124
125fn read_access(abi: ABI) -> BitFlags<AccessFs> {
127 AccessFs::from_read(abi)
128}
129
130fn write_access(abi: ABI) -> BitFlags<AccessFs> {
132 AccessFs::from_all(abi)
135}
136
137fn exec_access(abi: ABI) -> BitFlags<AccessFs> {
139 BitFlags::from(AccessFs::Execute) | AccessFs::from_read(abi)
140}
141
142fn highest_abi(probe: &ProbeResult) -> ABI {
143 match probe.abi {
147 LandlockAbi::Unsupported => ABI::V1,
148 LandlockAbi::V1 => ABI::V1,
149 LandlockAbi::V2 => ABI::V2,
150 LandlockAbi::V3 => ABI::V3,
151 LandlockAbi::V4 => ABI::V4,
152 LandlockAbi::V5 => ABI::V5,
153 LandlockAbi::V6 => ABI::V6,
154 }
155}
156
157pub struct CompiledLandlock {
160 pub ruleset: RulesetCreated,
162}
163
164impl std::fmt::Debug for CompiledLandlock {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.debug_struct("CompiledLandlock").finish_non_exhaustive()
167 }
168}
169
170pub fn compile(
173 profile: &SandboxProfile,
174 proxy_port: Option<u16>,
175 probe: &ProbeResult,
176 options: BackendOptions,
177) -> Result<CompiledLandlock, CoreError> {
178 if options.allow_degraded {
179 tracing::warn!(
186 "--allow-degraded ACTIVE: the following Linux sandbox checks are DISABLED for this \
187 run: (1) privilege-escalation subpath lint (allowExec subpaths can include \
188 sudo/su/pkexec/etc.); (2) denyRead forbidden-list seal \
189 (allowRead/allowWrite/allowExec may overlap denyRead paths); (3) \
190 refuse-on-missing-Landlock-ABI-v4 (kernel may run without per-port TCP filter). \
191 Re-run without --allow-degraded for full enforcement."
192 );
193 }
194
195 lint_allow_exec_for_priv_escalation(profile, options)?;
197 let forbidden_reads = build_forbidden_reads(profile)?;
198 lint_forbidden_reads_against_grants(profile, &forbidden_reads, options)?;
199
200 let abi = highest_abi(probe);
201 let ruleset = Ruleset::default()
202 .set_compatibility(CompatLevel::BestEffort)
203 .handle_access(AccessFs::from_all(abi))?;
204 let ruleset = if probe.abi.supports_net_port_filter() && !profile.allow_all_network {
205 ruleset.handle_access(AccessNet::ConnectTcp)?
206 } else {
207 ruleset
208 };
209
210 let mut created = ruleset.create()?.set_no_new_privs(false);
214
215 let baseline_reads: Vec<PathBuf> = READ_ALLOWLIST_ANCHORS.iter().map(PathBuf::from).collect();
219 for path in &baseline_reads {
220 let policy = symlink_policy_for(path);
221 created = add_path_rules(
222 created,
223 std::slice::from_ref(path),
224 read_access(abi),
225 policy,
226 )?;
227 }
228 for sp in &profile.allow_read {
229 let policy = symlink_policy_for(&sp.path);
230 created = add_path_rules(
231 created,
232 std::slice::from_ref(&sp.path),
233 read_access(abi),
234 policy,
235 )?;
236 }
237
238 let user_writes: Vec<PathBuf> = profile
243 .allow_write
244 .iter()
245 .map(|sp| sp.path.clone())
246 .collect();
247 let baseline_writes: Vec<PathBuf> = BASELINE_WRITE_PATHS.iter().map(PathBuf::from).collect();
248 let all_writes: Vec<PathBuf> = user_writes
249 .iter()
250 .chain(baseline_writes.iter())
251 .cloned()
252 .collect();
253 ensure_writable_dirs(&all_writes);
254 for path in &all_writes {
255 let policy = symlink_policy_for(path);
256 created = add_path_rules(
257 created,
258 std::slice::from_ref(path),
259 write_access(abi),
260 policy,
261 )?;
262 }
263
264 for sp in &profile.allow_exec {
271 let policy = symlink_policy_for(&sp.path);
272 created = add_path_rules(
273 created,
274 std::slice::from_ref(&sp.path),
275 exec_access(abi),
276 policy,
277 )?;
278 }
279
280 if probe.abi.supports_net_port_filter() && !profile.allow_all_network {
282 if let Some(port) = proxy_port {
283 created = created.add_rule(NetPort::new(port, AccessNet::ConnectTcp))?;
284 } else if !profile.enable_proxy {
285 created = created.add_rule(NetPort::new(443, AccessNet::ConnectTcp))?;
286 }
287 }
288
289 Ok(CompiledLandlock { ruleset: created })
290}
291
292#[derive(Debug, Clone, Copy)]
300enum SymlinkPolicy {
301 Follow,
303 Refuse,
305}
306
307fn add_path_rules(
308 mut created: RulesetCreated,
309 paths: &[PathBuf],
310 access: BitFlags<AccessFs>,
311 policy: SymlinkPolicy,
312) -> Result<RulesetCreated, CoreError> {
313 for path in paths {
314 if matches!(policy, SymlinkPolicy::Refuse) && is_symlink(path) {
315 return Err(CoreError::ProfileLint(format!(
316 "Landlock allowlist entry '{}' is a symlink. Refusing to open it — a symlink lets \
317 an attacker redirect the grant onto a target of their choosing. Replace the \
318 entry with the canonical target path or remove the symlink before re-running sbe.",
319 path.display(),
320 )));
321 }
322
323 let fd = match PathFd::new(path) {
327 Ok(fd) => fd,
328 Err(e) => {
329 tracing::debug!(path = %path.display(), error = %e, "skipping missing landlock path");
330 continue;
331 }
332 };
333 created = created.add_rule(PathBeneath::new(fd, access))?;
334 }
335 Ok(created)
336}
337
338#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
339fn is_symlink(p: &Path) -> bool {
340 std::fs::symlink_metadata(p)
341 .map(|m| m.file_type().is_symlink())
342 .unwrap_or(false)
343}
344
345const ROOT_TRUSTED_PREFIXES: &[&str] = &[
354 "/bin",
355 "/sbin",
356 "/lib",
357 "/lib32",
358 "/lib64",
359 "/usr",
360 "/etc",
361 "/proc",
362 "/sys",
363 "/dev",
364 "/tmp",
365 "/var/tmp",
366 "/var/log",
367 "/var/cache",
368 "/var/lib",
369 "/var/run",
370 "/run",
371 "/opt",
372 "/boot",
373 "/srv",
374];
375
376fn symlink_policy_for(p: &Path) -> SymlinkPolicy {
377 if ROOT_TRUSTED_PREFIXES
378 .iter()
379 .any(|root| p == Path::new(root) || p.starts_with(root))
380 {
381 SymlinkPolicy::Follow
382 } else {
383 SymlinkPolicy::Refuse
384 }
385}
386
387#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
398fn ensure_writable_dirs(paths: &[PathBuf]) {
399 use std::os::unix::fs::PermissionsExt;
400 let home = std::env::var_os("HOME").map(PathBuf::from);
401 for p in paths {
402 match std::fs::symlink_metadata(p) {
406 Ok(m) if m.file_type().is_symlink() => {
407 tracing::warn!(
408 path = %p.display(),
409 "allow_write entry is a symlink; refusing to materialize. add_path_rules \
410 will reject this entry."
411 );
412 continue;
413 }
414 Ok(_) => continue, Err(_) => { }
416 }
417
418 let _ = std::fs::create_dir_all(p);
424 if let Some(h) = home.as_ref()
425 && p.starts_with(h)
426 && let Ok(meta) = std::fs::symlink_metadata(p)
427 && !meta.file_type().is_symlink()
428 && meta.file_type().is_dir()
429 {
430 let mut perms = meta.permissions();
431 perms.set_mode(0o700);
432 let _ = std::fs::set_permissions(p, perms);
433 }
434 }
435}
436
437fn build_forbidden_reads(profile: &SandboxProfile) -> Result<BTreeSet<PathBuf>, CoreError> {
438 let mut set = BTreeSet::new();
439 for sp in &profile.deny_read {
440 set.insert(sp.path.clone());
441 }
442 Ok(set)
443}
444
445fn lint_forbidden_reads_against_grants(
461 profile: &SandboxProfile,
462 forbidden: &BTreeSet<PathBuf>,
463 options: BackendOptions,
464) -> Result<(), CoreError> {
465 if options.allow_degraded {
466 return Ok(());
467 }
468 let user_slices: [(&str, &[SandboxPath]); 3] = [
469 (
470 "allowWrite",
471 &profile.allow_write[profile.first_user_allow_write..],
472 ),
473 (
474 "allowExec",
475 &profile.allow_exec[profile.first_user_allow_exec..],
476 ),
477 (
478 "allowRead",
479 &profile.allow_read[profile.first_user_allow_read..],
480 ),
481 ];
482 for (field, paths) in user_slices {
483 for sp in paths {
484 for f in forbidden {
485 if path_is_under(f, &sp.path) {
486 return Err(CoreError::ProfileLint(format!(
487 "denyRead path '{}' is under user-supplied {} entry '{}'. Landlock grants \
488 on allowWrite and allowExec also imply read, so this would silently \
489 expose the denied path. Either narrow the {} entry, remove the denyRead \
490 entry, or pass --allow-degraded if you understand the threat model.",
491 f.display(),
492 field,
493 sp.path.display(),
494 field,
495 )));
496 }
497 }
498 }
499 }
500 Ok(())
501}
502
503fn lint_allow_exec_for_priv_escalation(
504 profile: &SandboxProfile,
505 options: BackendOptions,
506) -> Result<(), CoreError> {
507 if options.allow_degraded {
508 return Ok(());
509 }
510
511 for sp in &profile.allow_exec {
512 if !is_subpath(sp) {
513 continue;
514 }
515 for binary in PRIVILEGE_ESCALATION_BINARIES {
516 let bin_path = Path::new(binary);
517 if path_is_under(bin_path, &sp.path) {
518 return Err(CoreError::ProfileLint(format!(
519 "allowExec entry '{}' (directory) covers privilege-escalation binary '{}'. \
520 This would defeat the threat model. Replace with explicit per-binary entries \
521 or pass --allow-degraded if you know what you are doing.",
522 sp.path.display(),
523 binary,
524 )));
525 }
526 }
527 }
528 Ok(())
529}
530
531fn is_subpath(sp: &SandboxPath) -> bool {
532 use crate::config::PathKind;
533 matches!(sp.kind, PathKind::Subpath)
534}
535
536fn path_is_under(candidate: &Path, anchor: &Path) -> bool {
537 candidate == anchor || candidate.starts_with(anchor)
538}
539
540impl From<landlock::RulesetError> for CoreError {
541 fn from(err: landlock::RulesetError) -> Self {
542 CoreError::Backend(format!("landlock ruleset error: {err}"))
543 }
544}
545
546impl From<landlock::AddRulesError> for CoreError {
547 fn from(err: landlock::AddRulesError) -> Self {
548 CoreError::Backend(format!("landlock add_rules error: {err}"))
549 }
550}
551
552impl From<landlock::AddRuleError<AccessFs>> for CoreError {
553 fn from(err: landlock::AddRuleError<AccessFs>) -> Self {
554 CoreError::Backend(format!("landlock add_rule (fs) error: {err}"))
555 }
556}
557
558impl From<landlock::AddRuleError<AccessNet>> for CoreError {
559 fn from(err: landlock::AddRuleError<AccessNet>) -> Self {
560 CoreError::Backend(format!("landlock add_rule (net) error: {err}"))
561 }
562}
563
564#[cfg(test)]
565mod tests {
566 use std::path::PathBuf;
567
568 use super::*;
569 use crate::{
570 config::{PathKind, SandboxPath},
571 detect::Ecosystem,
572 };
573
574 #[test]
575 fn test_should_reject_priv_escalation_subpath() {
576 let mut profile = SandboxProfile::for_ecosystem(
577 Ecosystem::Rust,
578 &PathBuf::from("/home/test"),
579 &PathBuf::from("/home/test/pwd"),
580 );
581 profile.allow_exec.push(SandboxPath {
582 path: PathBuf::from("/usr/bin"),
583 kind: PathKind::Subpath,
584 });
585 let err =
586 lint_allow_exec_for_priv_escalation(&profile, BackendOptions::default()).unwrap_err();
587 assert!(format!("{err}").contains("privilege-escalation"));
588 }
589
590 #[test]
591 fn test_should_pass_priv_escalation_with_allow_degraded() {
592 let mut profile = SandboxProfile::for_ecosystem(
593 Ecosystem::Rust,
594 &PathBuf::from("/home/test"),
595 &PathBuf::from("/home/test/pwd"),
596 );
597 profile.allow_exec.push(SandboxPath {
598 path: PathBuf::from("/usr/bin"),
599 kind: PathKind::Subpath,
600 });
601 let res = lint_allow_exec_for_priv_escalation(
602 &profile,
603 BackendOptions {
604 allow_degraded: true,
605 },
606 );
607 assert!(res.is_ok());
608 }
609
610 #[test]
611 fn test_should_not_lint_baseline_anchor_overlap() {
612 let mut profile = SandboxProfile::for_ecosystem(
618 Ecosystem::Rust,
619 &PathBuf::from("/home/test"),
620 &PathBuf::from("/home/test/pwd"),
621 );
622 profile.deny_read.clear();
623 profile.deny_read.push(SandboxPath {
624 path: PathBuf::from("/etc/ssh"),
625 kind: PathKind::Subpath,
626 });
627 let forbidden = build_forbidden_reads(&profile).unwrap();
628 let lint =
629 lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default());
630 assert!(lint.is_ok(), "baseline anchor overlap must not lint");
631 }
632
633 #[test]
634 fn test_should_reject_forbidden_read_overlap_with_user_allow_read() {
635 let mut profile = SandboxProfile::for_ecosystem(
636 Ecosystem::Rust,
637 &PathBuf::from("/home/test"),
638 &PathBuf::from("/home/test/pwd"),
639 );
640 profile.deny_read.clear();
641 profile.deny_read.push(SandboxPath {
642 path: PathBuf::from("/home/test/.ssh"),
643 kind: PathKind::Subpath,
644 });
645 profile.allow_read.push(SandboxPath {
647 path: PathBuf::from("/home/test"),
648 kind: PathKind::Subpath,
649 });
650 let forbidden = build_forbidden_reads(&profile).unwrap();
651 let err =
652 lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
653 .unwrap_err();
654 assert!(format!("{err}").contains("denyRead"));
655 assert!(format!("{err}").contains("allowRead"));
656 }
657
658 #[test]
663 fn test_should_reject_forbidden_read_overlap_with_allow_write() {
664 let mut profile = SandboxProfile::for_ecosystem(
665 Ecosystem::Rust,
666 &PathBuf::from("/home/test"),
667 &PathBuf::from("/home/test/pwd"),
668 );
669 profile.deny_read.clear();
670 profile.deny_read.push(SandboxPath {
671 path: PathBuf::from("/home/test/.ssh"),
672 kind: PathKind::Subpath,
673 });
674 profile.allow_write.push(SandboxPath {
675 path: PathBuf::from("/home/test"),
676 kind: PathKind::Subpath,
677 });
678 let forbidden = build_forbidden_reads(&profile).unwrap();
679 let err =
680 lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
681 .unwrap_err();
682 assert!(format!("{err}").contains("denyRead"));
683 assert!(format!("{err}").contains("allowWrite"));
684 }
685
686 #[test]
689 fn test_should_reject_forbidden_read_overlap_with_allow_exec() {
690 let mut profile = SandboxProfile::for_ecosystem(
691 Ecosystem::Rust,
692 &PathBuf::from("/home/test"),
693 &PathBuf::from("/home/test/pwd"),
694 );
695 profile.deny_read.clear();
696 profile.deny_read.push(SandboxPath {
697 path: PathBuf::from("/home/test/.aws/credentials"),
698 kind: PathKind::Literal,
699 });
700 profile.allow_exec.push(SandboxPath {
701 path: PathBuf::from("/home/test/.aws"),
702 kind: PathKind::Subpath,
703 });
704 let forbidden = build_forbidden_reads(&profile).unwrap();
705 let err =
706 lint_forbidden_reads_against_grants(&profile, &forbidden, BackendOptions::default())
707 .unwrap_err();
708 assert!(format!("{err}").contains("allowExec"));
709 }
710
711 #[test]
712 fn test_should_bypass_forbidden_read_overlap_under_allow_degraded() {
713 let mut profile = SandboxProfile::for_ecosystem(
714 Ecosystem::Rust,
715 &PathBuf::from("/home/test"),
716 &PathBuf::from("/home/test/pwd"),
717 );
718 profile.deny_read.clear();
719 profile.deny_read.push(SandboxPath {
720 path: PathBuf::from("/home/test/.ssh"),
721 kind: PathKind::Subpath,
722 });
723 profile.allow_write.push(SandboxPath {
724 path: PathBuf::from("/home/test"),
725 kind: PathKind::Subpath,
726 });
727 let forbidden = build_forbidden_reads(&profile).unwrap();
728 let res = lint_forbidden_reads_against_grants(
729 &profile,
730 &forbidden,
731 BackendOptions {
732 allow_degraded: true,
733 },
734 );
735 assert!(res.is_ok(), "allow_degraded should bypass the seal lint");
736 }
737}