1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
10pub enum SeccompMode {
11 #[default]
13 Default,
14 Unconfined,
16 Custom(String),
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SecurityConfig {
23 pub seccomp: SeccompMode,
25 pub no_new_privileges: bool,
27 pub cap_add: Vec<String>,
29 pub cap_drop: Vec<String>,
31 pub privileged: bool,
33}
34
35impl Default for SecurityConfig {
36 fn default() -> Self {
37 Self {
38 seccomp: SeccompMode::Default,
39 no_new_privileges: true, cap_add: vec![],
41 cap_drop: vec![],
42 privileged: false,
43 }
44 }
45}
46
47impl SecurityConfig {
48 pub fn validate(&self) -> Result<(), String> {
53 if let SeccompMode::Custom(path) = &self.seccomp {
54 return Err(format!(
55 "custom seccomp profile '{}' is not supported; \
56 use seccomp=default or seccomp=unconfined",
57 path
58 ));
59 }
60 Ok(())
61 }
62
63 pub fn from_options(
71 security_opt: &[String],
72 cap_add: &[String],
73 cap_drop: &[String],
74 privileged: bool,
75 ) -> Self {
76 if privileged {
77 return Self {
78 seccomp: SeccompMode::Unconfined,
79 no_new_privileges: false,
80 cap_add: vec!["ALL".to_string()],
81 cap_drop: vec![],
82 privileged: true,
83 };
84 }
85
86 let mut config = Self {
87 cap_add: cap_add.to_vec(),
88 cap_drop: cap_drop.to_vec(),
89 ..Self::default()
90 };
91
92 for opt in security_opt {
93 let opt = opt.trim();
94 if let Some(value) = opt.strip_prefix("seccomp=") {
95 config.seccomp = if value == "unconfined" {
96 SeccompMode::Unconfined
97 } else {
98 SeccompMode::Custom(value.to_string())
99 };
100 } else if opt == "no-new-privileges" || opt == "no-new-privileges=true" {
101 config.no_new_privileges = true;
102 } else if opt == "no-new-privileges=false" {
103 config.no_new_privileges = false;
104 } else if opt.starts_with("apparmor=") {
105 tracing::warn!(
106 opt = %opt,
107 "AppArmor profiles are not supported in a3s-box; option ignored"
108 );
109 } else if opt.starts_with("label=") {
110 tracing::warn!(
111 opt = %opt,
112 "SELinux labels are not supported in a3s-box; option ignored"
113 );
114 }
115 }
116
117 config
118 }
119
120 pub fn to_env_vars(&self) -> Vec<(String, String)> {
124 let mut env = Vec::new();
125
126 let seccomp_value = match &self.seccomp {
128 SeccompMode::Default => "default".to_string(),
129 SeccompMode::Unconfined => "unconfined".to_string(),
130 SeccompMode::Custom(path) => format!("custom:{}", path),
131 };
132 env.push(("A3S_SEC_SECCOMP".to_string(), seccomp_value));
133
134 env.push((
136 "A3S_SEC_NO_NEW_PRIVS".to_string(),
137 if self.no_new_privileges { "1" } else { "0" }.to_string(),
138 ));
139
140 if self.privileged {
142 env.push(("A3S_SEC_PRIVILEGED".to_string(), "1".to_string()));
143 }
144
145 if !self.cap_add.is_empty() {
147 env.push(("A3S_SEC_CAP_ADD".to_string(), self.cap_add.join(",")));
148 }
149 if !self.cap_drop.is_empty() {
150 env.push(("A3S_SEC_CAP_DROP".to_string(), self.cap_drop.join(",")));
151 }
152
153 env
154 }
155
156 pub fn from_env_vars() -> Self {
158 let mut config = Self::default();
159
160 if let Ok(val) = std::env::var("A3S_SEC_PRIVILEGED") {
161 if val == "1" {
162 return Self {
163 seccomp: SeccompMode::Unconfined,
164 no_new_privileges: false,
165 cap_add: vec!["ALL".to_string()],
166 cap_drop: vec![],
167 privileged: true,
168 };
169 }
170 }
171
172 if let Ok(val) = std::env::var("A3S_SEC_SECCOMP") {
173 config.seccomp = if val == "unconfined" {
174 SeccompMode::Unconfined
175 } else if val == "default" {
176 SeccompMode::Default
177 } else if let Some(path) = val.strip_prefix("custom:") {
178 SeccompMode::Custom(path.to_string())
179 } else {
180 SeccompMode::Default
181 };
182 }
183
184 if let Ok(val) = std::env::var("A3S_SEC_NO_NEW_PRIVS") {
185 config.no_new_privileges = val == "1";
186 }
187
188 if let Ok(val) = std::env::var("A3S_SEC_CAP_ADD") {
189 config.cap_add = val.split(',').map(|s| s.trim().to_string()).collect();
190 }
191
192 if let Ok(val) = std::env::var("A3S_SEC_CAP_DROP") {
193 config.cap_drop = val.split(',').map(|s| s.trim().to_string()).collect();
194 }
195
196 config
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn test_security_config_default() {
206 let config = SecurityConfig::default();
207 assert_eq!(config.seccomp, SeccompMode::Default);
208 assert!(config.no_new_privileges);
209 assert!(config.cap_add.is_empty());
210 assert!(config.cap_drop.is_empty());
211 assert!(!config.privileged);
212 }
213
214 #[test]
215 fn test_seccomp_mode_default() {
216 assert_eq!(SeccompMode::default(), SeccompMode::Default);
217 }
218
219 #[test]
220 fn test_from_options_empty() {
221 let config = SecurityConfig::from_options(&[], &[], &[], false);
222 assert_eq!(config.seccomp, SeccompMode::Default);
223 assert!(config.no_new_privileges);
224 assert!(!config.privileged);
225 }
226
227 #[test]
228 fn test_from_options_seccomp_unconfined() {
229 let opts = vec!["seccomp=unconfined".to_string()];
230 let config = SecurityConfig::from_options(&opts, &[], &[], false);
231 assert_eq!(config.seccomp, SeccompMode::Unconfined);
232 }
233
234 #[test]
235 fn test_from_options_seccomp_custom() {
236 let opts = vec!["seccomp=/path/to/profile.json".to_string()];
237 let config = SecurityConfig::from_options(&opts, &[], &[], false);
238 assert_eq!(
239 config.seccomp,
240 SeccompMode::Custom("/path/to/profile.json".to_string())
241 );
242 }
243
244 #[test]
245 fn test_from_options_no_new_privileges() {
246 let opts = vec!["no-new-privileges".to_string()];
247 let config = SecurityConfig::from_options(&opts, &[], &[], false);
248 assert!(config.no_new_privileges);
249 }
250
251 #[test]
252 fn test_from_options_no_new_privileges_false() {
253 let opts = vec!["no-new-privileges=false".to_string()];
254 let config = SecurityConfig::from_options(&opts, &[], &[], false);
255 assert!(!config.no_new_privileges);
256 }
257
258 #[test]
259 fn test_from_options_privileged() {
260 let config = SecurityConfig::from_options(&[], &[], &[], true);
261 assert!(config.privileged);
262 assert_eq!(config.seccomp, SeccompMode::Unconfined);
263 assert!(!config.no_new_privileges);
264 assert_eq!(config.cap_add, vec!["ALL"]);
265 }
266
267 #[test]
268 fn test_from_options_capabilities() {
269 let cap_add = vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()];
270 let cap_drop = vec!["NET_RAW".to_string()];
271 let config = SecurityConfig::from_options(&[], &cap_add, &cap_drop, false);
272 assert_eq!(config.cap_add, vec!["NET_ADMIN", "SYS_PTRACE"]);
273 assert_eq!(config.cap_drop, vec!["NET_RAW"]);
274 }
275
276 #[test]
277 fn test_to_env_vars_default() {
278 let config = SecurityConfig::default();
279 let env = config.to_env_vars();
280 assert!(env.contains(&("A3S_SEC_SECCOMP".to_string(), "default".to_string())));
281 assert!(env.contains(&("A3S_SEC_NO_NEW_PRIVS".to_string(), "1".to_string())));
282 assert!(!env.iter().any(|(k, _)| k == "A3S_SEC_CAP_ADD"));
284 assert!(!env.iter().any(|(k, _)| k == "A3S_SEC_CAP_DROP"));
285 assert!(!env.iter().any(|(k, _)| k == "A3S_SEC_PRIVILEGED"));
286 }
287
288 #[test]
289 fn test_to_env_vars_privileged() {
290 let config = SecurityConfig::from_options(&[], &[], &[], true);
291 let env = config.to_env_vars();
292 assert!(env.contains(&("A3S_SEC_SECCOMP".to_string(), "unconfined".to_string())));
293 assert!(env.contains(&("A3S_SEC_NO_NEW_PRIVS".to_string(), "0".to_string())));
294 assert!(env.contains(&("A3S_SEC_PRIVILEGED".to_string(), "1".to_string())));
295 assert!(env.contains(&("A3S_SEC_CAP_ADD".to_string(), "ALL".to_string())));
296 }
297
298 #[test]
299 fn test_to_env_vars_with_caps() {
300 let cap_add = vec!["NET_ADMIN".to_string()];
301 let cap_drop = vec!["ALL".to_string()];
302 let config = SecurityConfig::from_options(&[], &cap_add, &cap_drop, false);
303 let env = config.to_env_vars();
304 assert!(env.contains(&("A3S_SEC_CAP_ADD".to_string(), "NET_ADMIN".to_string())));
305 assert!(env.contains(&("A3S_SEC_CAP_DROP".to_string(), "ALL".to_string())));
306 }
307
308 #[test]
309 fn test_env_vars_roundtrip() {
310 let original = SecurityConfig::from_options(
311 &["seccomp=unconfined".to_string()],
312 &["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()],
313 &["NET_RAW".to_string()],
314 false,
315 );
316 let env = original.to_env_vars();
317
318 for (key, value) in &env {
320 std::env::set_var(key, value);
321 }
322
323 let parsed = SecurityConfig::from_env_vars();
324 assert_eq!(parsed.seccomp, SeccompMode::Unconfined);
325 assert!(parsed.no_new_privileges); assert_eq!(parsed.cap_add, vec!["NET_ADMIN", "SYS_PTRACE"]);
327 assert_eq!(parsed.cap_drop, vec!["NET_RAW"]);
328 assert!(!parsed.privileged);
329
330 for (key, _) in &env {
332 std::env::remove_var(key);
333 }
334 }
335
336 #[test]
337 fn test_security_config_serde_roundtrip() {
338 let config = SecurityConfig {
339 seccomp: SeccompMode::Custom("/my/profile.json".to_string()),
340 no_new_privileges: false,
341 cap_add: vec!["NET_ADMIN".to_string()],
342 cap_drop: vec!["ALL".to_string()],
343 privileged: false,
344 };
345 let json = serde_json::to_string(&config).unwrap();
346 let parsed: SecurityConfig = serde_json::from_str(&json).unwrap();
347 assert_eq!(
348 parsed.seccomp,
349 SeccompMode::Custom("/my/profile.json".to_string())
350 );
351 assert!(!parsed.no_new_privileges);
352 assert_eq!(parsed.cap_add, vec!["NET_ADMIN"]);
353 assert_eq!(parsed.cap_drop, vec!["ALL"]);
354 }
355
356 #[test]
359 fn test_validate_default_ok() {
360 let config = SecurityConfig::default();
361 assert!(config.validate().is_ok());
362 }
363
364 #[test]
365 fn test_validate_unconfined_ok() {
366 let config =
367 SecurityConfig::from_options(&["seccomp=unconfined".to_string()], &[], &[], false);
368 assert!(config.validate().is_ok());
369 }
370
371 #[test]
372 fn test_validate_custom_rejected() {
373 let config = SecurityConfig::from_options(
374 &["seccomp=/path/to/profile.json".to_string()],
375 &[],
376 &[],
377 false,
378 );
379 let err = config.validate().unwrap_err();
380 assert!(err.contains("custom seccomp profile"));
381 assert!(err.contains("not supported"));
382 }
383
384 #[test]
385 fn test_validate_privileged_ok() {
386 let config = SecurityConfig::from_options(&[], &[], &[], true);
387 assert!(config.validate().is_ok());
388 }
389
390 #[test]
391 fn test_from_options_apparmor_ignored() {
392 let opts = vec!["apparmor=docker-default".to_string()];
394 let config = SecurityConfig::from_options(&opts, &[], &[], false);
395 assert_eq!(config.seccomp, SeccompMode::Default);
397 assert!(config.no_new_privileges);
398 }
399
400 #[test]
401 fn test_from_options_selinux_ignored() {
402 let opts = vec!["label=type:container_t".to_string()];
404 let config = SecurityConfig::from_options(&opts, &[], &[], false);
405 assert_eq!(config.seccomp, SeccompMode::Default);
406 assert!(config.no_new_privileges);
407 }
408
409 #[test]
410 fn test_from_options_mixed_with_apparmor_selinux() {
411 let opts = vec![
412 "seccomp=unconfined".to_string(),
413 "apparmor=unconfined".to_string(),
414 "label=disable".to_string(),
415 "no-new-privileges=false".to_string(),
416 ];
417 let config = SecurityConfig::from_options(&opts, &[], &[], false);
418 assert_eq!(config.seccomp, SeccompMode::Unconfined);
419 assert!(!config.no_new_privileges);
420 }
421}