nucleus/security/
landlock.rs1use 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
8const TARGET_ABI: ABI = ABI::V5;
11
12const MINIMUM_PRODUCTION_ABI: ABI = ABI::V3;
18
19pub struct LandlockManager {
29 applied: bool,
30}
31
32impl LandlockManager {
33 pub fn new() -> Self {
34 Self { applied: false }
35 }
36
37 pub fn apply_container_policy(&mut self) -> Result<bool> {
51 self.apply_container_policy_with_mode(false)
52 }
53
54 pub fn assert_minimum_abi(&self, production_mode: bool) -> Result<()> {
60 let min_access = AccessFs::from_all(MINIMUM_PRODUCTION_ABI);
64 let target_access = AccessFs::from_all(TARGET_ABI);
65
66 if min_access != target_access {
69 info!(
70 "Landlock ABI: target={:?}, minimum_production={:?}",
71 TARGET_ABI, MINIMUM_PRODUCTION_ABI
72 );
73 }
74
75 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 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 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 let access_read_exec = access_read | AccessFs::Execute;
163
164 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 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 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 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 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 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 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 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 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 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 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
266fn 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 let _ = mgr.apply_container_policy_with_mode(true);
286 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 let result = mgr.apply_container_policy_with_mode(true);
296 assert!(result.is_ok());
297 }
298
299 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 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 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 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 let source = include_str!("landlock.rs");
364 let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
365 let not_enforced_start = fn_body
367 .find("NotEnforced")
368 .expect("function must handle NotEnforced status");
369 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}