nucleus/security/
landlock_policy.rs1use 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
32const TARGET_ABI: ABI = ABI::V5;
34
35#[derive(Debug, Clone, Deserialize)]
37pub struct LandlockPolicy {
38 #[serde(default = "default_min_abi")]
40 pub min_abi: u8,
41
42 #[serde(default)]
44 pub rules: Vec<LandlockRule>,
45}
46
47fn default_min_abi() -> u8 {
48 3
49}
50
51#[derive(Debug, Clone, Deserialize)]
53pub struct LandlockRule {
54 pub path: String,
56
57 pub access: Vec<String>,
59}
60
61impl LandlockPolicy {
62 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 pub fn apply(&self, best_effort: bool) -> Result<bool> {
89 let access_all = AccessFs::from_all(TARGET_ABI);
90
91 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
171fn 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
215fn 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}