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