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                info!("Landlock custom policy partially enforced");
153                Ok(true)
154            }
155            RulesetStatus::NotEnforced => {
156                if best_effort {
157                    warn!("Landlock custom policy not enforced (kernel unsupported)");
158                    Ok(false)
159                } else {
160                    Err(NucleusError::LandlockError(
161                        "Landlock custom policy not enforced (kernel unsupported) \
162                         and best_effort=false"
163                            .to_string(),
164                    ))
165                }
166            }
167        }
168    }
169}
170
171/// Parse access flag strings into AccessFs bitflags.
172fn parse_access_flags(names: &[String]) -> Result<landlock::BitFlags<AccessFs>> {
173    let mut flags: landlock::BitFlags<AccessFs> = landlock::BitFlags::empty();
174    for name in names {
175        let flag: landlock::BitFlags<AccessFs> = match name.as_str() {
176            "read" => AccessFs::from_read(TARGET_ABI),
177            "write" => AccessFs::WriteFile | AccessFs::Truncate,
178            "execute" => AccessFs::Execute.into(),
179            "create" => {
180                AccessFs::MakeChar
181                    | AccessFs::MakeDir
182                    | AccessFs::MakeReg
183                    | AccessFs::MakeSock
184                    | AccessFs::MakeFifo
185                    | AccessFs::MakeSym
186                    | AccessFs::MakeBlock
187            }
188            "remove" => AccessFs::RemoveDir | AccessFs::RemoveFile,
189            "readdir" => AccessFs::ReadDir.into(),
190            "all" => {
191                tracing::warn!(
192                    "Landlock policy uses 'all' access flag which includes Execute. \
193                     Consider 'all_except_execute' for writable paths to prevent \
194                     drop-and-exec attacks."
195                );
196                AccessFs::from_all(TARGET_ABI)
197            }
198            "all_except_execute" => {
199                let mut a = AccessFs::from_all(TARGET_ABI);
200                a.remove(AccessFs::Execute);
201                a
202            }
203            _ => {
204                return Err(NucleusError::ConfigError(format!(
205                    "Unknown Landlock access flag: '{}'. Valid: read, write, execute, create, remove, readdir, all, all_except_execute",
206                    name
207                )));
208            }
209        };
210        flags |= flag;
211    }
212    Ok(flags)
213}
214
215/// Convert a numeric ABI version (1-5) to the landlock crate enum.
216fn abi_from_version(version: u8) -> Result<ABI> {
217    match version {
218        1 => Ok(ABI::V1),
219        2 => Ok(ABI::V2),
220        3 => Ok(ABI::V3),
221        4 => Ok(ABI::V4),
222        5 => Ok(ABI::V5),
223        _ => Err(NucleusError::ConfigError(format!(
224            "Invalid Landlock ABI version: {}. Valid: 1-5",
225            version
226        ))),
227    }
228}
229
230fn ll_err(e: landlock::RulesetError) -> NucleusError {
231    NucleusError::LandlockError(e.to_string())
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_parse_minimal_policy() {
240        let toml = r#"
241[[rules]]
242path = "/tmp"
243access = ["read", "write"]
244"#;
245        let policy: LandlockPolicy = toml::from_str(toml).unwrap();
246        assert_eq!(policy.min_abi, 3);
247        assert_eq!(policy.rules.len(), 1);
248        assert_eq!(policy.rules[0].path, "/tmp");
249    }
250
251    #[test]
252    fn test_parse_full_policy() {
253        let toml = r#"
254min_abi = 5
255
256[[rules]]
257path = "/bin"
258access = ["read", "execute"]
259
260[[rules]]
261path = "/etc"
262access = ["read"]
263
264[[rules]]
265path = "/tmp"
266access = ["read", "write", "create", "remove"]
267"#;
268        let policy: LandlockPolicy = toml::from_str(toml).unwrap();
269        assert_eq!(policy.min_abi, 5);
270        assert_eq!(policy.rules.len(), 3);
271    }
272
273    #[test]
274    fn test_parse_access_flags_valid() {
275        let flags = parse_access_flags(&["read".into(), "execute".into()]);
276        assert!(flags.is_ok());
277    }
278
279    #[test]
280    fn test_parse_access_flags_invalid() {
281        let flags = parse_access_flags(&["destroy".into()]);
282        assert!(flags.is_err());
283    }
284
285    #[test]
286    fn test_abi_from_version() {
287        assert!(matches!(abi_from_version(1), Ok(ABI::V1)));
288        assert!(matches!(abi_from_version(5), Ok(ABI::V5)));
289        assert!(abi_from_version(0).is_err());
290        assert!(abi_from_version(6).is_err());
291    }
292
293    #[test]
294    fn test_all_except_execute_excludes_execute() {
295        let flags = parse_access_flags(&["all_except_execute".into()]).unwrap();
296        assert!(
297            !flags.contains(AccessFs::Execute),
298            "all_except_execute must not include Execute"
299        );
300        assert!(
301            flags.contains(AccessFs::WriteFile),
302            "all_except_execute must include WriteFile"
303        );
304        assert!(
305            flags.contains(AccessFs::ReadFile),
306            "all_except_execute must include ReadFile"
307        );
308    }
309
310    #[test]
311    fn test_all_includes_execute() {
312        let flags = parse_access_flags(&["all".into()]).unwrap();
313        assert!(
314            flags.contains(AccessFs::Execute),
315            "all must include Execute"
316        );
317    }
318
319    #[test]
320    fn test_default_min_abi() {
321        let toml = r#"
322[[rules]]
323path = "/"
324access = ["readdir"]
325"#;
326        let policy: LandlockPolicy = toml::from_str(toml).unwrap();
327        assert_eq!(policy.min_abi, 3);
328    }
329}