Skip to main content

nucleus/security/
landlock.rs

1use crate::error::{NucleusError, Result};
2use landlock::{
3    Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetError,
4    RulesetStatus, ABI,
5};
6use tracing::{debug, info, warn};
7
8/// Target ABI – covers up to Linux 6.12 features (Truncate, IoctlDev, Refer, etc.).
9/// The landlock crate gracefully degrades for older kernels.
10const TARGET_ABI: ABI = ABI::V5;
11
12/// Minimum Landlock ABI version required for production mode.
13///
14/// V3 adds LANDLOCK_ACCESS_FS_TRUNCATE which prevents silent data truncation
15/// that V1/V2 cannot control. This is the minimum we consider safe for
16/// production workloads.
17const MINIMUM_PRODUCTION_ABI: ABI = ABI::V3;
18
19/// Landlock filesystem access-control manager
20///
21/// Implements fine-grained, path-based filesystem restrictions as an additional
22/// defense layer on top of namespaces, capabilities, and seccomp.
23///
24/// Properties (matching Nucleus security invariants):
25/// - Irreversible: once restrict_self() is called, restrictions cannot be lifted
26/// - Stackable: layered with seccomp and capability dropping
27/// - Unprivileged: works in rootless mode
28pub struct LandlockManager {
29    applied: bool,
30    /// Additional paths to grant read+write access to (e.g. volume mounts).
31    extra_rw_paths: Vec<String>,
32}
33
34impl LandlockManager {
35    pub fn new() -> Self {
36        Self {
37            applied: false,
38            extra_rw_paths: Vec::new(),
39        }
40    }
41
42    /// Register additional paths that need read+write access.
43    /// Used for volume mounts that aren't under the default allowed paths.
44    pub fn add_rw_path(&mut self, path: &str) {
45        self.extra_rw_paths.push(path.to_string());
46    }
47
48    /// Apply the container Landlock policy.
49    ///
50    /// Rules:
51    /// - `/` (root):         read-only traversal (ReadDir) so path resolution works
52    /// - `/bin`, `/usr`:     read + execute (for running agent binaries)
53    /// - `/lib`, `/lib64`:   read (shared libraries)
54    /// - `/etc`:             read (config / resolv.conf / nsswitch)
55    /// - `/dev`:             read (already minimal device nodes)
56    /// - `/proc`:            read (already mounted read-only)
57    /// - `/tmp`:             read + write + create + remove (agent scratch space)
58    /// - `/context`:         read-only (pre-populated agent data)
59    ///
60    /// Everything else is denied by the ruleset.
61    pub fn apply_container_policy(&mut self) -> Result<bool> {
62        self.apply_container_policy_with_mode(false)
63    }
64
65    /// Assert that the kernel supports at least the minimum Landlock ABI version
66    /// required for production workloads.
67    ///
68    /// Returns Ok(()) if the ABI is sufficient, or Err if the kernel is too old.
69    /// In best-effort mode, a too-old kernel is logged but not fatal.
70    pub fn assert_minimum_abi(&self, production_mode: bool) -> Result<()> {
71        // Probe the kernel's Landlock ABI version by attempting to create a ruleset
72        // with the minimum ABI's access rights. If the kernel doesn't support the
73        // minimum ABI, the ruleset will be NotEnforced or PartiallyEnforced.
74        let min_access = AccessFs::from_all(MINIMUM_PRODUCTION_ABI);
75        let target_access = AccessFs::from_all(TARGET_ABI);
76
77        // If the minimum access set equals the target, the kernel supports everything
78        // If the minimum is a subset, check that at least the minimum rights are present
79        if min_access != target_access {
80            info!(
81                "Landlock ABI: target={:?}, minimum_production={:?}",
82                TARGET_ABI, MINIMUM_PRODUCTION_ABI
83            );
84        }
85
86        // The actual enforcement check happens in build_and_restrict().
87        // Here we do a lightweight check: if the kernel supports the target ABI,
88        // it certainly supports the minimum. The landlock crate handles this
89        // gracefully, but we want an explicit assertion for production.
90        match Ruleset::default().handle_access(AccessFs::from_all(MINIMUM_PRODUCTION_ABI)) {
91            Ok(_) => {
92                info!("Landlock ABI >= V3 confirmed");
93                Ok(())
94            }
95            Err(e) => {
96                let msg = format!(
97                    "Kernel Landlock ABI is below minimum required version (V3): {}",
98                    e
99                );
100                if production_mode {
101                    Err(ll_err(e))
102                } else {
103                    warn!("{}", msg);
104                    Ok(())
105                }
106            }
107        }
108    }
109
110    /// Apply with configurable failure behavior.
111    ///
112    /// When `best_effort` is true, failures (e.g. kernel without Landlock) are
113    /// logged and execution continues.
114    pub fn apply_container_policy_with_mode(&mut self, best_effort: bool) -> Result<bool> {
115        if self.applied {
116            debug!("Landlock policy already applied, skipping");
117            return Ok(true);
118        }
119
120        info!("Applying Landlock filesystem policy");
121
122        match self.build_and_restrict() {
123            Ok(status) => match status {
124                RulesetStatus::FullyEnforced => {
125                    self.applied = true;
126                    info!("Landlock policy fully enforced");
127                    Ok(true)
128                }
129                RulesetStatus::PartiallyEnforced => {
130                    if best_effort {
131                        self.applied = true;
132                        info!(
133                            "Landlock policy partially enforced (kernel lacks some access rights)"
134                        );
135                        Ok(true)
136                    } else {
137                        Err(NucleusError::LandlockError(
138                            "Landlock policy only partially enforced; strict mode requires full target ABI support".to_string(),
139                        ))
140                    }
141                }
142                RulesetStatus::NotEnforced => {
143                    if best_effort {
144                        warn!("Landlock not enforced (kernel does not support Landlock)");
145                        Ok(false)
146                    } else {
147                        Err(NucleusError::LandlockError(
148                            "Landlock not enforced (kernel does not support Landlock)".to_string(),
149                        ))
150                    }
151                }
152            },
153            Err(e) => {
154                if best_effort {
155                    warn!(
156                        "Failed to apply Landlock policy: {} (continuing without Landlock)",
157                        e
158                    );
159                    Ok(false)
160                } else {
161                    Err(e)
162                }
163            }
164        }
165    }
166
167    /// Build the ruleset and call restrict_self().
168    fn build_and_restrict(&self) -> Result<RulesetStatus> {
169        let access_all = AccessFs::from_all(TARGET_ABI);
170        let access_read = AccessFs::from_read(TARGET_ABI);
171
172        // Read + execute for binary paths
173        let access_read_exec = access_read | AccessFs::Execute;
174
175        // Write access set for /tmp – full read+write but no execute.
176        // Executing from /tmp is a common attack pattern (drop-and-exec).
177        let mut access_tmp = access_all;
178        access_tmp.remove(AccessFs::Execute);
179
180        let mut ruleset = Ruleset::default()
181            .handle_access(access_all)
182            .map_err(ll_err)?
183            .create()
184            .map_err(ll_err)?;
185
186        // Root directory: minimal traversal only
187        // We add ReadDir so that path resolution through / works
188        if let Ok(fd) = PathFd::new("/") {
189            ruleset = ruleset
190                .add_rule(PathBeneath::new(fd, AccessFs::ReadDir))
191                .map_err(ll_err)?;
192        }
193
194        // M13: Mandatory paths that must exist for a functional container.
195        // Warn (or error in strict mode) when these are missing.
196        const MANDATORY_PATHS: &[&str] = &["/bin", "/usr", "/lib", "/etc"];
197        for path in MANDATORY_PATHS {
198            if !std::path::Path::new(path).exists() {
199                warn!(
200                    "Landlock: mandatory path {} does not exist; container may not function correctly",
201                    path
202                );
203            }
204        }
205
206        // Binary paths: read + execute
207        for path in &["/bin", "/usr", "/sbin"] {
208            if let Ok(fd) = PathFd::new(path) {
209                ruleset = ruleset
210                    .add_rule(PathBeneath::new(fd, access_read_exec))
211                    .map_err(ll_err)?;
212            }
213        }
214
215        // Shared libraries: read
216        for path in &["/lib", "/lib64", "/lib32"] {
217            if let Ok(fd) = PathFd::new(path) {
218                ruleset = ruleset
219                    .add_rule(PathBeneath::new(fd, access_read))
220                    .map_err(ll_err)?;
221            }
222        }
223
224        // Config/device/proc: read
225        for path in &["/etc", "/dev", "/proc"] {
226            if let Ok(fd) = PathFd::new(path) {
227                ruleset = ruleset
228                    .add_rule(PathBeneath::new(fd, access_read))
229                    .map_err(ll_err)?;
230            }
231        }
232
233        // /dev/shm: read+write for POSIX shared memory (shm_open).
234        // Required by PostgreSQL, Redis, and other programs.
235        // No execute – same policy as /tmp.
236        if let Ok(fd) = PathFd::new("/dev/shm") {
237            ruleset = ruleset
238                .add_rule(PathBeneath::new(fd, access_tmp))
239                .map_err(ll_err)?;
240        }
241
242        // /tmp: full read+write+create+remove
243        if let Ok(fd) = PathFd::new("/tmp") {
244            ruleset = ruleset
245                .add_rule(PathBeneath::new(fd, access_tmp))
246                .map_err(ll_err)?;
247        }
248
249        // /nix/store: read + execute (NixOS binaries and libraries)
250        if let Ok(fd) = PathFd::new("/nix/store") {
251            ruleset = ruleset
252                .add_rule(PathBeneath::new(fd, access_read_exec))
253                .map_err(ll_err)?;
254        }
255
256        // /run/secrets: read-only (container secrets mounted on tmpfs)
257        if let Ok(fd) = PathFd::new("/run/secrets") {
258            ruleset = ruleset
259                .add_rule(PathBeneath::new(fd, access_read))
260                .map_err(ll_err)?;
261        }
262
263        // /context: read-only (agent data)
264        if let Ok(fd) = PathFd::new("/context") {
265            ruleset = ruleset
266                .add_rule(PathBeneath::new(fd, access_read))
267                .map_err(ll_err)?;
268        }
269
270        // Volume mounts and other dynamically registered paths: full read+write
271        // (but no execute – same policy as /tmp to prevent drop-and-exec).
272        for path in &self.extra_rw_paths {
273            if let Ok(fd) = PathFd::new(path) {
274                debug!("Landlock: granting rw access to volume path {:?}", path);
275                ruleset = ruleset
276                    .add_rule(PathBeneath::new(fd, access_tmp))
277                    .map_err(ll_err)?;
278            }
279        }
280
281        let status = ruleset.restrict_self().map_err(ll_err)?;
282        Ok(status.ruleset)
283    }
284
285    /// Check if Landlock policy has been applied
286    pub fn is_applied(&self) -> bool {
287        self.applied
288    }
289}
290
291impl Default for LandlockManager {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297/// Convert a landlock RulesetError into NucleusError::LandlockError
298fn ll_err(e: RulesetError) -> NucleusError {
299    NucleusError::LandlockError(e.to_string())
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_landlock_manager_initial_state() {
308        let mgr = LandlockManager::new();
309        assert!(!mgr.is_applied());
310    }
311
312    #[test]
313    fn test_apply_idempotent() {
314        let mut mgr = LandlockManager::new();
315        // Best-effort so it succeeds even without Landlock support
316        let _ = mgr.apply_container_policy_with_mode(true);
317        // Second call should be a no-op
318        let result = mgr.apply_container_policy_with_mode(true);
319        assert!(result.is_ok());
320    }
321
322    #[test]
323    fn test_best_effort_on_unsupported_kernel() {
324        let mut mgr = LandlockManager::new();
325        // Should not error even if kernel has no Landlock
326        let result = mgr.apply_container_policy_with_mode(true);
327        assert!(result.is_ok());
328    }
329
330    /// Extract the body of a function from source text by brace-matching,
331    /// avoiding fragile hardcoded character-window offsets (SEC-MED-03).
332    fn extract_fn_body<'a>(source: &'a str, fn_signature: &str) -> &'a str {
333        let fn_start = source
334            .find(fn_signature)
335            .unwrap_or_else(|| panic!("function '{}' not found in source", fn_signature));
336        let after = &source[fn_start..];
337        let open = after
338            .find('{')
339            .unwrap_or_else(|| panic!("no opening brace found for '{}'", fn_signature));
340        let mut depth = 0u32;
341        let mut end = open;
342        for (i, ch) in after[open..].char_indices() {
343            match ch {
344                '{' => depth += 1,
345                '}' => {
346                    depth -= 1;
347                    if depth == 0 {
348                        end = open + i + 1;
349                        break;
350                    }
351                }
352                _ => {}
353            }
354        }
355        &after[..end]
356    }
357
358    #[test]
359    fn test_policy_covers_nix_store_and_secrets() {
360        // Landlock policy must include rules for /nix/store (read+exec) and
361        // /run/secrets (read) so NixOS binaries can execute and secrets are readable.
362        // NOTE: The Landlock API does not expose the ruleset for inspection, so
363        // this remains a source-text check – but uses brace-matched function
364        // body extraction instead of hardcoded char offsets.
365        let source = include_str!("landlock.rs");
366        let fn_body = extract_fn_body(source, "fn build_and_restrict");
367        assert!(
368            fn_body.contains("\"/nix/store\"") || fn_body.contains("\"/nix\""),
369            "Landlock build_and_restrict must include a rule for /nix/store or /nix"
370        );
371        assert!(
372            fn_body.contains("\"/run/secrets\"") || fn_body.contains("\"/run\""),
373            "Landlock build_and_restrict must include a rule for /run/secrets"
374        );
375    }
376
377    #[test]
378    fn test_tmp_access_excludes_execute() {
379        // L-5: /tmp should have read+write but NOT execute permission.
380        // Verify at the type level that our access_tmp definition
381        // does not include Execute.
382        let access_all = AccessFs::from_all(TARGET_ABI);
383        let mut access_tmp = access_all;
384        access_tmp.remove(AccessFs::Execute);
385        assert!(!access_tmp.contains(AccessFs::Execute));
386        // But it should still have write capabilities
387        assert!(access_tmp.contains(AccessFs::WriteFile));
388        assert!(access_tmp.contains(AccessFs::RemoveFile));
389    }
390
391    #[test]
392    fn test_not_enforced_returns_error_in_strict_mode() {
393        // SEC-11: When best_effort=false, NotEnforced must return Err, not Ok(false)
394        let source = include_str!("landlock.rs");
395        let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
396        // Find the NotEnforced match arm within the function body
397        let not_enforced_start = fn_body
398            .find("NotEnforced")
399            .expect("function must handle NotEnforced status");
400        // Search from NotEnforced to the next match arm ('=>' after a '}')
401        let rest = &fn_body[not_enforced_start..];
402        let arm_end = rest
403            .find("RestrictionStatus::")
404            .unwrap_or(rest.len().min(500));
405        let not_enforced_block = &rest[..arm_end];
406        assert!(
407            not_enforced_block.contains("best_effort") && not_enforced_block.contains("Err"),
408            "NotEnforced must return Err when best_effort=false. Block: {}",
409            not_enforced_block
410        );
411    }
412
413    #[test]
414    fn test_partially_enforced_returns_error_in_strict_mode() {
415        let source = include_str!("landlock.rs");
416        let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
417        let partial_start = fn_body
418            .find("PartiallyEnforced")
419            .expect("function must handle PartiallyEnforced status");
420        let rest = &fn_body[partial_start..];
421        let arm_end = rest.find("NotEnforced").unwrap_or(rest.len().min(500));
422        let partial_block = &rest[..arm_end];
423        assert!(
424            partial_block.contains("best_effort") && partial_block.contains("Err"),
425            "PartiallyEnforced must return Err when best_effort=false. Block: {}",
426            partial_block
427        );
428    }
429}