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 apply(&self, best_effort: bool) -> Result<bool> {
67 let access_all = AccessFs::from_all(TARGET_ABI);
68
69 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
141fn 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
185fn 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!(!flags.contains(AccessFs::Execute), "all_except_execute must not include Execute");
267 assert!(flags.contains(AccessFs::WriteFile), "all_except_execute must include WriteFile");
268 assert!(flags.contains(AccessFs::ReadFile), "all_except_execute must include ReadFile");
269 }
270
271 #[test]
272 fn test_all_includes_execute() {
273 let flags = parse_access_flags(&["all".into()]).unwrap();
274 assert!(flags.contains(AccessFs::Execute), "all must include Execute");
275 }
276
277 #[test]
278 fn test_default_min_abi() {
279 let toml = r#"
280[[rules]]
281path = "/"
282access = ["readdir"]
283"#;
284 let policy: LandlockPolicy = toml::from_str(toml).unwrap();
285 assert_eq!(policy.min_abi, 3);
286 }
287}