Skip to main content

a3s_box_core/
security.rs

1//! Security configuration for guest process hardening.
2//!
3//! Parses `--security-opt` values and capability lists into an actionable
4//! security profile that guest-init applies before exec.
5
6use serde::{Deserialize, Serialize};
7
8/// Seccomp filter mode for the guest process.
9#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
10pub enum SeccompMode {
11    /// Apply the default seccomp profile (blocks dangerous syscalls).
12    #[default]
13    Default,
14    /// Disable seccomp filtering entirely.
15    Unconfined,
16    /// Use a custom seccomp profile from a JSON file path.
17    Custom(String),
18}
19
20/// Parsed security configuration for guest process enforcement.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SecurityConfig {
23    /// Seccomp filter mode.
24    pub seccomp: SeccompMode,
25    /// Set PR_SET_NO_NEW_PRIVS before exec.
26    pub no_new_privileges: bool,
27    /// Linux capabilities to add.
28    pub cap_add: Vec<String>,
29    /// Linux capabilities to drop.
30    pub cap_drop: Vec<String>,
31    /// Privileged mode (disables all restrictions).
32    pub privileged: bool,
33}
34
35impl Default for SecurityConfig {
36    fn default() -> Self {
37        Self {
38            seccomp: SeccompMode::Default,
39            no_new_privileges: true, // secure by default
40            cap_add: vec![],
41            cap_drop: vec![],
42            privileged: false,
43        }
44    }
45}
46
47impl SecurityConfig {
48    /// Validate that the security configuration can be enforced at runtime.
49    ///
50    /// Returns an error if custom seccomp profiles are specified, since they
51    /// are not yet supported and would silently fall through to no filtering.
52    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    /// Parse security config from CLI-style options.
64    ///
65    /// Accepts the same format as Docker:
66    /// - `seccomp=unconfined` — disable seccomp
67    /// - `seccomp=<path>` — custom profile
68    /// - `no-new-privileges` or `no-new-privileges=true` — enable (default)
69    /// - `no-new-privileges=false` — disable
70    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    /// Encode as environment variables for passing to guest-init.
121    ///
122    /// Returns a list of (key, value) pairs with `A3S_SEC_*` prefix.
123    pub fn to_env_vars(&self) -> Vec<(String, String)> {
124        let mut env = Vec::new();
125
126        // Seccomp mode
127        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        // No new privileges
135        env.push((
136            "A3S_SEC_NO_NEW_PRIVS".to_string(),
137            if self.no_new_privileges { "1" } else { "0" }.to_string(),
138        ));
139
140        // Privileged
141        if self.privileged {
142            env.push(("A3S_SEC_PRIVILEGED".to_string(), "1".to_string()));
143        }
144
145        // Capabilities
146        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    /// Parse from guest-init environment variables.
157    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        // No cap_add/cap_drop/privileged env vars when empty
283        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        // Simulate setting env vars
319        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); // default from original
326        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        // Clean up env vars
331        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    // --- SecurityConfig::validate tests ---
357
358    #[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        // AppArmor options should be accepted (not panic) but have no effect
393        let opts = vec!["apparmor=docker-default".to_string()];
394        let config = SecurityConfig::from_options(&opts, &[], &[], false);
395        // Config should still be default — apparmor is ignored
396        assert_eq!(config.seccomp, SeccompMode::Default);
397        assert!(config.no_new_privileges);
398    }
399
400    #[test]
401    fn test_from_options_selinux_ignored() {
402        // SELinux label options should be accepted (not panic) but have no effect
403        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}