Skip to main content

nucleus/security/
landlock_policy.rs

1//! Landlock policy: external TOML-based filesystem access rules.
2//!
3//! Allows operators to define per-service Landlock rules in a standalone
4//! TOML file, replacing the hardcoded default policy.
5//!
6//! # Example
7//!
8//! ```toml
9//! min_abi = 3
10//!
11//! [[rules]]
12//! path = "/bin"
13//! access = ["read", "execute"]
14//!
15//! [[rules]]
16//! path = "/tmp"
17//! access = ["read", "write", "create", "remove"]
18//!
19//! [[rules]]
20//! path = "/run/secrets"
21//! access = ["read"]
22//! ```
23
24use crate::error::{NucleusError, Result};
25use landlock::{
26    Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus,
27    ABI,
28};
29use serde::Deserialize;
30use tracing::{info, warn};
31
32/// Target ABI for access flag construction.
33const TARGET_ABI: ABI = ABI::V5;
34
35/// Parsed Landlock policy from a TOML file.
36#[derive(Debug, Clone, Deserialize)]
37pub struct LandlockPolicy {
38    /// Minimum required ABI version (1-5). Default: 3.
39    #[serde(default = "default_min_abi")]
40    pub min_abi: u8,
41
42    /// Filesystem access rules.
43    #[serde(default)]
44    pub rules: Vec<LandlockRule>,
45}
46
47fn default_min_abi() -> u8 {
48    3
49}
50
51/// A single filesystem access rule.
52#[derive(Debug, Clone, Deserialize)]
53pub struct LandlockRule {
54    /// Path to grant access to.
55    pub path: String,
56
57    /// Access permissions: "read", "write", "execute", "create", "remove", "readdir".
58    pub access: Vec<String>,
59}
60
61impl LandlockPolicy {
62    /// Validate the policy for production safety.
63    ///
64    /// Rejects rules that grant both write and execute on the same path,
65    /// as this enables drop-and-exec attacks.
66    pub fn validate_production(&self) -> Result<()> {
67        for rule in &self.rules {
68            let flags = parse_access_flags(&rule.access)?;
69            let has_write =
70                flags.contains(AccessFs::WriteFile) || flags.contains(AccessFs::MakeReg);
71            let has_execute = flags.contains(AccessFs::Execute);
72            if has_write && has_execute {
73                return Err(NucleusError::ConfigError(format!(
74                    "Landlock policy grants both write and execute on '{}'. \
75                     This enables drop-and-exec attacks. Use separate rules or \
76                     'all_except_execute' for writable paths.",
77                    rule.path
78                )));
79            }
80        }
81        Ok(())
82    }
83
84    /// Apply this policy, replacing the default hardcoded Landlock rules.
85    ///
86    /// Returns true if the policy was enforced (fully or partially),
87    /// false if not enforced (kernel too old).
88    pub fn apply(&self, best_effort: bool) -> Result<bool> {
89        let access_all = AccessFs::from_all(TARGET_ABI);
90
91        // Check minimum ABI
92        let min_abi_enum = abi_from_version(self.min_abi)?;
93        match Ruleset::default().handle_access(AccessFs::from_all(min_abi_enum)) {
94            Ok(_) => {
95                info!("Landlock ABI >= V{} confirmed", self.min_abi);
96            }
97            Err(e) => {
98                let msg = format!(
99                    "Kernel Landlock ABI below required V{}: {}",
100                    self.min_abi, e
101                );
102                if best_effort {
103                    warn!("{}", msg);
104                    return Ok(false);
105                } else {
106                    return Err(NucleusError::LandlockError(msg));
107                }
108            }
109        }
110
111        let mut ruleset = Ruleset::default()
112            .handle_access(access_all)
113            .map_err(ll_err)?
114            .create()
115            .map_err(ll_err)?;
116
117        for rule in &self.rules {
118            let flags = parse_access_flags(&rule.access)?;
119            match PathFd::new(&rule.path) {
120                Ok(fd) => {
121                    ruleset = ruleset
122                        .add_rule(PathBeneath::new(fd, flags))
123                        .map_err(ll_err)?;
124                    info!("Landlock rule: {} => {:?}", rule.path, rule.access);
125                }
126                Err(e) => {
127                    if best_effort {
128                        warn!(
129                            "Skipping Landlock rule for {:?} (path not accessible: {})",
130                            rule.path, e
131                        );
132                    } else {
133                        return Err(NucleusError::LandlockError(format!(
134                            "Cannot open path {:?} for Landlock rule: {}",
135                            rule.path, e
136                        )));
137                    }
138                }
139            }
140        }
141
142        let status = ruleset.restrict_self().map_err(ll_err)?;
143        match status.ruleset {
144            RulesetStatus::FullyEnforced => {
145                info!(
146                    "Landlock custom policy fully enforced ({} rules)",
147                    self.rules.len()
148                );
149                Ok(true)
150            }
151            RulesetStatus::PartiallyEnforced => {
152                if best_effort {
153                    info!("Landlock custom policy partially enforced");
154                    Ok(true)
155                } else {
156                    Err(NucleusError::LandlockError(
157                        "Landlock custom policy only partially enforced; strict mode requires full target ABI support".to_string(),
158                    ))
159                }
160            }
161            RulesetStatus::NotEnforced => {
162                if best_effort {
163                    warn!("Landlock custom policy not enforced (kernel unsupported)");
164                    Ok(false)
165                } else {
166                    Err(NucleusError::LandlockError(
167                        "Landlock custom policy not enforced (kernel unsupported) \
168                         and best_effort=false"
169                            .to_string(),
170                    ))
171                }
172            }
173        }
174    }
175}
176
177/// Parse access flag strings into AccessFs bitflags.
178fn parse_access_flags(names: &[String]) -> Result<landlock::BitFlags<AccessFs>> {
179    let mut flags: landlock::BitFlags<AccessFs> = landlock::BitFlags::empty();
180    for name in names {
181        let flag: landlock::BitFlags<AccessFs> = match name.as_str() {
182            "read" => AccessFs::from_read(TARGET_ABI),
183            "write" => AccessFs::WriteFile | AccessFs::Truncate,
184            "execute" => AccessFs::Execute.into(),
185            "create" => {
186                AccessFs::MakeChar
187                    | AccessFs::MakeDir
188                    | AccessFs::MakeReg
189                    | AccessFs::MakeSock
190                    | AccessFs::MakeFifo
191                    | AccessFs::MakeSym
192                    | AccessFs::MakeBlock
193            }
194            "remove" => AccessFs::RemoveDir | AccessFs::RemoveFile,
195            "readdir" => AccessFs::ReadDir.into(),
196            "all" => {
197                tracing::warn!(
198                    "Landlock policy uses 'all' access flag which includes Execute. \
199                     Consider 'all_except_execute' for writable paths to prevent \
200                     drop-and-exec attacks."
201                );
202                AccessFs::from_all(TARGET_ABI)
203            }
204            "all_except_execute" => {
205                let mut a = AccessFs::from_all(TARGET_ABI);
206                a.remove(AccessFs::Execute);
207                a
208            }
209            _ => {
210                return Err(NucleusError::ConfigError(format!(
211                    "Unknown Landlock access flag: '{}'. Valid: read, write, execute, create, remove, readdir, all, all_except_execute",
212                    name
213                )));
214            }
215        };
216        flags |= flag;
217    }
218    Ok(flags)
219}
220
221/// Convert a numeric ABI version (1-5) to the landlock crate enum.
222fn abi_from_version(version: u8) -> Result<ABI> {
223    match version {
224        1 => Ok(ABI::V1),
225        2 => Ok(ABI::V2),
226        3 => Ok(ABI::V3),
227        4 => Ok(ABI::V4),
228        5 => Ok(ABI::V5),
229        _ => Err(NucleusError::ConfigError(format!(
230            "Invalid Landlock ABI version: {}. Valid: 1-5",
231            version
232        ))),
233    }
234}
235
236fn ll_err(e: landlock::RulesetError) -> NucleusError {
237    NucleusError::LandlockError(e.to_string())
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_parse_minimal_policy() {
246        let toml = r#"
247[[rules]]
248path = "/tmp"
249access = ["read", "write"]
250"#;
251        let policy: LandlockPolicy = toml::from_str(toml).unwrap();
252        assert_eq!(policy.min_abi, 3);
253        assert_eq!(policy.rules.len(), 1);
254        assert_eq!(policy.rules[0].path, "/tmp");
255    }
256
257    #[test]
258    fn test_parse_full_policy() {
259        let toml = r#"
260min_abi = 5
261
262[[rules]]
263path = "/bin"
264access = ["read", "execute"]
265
266[[rules]]
267path = "/etc"
268access = ["read"]
269
270[[rules]]
271path = "/tmp"
272access = ["read", "write", "create", "remove"]
273"#;
274        let policy: LandlockPolicy = toml::from_str(toml).unwrap();
275        assert_eq!(policy.min_abi, 5);
276        assert_eq!(policy.rules.len(), 3);
277    }
278
279    #[test]
280    fn test_parse_access_flags_valid() {
281        let flags = parse_access_flags(&["read".into(), "execute".into()]);
282        assert!(flags.is_ok());
283    }
284
285    #[test]
286    fn test_parse_access_flags_invalid() {
287        let flags = parse_access_flags(&["destroy".into()]);
288        assert!(flags.is_err());
289    }
290
291    #[test]
292    fn test_abi_from_version() {
293        assert!(matches!(abi_from_version(1), Ok(ABI::V1)));
294        assert!(matches!(abi_from_version(5), Ok(ABI::V5)));
295        assert!(abi_from_version(0).is_err());
296        assert!(abi_from_version(6).is_err());
297    }
298
299    #[test]
300    fn test_all_except_execute_excludes_execute() {
301        let flags = parse_access_flags(&["all_except_execute".into()]).unwrap();
302        assert!(
303            !flags.contains(AccessFs::Execute),
304            "all_except_execute must not include Execute"
305        );
306        assert!(
307            flags.contains(AccessFs::WriteFile),
308            "all_except_execute must include WriteFile"
309        );
310        assert!(
311            flags.contains(AccessFs::ReadFile),
312            "all_except_execute must include ReadFile"
313        );
314    }
315
316    #[test]
317    fn test_all_includes_execute() {
318        let flags = parse_access_flags(&["all".into()]).unwrap();
319        assert!(
320            flags.contains(AccessFs::Execute),
321            "all must include Execute"
322        );
323    }
324
325    #[test]
326    fn test_default_min_abi() {
327        let toml = r#"
328[[rules]]
329path = "/"
330access = ["readdir"]
331"#;
332        let policy: LandlockPolicy = toml::from_str(toml).unwrap();
333        assert_eq!(policy.min_abi, 3);
334    }
335}