nucleus/security/
landlock.rs1use crate::error::{NucleusError, Result};
2use landlock::{
3 Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetError,
4 RulesetStatus, ABI,
5};
6use std::path::PathBuf;
7use tracing::{debug, info, warn};
8
9const TARGET_ABI: ABI = ABI::V5;
12
13const MINIMUM_PRODUCTION_ABI: ABI = ABI::V3;
19
20pub struct LandlockManager {
30 applied: bool,
31 extra_rw_paths: Vec<String>,
33}
34
35impl LandlockManager {
36 pub fn new() -> Self {
37 Self {
38 applied: false,
39 extra_rw_paths: Vec::new(),
40 }
41 }
42
43 pub fn add_rw_path(&mut self, path: &str) {
46 self.extra_rw_paths.push(path.to_string());
47 }
48
49 pub fn apply_container_policy(&mut self) -> Result<bool> {
63 self.apply_container_policy_with_mode(false)
64 }
65
66 pub fn assert_minimum_abi(&self, production_mode: bool) -> Result<()> {
72 let min_access = AccessFs::from_all(MINIMUM_PRODUCTION_ABI);
76 let target_access = AccessFs::from_all(TARGET_ABI);
77
78 if min_access != target_access {
81 info!(
82 "Landlock ABI: target={:?}, minimum_production={:?}",
83 TARGET_ABI, MINIMUM_PRODUCTION_ABI
84 );
85 }
86
87 match Ruleset::default().handle_access(AccessFs::from_all(MINIMUM_PRODUCTION_ABI)) {
92 Ok(_) => {
93 info!("Landlock ABI >= V3 confirmed");
94 Ok(())
95 }
96 Err(e) => {
97 let msg = format!(
98 "Kernel Landlock ABI is below minimum required version (V3): {}",
99 e
100 );
101 if production_mode {
102 Err(ll_err(e))
103 } else {
104 warn!("{}", msg);
105 Ok(())
106 }
107 }
108 }
109 }
110
111 pub fn apply_container_policy_with_mode(&mut self, best_effort: bool) -> Result<bool> {
116 if self.applied {
117 debug!("Landlock policy already applied, skipping");
118 return Ok(true);
119 }
120
121 info!("Applying Landlock filesystem policy");
122
123 match self.build_and_restrict() {
124 Ok(status) => match status {
125 RulesetStatus::FullyEnforced => {
126 self.applied = true;
127 info!("Landlock policy fully enforced");
128 Ok(true)
129 }
130 RulesetStatus::PartiallyEnforced => {
131 if best_effort {
132 self.applied = true;
133 info!(
134 "Landlock policy partially enforced (kernel lacks some access rights)"
135 );
136 Ok(true)
137 } else {
138 Err(NucleusError::LandlockError(
139 "Landlock policy only partially enforced; strict mode requires full target ABI support".to_string(),
140 ))
141 }
142 }
143 RulesetStatus::NotEnforced => {
144 if best_effort {
145 warn!("Landlock not enforced (kernel does not support Landlock)");
146 Ok(false)
147 } else {
148 Err(NucleusError::LandlockError(
149 "Landlock not enforced (kernel does not support Landlock)".to_string(),
150 ))
151 }
152 }
153 },
154 Err(e) => {
155 if best_effort {
156 warn!(
157 "Failed to apply Landlock policy: {} (continuing without Landlock)",
158 e
159 );
160 Ok(false)
161 } else {
162 Err(e)
163 }
164 }
165 }
166 }
167
168 pub fn apply_execute_allowlist_policy(
176 &mut self,
177 allowed_roots: &[PathBuf],
178 best_effort: bool,
179 ) -> Result<bool> {
180 if self.applied {
181 debug!("Landlock execute allowlist already applied, skipping");
182 return Ok(true);
183 }
184
185 info!(
186 allowed_roots = ?allowed_roots,
187 "Applying Landlock execute allowlist policy"
188 );
189
190 match self.build_execute_allowlist_and_restrict(allowed_roots) {
191 Ok(status) => match status {
192 RulesetStatus::FullyEnforced => {
193 self.applied = true;
194 info!("Landlock execute allowlist fully enforced");
195 Ok(true)
196 }
197 RulesetStatus::PartiallyEnforced => {
198 if best_effort {
199 self.applied = true;
200 info!("Landlock execute allowlist partially enforced");
201 Ok(true)
202 } else {
203 Err(NucleusError::LandlockError(
204 "Landlock execute allowlist only partially enforced; strict mode requires full enforcement".to_string(),
205 ))
206 }
207 }
208 RulesetStatus::NotEnforced => {
209 if best_effort {
210 warn!("Landlock execute allowlist not enforced");
211 Ok(false)
212 } else {
213 Err(NucleusError::LandlockError(
214 "Landlock execute allowlist not enforced".to_string(),
215 ))
216 }
217 }
218 },
219 Err(e) => {
220 if best_effort {
221 warn!(
222 "Failed to apply Landlock execute allowlist: {} (continuing without it)",
223 e
224 );
225 Ok(false)
226 } else {
227 Err(e)
228 }
229 }
230 }
231 }
232
233 fn build_and_restrict(&self) -> Result<RulesetStatus> {
235 let access_all = AccessFs::from_all(TARGET_ABI);
236 let access_read = AccessFs::from_read(TARGET_ABI);
237
238 let access_read_exec = access_read | AccessFs::Execute;
240
241 let mut access_tmp = access_all;
244 access_tmp.remove(AccessFs::Execute);
245
246 let mut ruleset = Ruleset::default()
247 .handle_access(access_all)
248 .map_err(ll_err)?
249 .create()
250 .map_err(ll_err)?;
251
252 if let Ok(fd) = PathFd::new("/") {
255 ruleset = ruleset
256 .add_rule(PathBeneath::new(fd, AccessFs::ReadDir))
257 .map_err(ll_err)?;
258 }
259
260 const MANDATORY_PATHS: &[&str] = &["/bin", "/usr", "/lib", "/etc"];
263 for path in MANDATORY_PATHS {
264 if !std::path::Path::new(path).exists() {
265 warn!(
266 "Landlock: mandatory path {} does not exist; container may not function correctly",
267 path
268 );
269 }
270 }
271
272 for path in &["/bin", "/usr", "/sbin"] {
274 if let Ok(fd) = PathFd::new(path) {
275 ruleset = ruleset
276 .add_rule(PathBeneath::new(fd, access_read_exec))
277 .map_err(ll_err)?;
278 }
279 }
280
281 for path in &["/lib", "/lib64", "/lib32"] {
283 if let Ok(fd) = PathFd::new(path) {
284 ruleset = ruleset
285 .add_rule(PathBeneath::new(fd, access_read))
286 .map_err(ll_err)?;
287 }
288 }
289
290 for path in &["/etc", "/dev", "/proc"] {
292 if let Ok(fd) = PathFd::new(path) {
293 ruleset = ruleset
294 .add_rule(PathBeneath::new(fd, access_read))
295 .map_err(ll_err)?;
296 }
297 }
298
299 if let Ok(fd) = PathFd::new("/dev/shm") {
303 ruleset = ruleset
304 .add_rule(PathBeneath::new(fd, access_tmp))
305 .map_err(ll_err)?;
306 }
307
308 if let Ok(fd) = PathFd::new("/tmp") {
310 ruleset = ruleset
311 .add_rule(PathBeneath::new(fd, access_tmp))
312 .map_err(ll_err)?;
313 }
314
315 if let Ok(fd) = PathFd::new("/nix/store") {
317 ruleset = ruleset
318 .add_rule(PathBeneath::new(fd, access_read_exec))
319 .map_err(ll_err)?;
320 }
321
322 if let Ok(fd) = PathFd::new("/run/secrets") {
324 ruleset = ruleset
325 .add_rule(PathBeneath::new(fd, access_read))
326 .map_err(ll_err)?;
327 }
328
329 if let Ok(fd) = PathFd::new("/context") {
331 ruleset = ruleset
332 .add_rule(PathBeneath::new(fd, access_read))
333 .map_err(ll_err)?;
334 }
335
336 for path in &self.extra_rw_paths {
339 if let Ok(fd) = PathFd::new(path) {
340 debug!("Landlock: granting rw access to volume path {:?}", path);
341 ruleset = ruleset
342 .add_rule(PathBeneath::new(fd, access_tmp))
343 .map_err(ll_err)?;
344 }
345 }
346
347 let status = ruleset.restrict_self().map_err(ll_err)?;
348 Ok(status.ruleset)
349 }
350
351 fn build_execute_allowlist_and_restrict(
352 &self,
353 allowed_roots: &[PathBuf],
354 ) -> Result<RulesetStatus> {
355 let access_execute = AccessFs::Execute;
356 let mut ruleset = Ruleset::default()
357 .handle_access(access_execute)
358 .map_err(ll_err)?
359 .create()
360 .map_err(ll_err)?;
361
362 let mut added_rules = 0usize;
363 for root in allowed_roots {
364 let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.clone());
365 match PathFd::new(canonical.as_path()) {
366 Ok(fd) => {
367 ruleset = ruleset
368 .add_rule(PathBeneath::new(fd, access_execute))
369 .map_err(ll_err)?;
370 added_rules += 1;
371 }
372 Err(err) => {
373 warn!(
374 "Landlock execute allowlist skipped {:?}: {}",
375 canonical, err
376 );
377 }
378 }
379 }
380
381 if added_rules == 0 {
382 return Err(NucleusError::LandlockError(
383 "Landlock execute allowlist has no valid executable roots".to_string(),
384 ));
385 }
386
387 let status = ruleset.restrict_self().map_err(ll_err)?;
388 Ok(status.ruleset)
389 }
390
391 pub fn is_applied(&self) -> bool {
393 self.applied
394 }
395}
396
397impl Default for LandlockManager {
398 fn default() -> Self {
399 Self::new()
400 }
401}
402
403fn ll_err(e: RulesetError) -> NucleusError {
405 NucleusError::LandlockError(e.to_string())
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn test_landlock_manager_initial_state() {
414 let mgr = LandlockManager::new();
415 assert!(!mgr.is_applied());
416 }
417
418 #[test]
419 fn test_apply_idempotent() {
420 let mut mgr = LandlockManager::new();
421 let _ = mgr.apply_container_policy_with_mode(true);
423 let result = mgr.apply_container_policy_with_mode(true);
425 assert!(result.is_ok());
426 }
427
428 #[test]
429 fn test_best_effort_on_unsupported_kernel() {
430 let mut mgr = LandlockManager::new();
431 let result = mgr.apply_container_policy_with_mode(true);
433 assert!(result.is_ok());
434 }
435
436 fn extract_fn_body<'a>(source: &'a str, fn_signature: &str) -> &'a str {
439 let fn_start = source
440 .find(fn_signature)
441 .unwrap_or_else(|| panic!("function '{}' not found in source", fn_signature));
442 let after = &source[fn_start..];
443 let open = after
444 .find('{')
445 .unwrap_or_else(|| panic!("no opening brace found for '{}'", fn_signature));
446 let mut depth = 0u32;
447 let mut end = open;
448 for (i, ch) in after[open..].char_indices() {
449 match ch {
450 '{' => depth += 1,
451 '}' => {
452 depth -= 1;
453 if depth == 0 {
454 end = open + i + 1;
455 break;
456 }
457 }
458 _ => {}
459 }
460 }
461 &after[..end]
462 }
463
464 #[test]
465 fn test_policy_covers_nix_store_and_secrets() {
466 let source = include_str!("landlock.rs");
472 let fn_body = extract_fn_body(source, "fn build_and_restrict");
473 assert!(
474 fn_body.contains("\"/nix/store\"") || fn_body.contains("\"/nix\""),
475 "Landlock build_and_restrict must include a rule for /nix/store or /nix"
476 );
477 assert!(
478 fn_body.contains("\"/run/secrets\"") || fn_body.contains("\"/run\""),
479 "Landlock build_and_restrict must include a rule for /run/secrets"
480 );
481 }
482
483 #[test]
484 fn test_tmp_access_excludes_execute() {
485 let access_all = AccessFs::from_all(TARGET_ABI);
489 let mut access_tmp = access_all;
490 access_tmp.remove(AccessFs::Execute);
491 assert!(!access_tmp.contains(AccessFs::Execute));
492 assert!(access_tmp.contains(AccessFs::WriteFile));
494 assert!(access_tmp.contains(AccessFs::RemoveFile));
495 }
496
497 #[test]
498 fn test_execute_allowlist_handles_only_execute() {
499 let source = include_str!("landlock.rs");
500 let fn_body = extract_fn_body(source, "fn build_execute_allowlist_and_restrict");
501 assert!(
502 fn_body.contains("let access_execute = AccessFs::Execute"),
503 "execute allowlist must handle only execute access"
504 );
505 assert!(
506 fn_body.contains("handle_access(access_execute)"),
507 "execute allowlist must not handle read/write filesystem rights"
508 );
509 assert!(
510 !fn_body.contains("from_all"),
511 "execute allowlist must not accidentally become a broad filesystem policy"
512 );
513 }
514
515 #[test]
516 fn test_not_enforced_returns_error_in_strict_mode() {
517 let source = include_str!("landlock.rs");
519 let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
520 let not_enforced_start = fn_body
522 .find("NotEnforced")
523 .expect("function must handle NotEnforced status");
524 let rest = &fn_body[not_enforced_start..];
526 let arm_end = rest
527 .find("RestrictionStatus::")
528 .unwrap_or(rest.len().min(500));
529 let not_enforced_block = &rest[..arm_end];
530 assert!(
531 not_enforced_block.contains("best_effort") && not_enforced_block.contains("Err"),
532 "NotEnforced must return Err when best_effort=false. Block: {}",
533 not_enforced_block
534 );
535 }
536
537 #[test]
538 fn test_partially_enforced_returns_error_in_strict_mode() {
539 let source = include_str!("landlock.rs");
540 let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
541 let partial_start = fn_body
542 .find("PartiallyEnforced")
543 .expect("function must handle PartiallyEnforced status");
544 let rest = &fn_body[partial_start..];
545 let arm_end = rest.find("NotEnforced").unwrap_or(rest.len().min(500));
546 let partial_block = &rest[..arm_end];
547 assert!(
548 partial_block.contains("best_effort") && partial_block.contains("Err"),
549 "PartiallyEnforced must return Err when best_effort=false. Block: {}",
550 partial_block
551 );
552 }
553}