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    /// Apply this policy, replacing the default hardcoded Landlock rules.
63    ///
64    /// Returns true if the policy was enforced (fully or partially),
65    /// false if not enforced (kernel too old).
66    pub fn apply(&self, best_effort: bool) -> Result<bool> {
67        let access_all = AccessFs::from_all(TARGET_ABI);
68
69        // Check minimum ABI
70        let min_abi_enum = abi_from_version(self.min_abi)?;
71        match Ruleset::default().handle_access(AccessFs::from_all(min_abi_enum)) {
72            Ok(_) => {
73                info!("Landlock ABI >= V{} confirmed", self.min_abi);
74            }
75            Err(e) => {
76                let msg = format!(
77                    "Kernel Landlock ABI below required V{}: {}",
78                    self.min_abi, e
79                );
80                if best_effort {
81                    warn!("{}", msg);
82                    return Ok(false);
83                } else {
84                    return Err(NucleusError::LandlockError(msg));
85                }
86            }
87        }
88
89        let mut ruleset = Ruleset::default()
90            .handle_access(access_all)
91            .map_err(ll_err)?
92            .create()
93            .map_err(ll_err)?;
94
95        for rule in &self.rules {
96            let flags = parse_access_flags(&rule.access)?;
97            match PathFd::new(&rule.path) {
98                Ok(fd) => {
99                    ruleset = ruleset
100                        .add_rule(PathBeneath::new(fd, flags))
101                        .map_err(ll_err)?;
102                    info!("Landlock rule: {} => {:?}", rule.path, rule.access);
103                }
104                Err(e) => {
105                    if best_effort {
106                        warn!(
107                            "Skipping Landlock rule for {:?} (path not accessible: {})",
108                            rule.path, e
109                        );
110                    } else {
111                        return Err(NucleusError::LandlockError(format!(
112                            "Cannot open path {:?} for Landlock rule: {}",
113                            rule.path, e
114                        )));
115                    }
116                }
117            }
118        }
119
120        let status = ruleset.restrict_self().map_err(ll_err)?;
121        match status.ruleset {
122            RulesetStatus::FullyEnforced => {
123                info!(
124                    "Landlock custom policy fully enforced ({} rules)",
125                    self.rules.len()
126                );
127                Ok(true)
128            }
129            RulesetStatus::PartiallyEnforced => {
130                info!("Landlock custom policy partially enforced");
131                Ok(true)
132            }
133            RulesetStatus::NotEnforced => {
134                warn!("Landlock custom policy not enforced (kernel unsupported)");
135                Ok(false)
136            }
137        }
138    }
139}
140
141/// Parse access flag strings into AccessFs bitflags.
142fn parse_access_flags(names: &[String]) -> Result<landlock::BitFlags<AccessFs>> {
143    let mut flags: landlock::BitFlags<AccessFs> = landlock::BitFlags::empty();
144    for name in names {
145        let flag: landlock::BitFlags<AccessFs> = match name.as_str() {
146            "read" => AccessFs::from_read(TARGET_ABI),
147            "write" => AccessFs::WriteFile | AccessFs::Truncate,
148            "execute" => AccessFs::Execute.into(),
149            "create" => {
150                AccessFs::MakeChar
151                    | AccessFs::MakeDir
152                    | AccessFs::MakeReg
153                    | AccessFs::MakeSock
154                    | AccessFs::MakeFifo
155                    | AccessFs::MakeSym
156                    | AccessFs::MakeBlock
157            }
158            "remove" => AccessFs::RemoveDir | AccessFs::RemoveFile,
159            "readdir" => AccessFs::ReadDir.into(),
160            "all" => {
161                tracing::warn!(
162                    "Landlock policy uses 'all' access flag which includes Execute. \
163                     Consider 'all_except_execute' for writable paths to prevent \
164                     drop-and-exec attacks."
165                );
166                AccessFs::from_all(TARGET_ABI)
167            }
168            "all_except_execute" => {
169                let mut a = AccessFs::from_all(TARGET_ABI);
170                a.remove(AccessFs::Execute);
171                a
172            }
173            _ => {
174                return Err(NucleusError::ConfigError(format!(
175                    "Unknown Landlock access flag: '{}'. Valid: read, write, execute, create, remove, readdir, all, all_except_execute",
176                    name
177                )));
178            }
179        };
180        flags |= flag;
181    }
182    Ok(flags)
183}
184
185/// Convert a numeric ABI version (1-5) to the landlock crate enum.
186fn abi_from_version(version: u8) -> Result<ABI> {
187    match version {
188        1 => Ok(ABI::V1),
189        2 => Ok(ABI::V2),
190        3 => Ok(ABI::V3),
191        4 => Ok(ABI::V4),
192        5 => Ok(ABI::V5),
193        _ => Err(NucleusError::ConfigError(format!(
194            "Invalid Landlock ABI version: {}. Valid: 1-5",
195            version
196        ))),
197    }
198}
199
200fn ll_err(e: landlock::RulesetError) -> NucleusError {
201    NucleusError::LandlockError(e.to_string())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_parse_minimal_policy() {
210        let toml = r#"
211[[rules]]
212path = "/tmp"
213access = ["read", "write"]
214"#;
215        let policy: LandlockPolicy = toml::from_str(toml).unwrap();
216        assert_eq!(policy.min_abi, 3);
217        assert_eq!(policy.rules.len(), 1);
218        assert_eq!(policy.rules[0].path, "/tmp");
219    }
220
221    #[test]
222    fn test_parse_full_policy() {
223        let toml = r#"
224min_abi = 5
225
226[[rules]]
227path = "/bin"
228access = ["read", "execute"]
229
230[[rules]]
231path = "/etc"
232access = ["read"]
233
234[[rules]]
235path = "/tmp"
236access = ["read", "write", "create", "remove"]
237"#;
238        let policy: LandlockPolicy = toml::from_str(toml).unwrap();
239        assert_eq!(policy.min_abi, 5);
240        assert_eq!(policy.rules.len(), 3);
241    }
242
243    #[test]
244    fn test_parse_access_flags_valid() {
245        let flags = parse_access_flags(&["read".into(), "execute".into()]);
246        assert!(flags.is_ok());
247    }
248
249    #[test]
250    fn test_parse_access_flags_invalid() {
251        let flags = parse_access_flags(&["destroy".into()]);
252        assert!(flags.is_err());
253    }
254
255    #[test]
256    fn test_abi_from_version() {
257        assert!(matches!(abi_from_version(1), Ok(ABI::V1)));
258        assert!(matches!(abi_from_version(5), Ok(ABI::V5)));
259        assert!(abi_from_version(0).is_err());
260        assert!(abi_from_version(6).is_err());
261    }
262
263    #[test]
264    fn test_all_except_execute_excludes_execute() {
265        let flags = parse_access_flags(&["all_except_execute".into()]).unwrap();
266        assert!(
267            !flags.contains(AccessFs::Execute),
268            "all_except_execute must not include Execute"
269        );
270        assert!(
271            flags.contains(AccessFs::WriteFile),
272            "all_except_execute must include WriteFile"
273        );
274        assert!(
275            flags.contains(AccessFs::ReadFile),
276            "all_except_execute must include ReadFile"
277        );
278    }
279
280    #[test]
281    fn test_all_includes_execute() {
282        let flags = parse_access_flags(&["all".into()]).unwrap();
283        assert!(
284            flags.contains(AccessFs::Execute),
285            "all must include Execute"
286        );
287    }
288
289    #[test]
290    fn test_default_min_abi() {
291        let toml = r#"
292[[rules]]
293path = "/"
294access = ["readdir"]
295"#;
296        let policy: LandlockPolicy = toml::from_str(toml).unwrap();
297        assert_eq!(policy.min_abi, 3);
298    }
299}