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 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
177fn 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
221fn 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}