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 guest-enforceable security config from CLI-style options.
64    ///
65    /// Backend compatibility is validated by [`crate::resolve_execution`]
66    /// before the MicroVM specification is built. This parser only translates
67    /// options that guest-init can enforce.
68    ///
69    /// Accepts the same format as Docker:
70    /// - `seccomp=default` — apply the built-in profile
71    /// - `seccomp=unconfined` — disable seccomp
72    /// - `seccomp=<path>` — custom profile
73    /// - `no-new-privileges` or `no-new-privileges=true` — enable (default)
74    /// - `no-new-privileges=false` — disable
75    pub fn from_options(
76        security_opt: &[String],
77        cap_add: &[String],
78        cap_drop: &[String],
79        privileged: bool,
80    ) -> Self {
81        if privileged {
82            return Self {
83                seccomp: SeccompMode::Unconfined,
84                no_new_privileges: false,
85                cap_add: vec!["ALL".to_string()],
86                cap_drop: vec![],
87                privileged: true,
88            };
89        }
90
91        let mut config = Self {
92            cap_add: cap_add.to_vec(),
93            cap_drop: cap_drop.to_vec(),
94            ..Self::default()
95        };
96
97        for opt in security_opt {
98            let opt = opt.trim();
99            if opt.eq_ignore_ascii_case("no-new-privileges") {
100                config.no_new_privileges = true;
101                continue;
102            }
103
104            let Some((key, value)) = opt.split_once('=') else {
105                continue;
106            };
107            let key = key.trim();
108            let value = value.trim();
109            if key.eq_ignore_ascii_case("seccomp") {
110                config.seccomp = if value.eq_ignore_ascii_case("default") {
111                    SeccompMode::Default
112                } else if value.eq_ignore_ascii_case("unconfined") {
113                    SeccompMode::Unconfined
114                } else {
115                    SeccompMode::Custom(value.to_string())
116                };
117            } else if key.eq_ignore_ascii_case("no-new-privileges") {
118                if value.eq_ignore_ascii_case("true") {
119                    config.no_new_privileges = true;
120                } else if value.eq_ignore_ascii_case("false") {
121                    config.no_new_privileges = false;
122                }
123            }
124        }
125
126        config
127    }
128
129    /// Encode as environment variables for passing to guest-init.
130    ///
131    /// Returns a list of (key, value) pairs with `A3S_SEC_*` prefix.
132    pub fn to_env_vars(&self) -> Vec<(String, String)> {
133        let mut env = Vec::new();
134
135        // Seccomp mode
136        let seccomp_value = match &self.seccomp {
137            SeccompMode::Default => "default".to_string(),
138            SeccompMode::Unconfined => "unconfined".to_string(),
139            SeccompMode::Custom(path) => format!("custom:{}", path),
140        };
141        env.push(("A3S_SEC_SECCOMP".to_string(), seccomp_value));
142
143        // No new privileges
144        env.push((
145            "A3S_SEC_NO_NEW_PRIVS".to_string(),
146            if self.no_new_privileges { "1" } else { "0" }.to_string(),
147        ));
148
149        // Privileged
150        if self.privileged {
151            env.push(("A3S_SEC_PRIVILEGED".to_string(), "1".to_string()));
152        }
153
154        // Capabilities
155        if !self.cap_add.is_empty() {
156            env.push(("A3S_SEC_CAP_ADD".to_string(), self.cap_add.join(",")));
157        }
158        if !self.cap_drop.is_empty() {
159            env.push(("A3S_SEC_CAP_DROP".to_string(), self.cap_drop.join(",")));
160        }
161
162        env
163    }
164
165    /// Parse from guest-init environment variables.
166    pub fn from_env_vars() -> Self {
167        let mut config = Self::default();
168
169        if let Ok(val) = std::env::var("A3S_SEC_PRIVILEGED") {
170            if val == "1" {
171                return Self {
172                    seccomp: SeccompMode::Unconfined,
173                    no_new_privileges: false,
174                    cap_add: vec!["ALL".to_string()],
175                    cap_drop: vec![],
176                    privileged: true,
177                };
178            }
179        }
180
181        if let Ok(val) = std::env::var("A3S_SEC_SECCOMP") {
182            config.seccomp = if val == "unconfined" {
183                SeccompMode::Unconfined
184            } else if val == "default" {
185                SeccompMode::Default
186            } else if let Some(path) = val.strip_prefix("custom:") {
187                SeccompMode::Custom(path.to_string())
188            } else {
189                SeccompMode::Default
190            };
191        }
192
193        if let Ok(val) = std::env::var("A3S_SEC_NO_NEW_PRIVS") {
194            config.no_new_privileges = val == "1";
195        }
196
197        if let Ok(val) = std::env::var("A3S_SEC_CAP_ADD") {
198            config.cap_add = val.split(',').map(|s| s.trim().to_string()).collect();
199        }
200
201        if let Ok(val) = std::env::var("A3S_SEC_CAP_DROP") {
202            config.cap_drop = val.split(',').map(|s| s.trim().to_string()).collect();
203        }
204
205        config
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_security_config_default() {
215        let config = SecurityConfig::default();
216        assert_eq!(config.seccomp, SeccompMode::Default);
217        assert!(config.no_new_privileges);
218        assert!(config.cap_add.is_empty());
219        assert!(config.cap_drop.is_empty());
220        assert!(!config.privileged);
221    }
222
223    #[test]
224    fn test_seccomp_mode_default() {
225        assert_eq!(SeccompMode::default(), SeccompMode::Default);
226    }
227
228    #[test]
229    fn test_from_options_empty() {
230        let config = SecurityConfig::from_options(&[], &[], &[], false);
231        assert_eq!(config.seccomp, SeccompMode::Default);
232        assert!(config.no_new_privileges);
233        assert!(!config.privileged);
234    }
235
236    #[test]
237    fn test_from_options_seccomp_unconfined() {
238        let opts = vec!["seccomp=unconfined".to_string()];
239        let config = SecurityConfig::from_options(&opts, &[], &[], false);
240        assert_eq!(config.seccomp, SeccompMode::Unconfined);
241    }
242
243    #[test]
244    fn test_from_options_explicit_seccomp_default() {
245        let opts = vec![" SECCOMP=DEFAULT ".to_string()];
246        let config = SecurityConfig::from_options(&opts, &[], &[], false);
247        assert_eq!(config.seccomp, SeccompMode::Default);
248    }
249
250    #[test]
251    fn test_from_options_seccomp_custom() {
252        let opts = vec!["seccomp=/path/to/profile.json".to_string()];
253        let config = SecurityConfig::from_options(&opts, &[], &[], false);
254        assert_eq!(
255            config.seccomp,
256            SeccompMode::Custom("/path/to/profile.json".to_string())
257        );
258    }
259
260    #[test]
261    fn test_from_options_no_new_privileges() {
262        let opts = vec!["no-new-privileges".to_string()];
263        let config = SecurityConfig::from_options(&opts, &[], &[], false);
264        assert!(config.no_new_privileges);
265    }
266
267    #[test]
268    fn test_from_options_no_new_privileges_false() {
269        let opts = vec!["no-new-privileges=false".to_string()];
270        let config = SecurityConfig::from_options(&opts, &[], &[], false);
271        assert!(!config.no_new_privileges);
272    }
273
274    #[test]
275    fn test_from_options_privileged() {
276        let config = SecurityConfig::from_options(&[], &[], &[], true);
277        assert!(config.privileged);
278        assert_eq!(config.seccomp, SeccompMode::Unconfined);
279        assert!(!config.no_new_privileges);
280        assert_eq!(config.cap_add, vec!["ALL"]);
281    }
282
283    #[test]
284    fn test_from_options_capabilities() {
285        let cap_add = vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()];
286        let cap_drop = vec!["NET_RAW".to_string()];
287        let config = SecurityConfig::from_options(&[], &cap_add, &cap_drop, false);
288        assert_eq!(config.cap_add, vec!["NET_ADMIN", "SYS_PTRACE"]);
289        assert_eq!(config.cap_drop, vec!["NET_RAW"]);
290    }
291
292    #[test]
293    fn test_to_env_vars_default() {
294        let config = SecurityConfig::default();
295        let env = config.to_env_vars();
296        assert!(env.contains(&("A3S_SEC_SECCOMP".to_string(), "default".to_string())));
297        assert!(env.contains(&("A3S_SEC_NO_NEW_PRIVS".to_string(), "1".to_string())));
298        // No cap_add/cap_drop/privileged env vars when empty
299        assert!(!env.iter().any(|(k, _)| k == "A3S_SEC_CAP_ADD"));
300        assert!(!env.iter().any(|(k, _)| k == "A3S_SEC_CAP_DROP"));
301        assert!(!env.iter().any(|(k, _)| k == "A3S_SEC_PRIVILEGED"));
302    }
303
304    #[test]
305    fn test_to_env_vars_privileged() {
306        let config = SecurityConfig::from_options(&[], &[], &[], true);
307        let env = config.to_env_vars();
308        assert!(env.contains(&("A3S_SEC_SECCOMP".to_string(), "unconfined".to_string())));
309        assert!(env.contains(&("A3S_SEC_NO_NEW_PRIVS".to_string(), "0".to_string())));
310        assert!(env.contains(&("A3S_SEC_PRIVILEGED".to_string(), "1".to_string())));
311        assert!(env.contains(&("A3S_SEC_CAP_ADD".to_string(), "ALL".to_string())));
312    }
313
314    #[test]
315    fn test_to_env_vars_with_caps() {
316        let cap_add = vec!["NET_ADMIN".to_string()];
317        let cap_drop = vec!["ALL".to_string()];
318        let config = SecurityConfig::from_options(&[], &cap_add, &cap_drop, false);
319        let env = config.to_env_vars();
320        assert!(env.contains(&("A3S_SEC_CAP_ADD".to_string(), "NET_ADMIN".to_string())));
321        assert!(env.contains(&("A3S_SEC_CAP_DROP".to_string(), "ALL".to_string())));
322    }
323
324    #[test]
325    fn test_env_vars_roundtrip() {
326        let original = SecurityConfig::from_options(
327            &["seccomp=unconfined".to_string()],
328            &["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()],
329            &["NET_RAW".to_string()],
330            false,
331        );
332        let env = original.to_env_vars();
333
334        // Simulate setting env vars
335        for (key, value) in &env {
336            std::env::set_var(key, value);
337        }
338
339        let parsed = SecurityConfig::from_env_vars();
340        assert_eq!(parsed.seccomp, SeccompMode::Unconfined);
341        assert!(parsed.no_new_privileges); // default from original
342        assert_eq!(parsed.cap_add, vec!["NET_ADMIN", "SYS_PTRACE"]);
343        assert_eq!(parsed.cap_drop, vec!["NET_RAW"]);
344        assert!(!parsed.privileged);
345
346        // Clean up env vars
347        for (key, _) in &env {
348            std::env::remove_var(key);
349        }
350    }
351
352    #[test]
353    fn test_security_config_serde_roundtrip() {
354        let config = SecurityConfig {
355            seccomp: SeccompMode::Custom("/my/profile.json".to_string()),
356            no_new_privileges: false,
357            cap_add: vec!["NET_ADMIN".to_string()],
358            cap_drop: vec!["ALL".to_string()],
359            privileged: false,
360        };
361        let json = serde_json::to_string(&config).unwrap();
362        let parsed: SecurityConfig = serde_json::from_str(&json).unwrap();
363        assert_eq!(
364            parsed.seccomp,
365            SeccompMode::Custom("/my/profile.json".to_string())
366        );
367        assert!(!parsed.no_new_privileges);
368        assert_eq!(parsed.cap_add, vec!["NET_ADMIN"]);
369        assert_eq!(parsed.cap_drop, vec!["ALL"]);
370    }
371
372    // --- SecurityConfig::validate tests ---
373
374    #[test]
375    fn test_validate_default_ok() {
376        let config = SecurityConfig::default();
377        assert!(config.validate().is_ok());
378    }
379
380    #[test]
381    fn test_validate_unconfined_ok() {
382        let config =
383            SecurityConfig::from_options(&["seccomp=unconfined".to_string()], &[], &[], false);
384        assert!(config.validate().is_ok());
385    }
386
387    #[test]
388    fn test_validate_custom_rejected() {
389        let config = SecurityConfig::from_options(
390            &["seccomp=/path/to/profile.json".to_string()],
391            &[],
392            &[],
393            false,
394        );
395        let err = config.validate().unwrap_err();
396        assert!(err.contains("custom seccomp profile"));
397        assert!(err.contains("not supported"));
398    }
399
400    #[test]
401    fn test_validate_privileged_ok() {
402        let config = SecurityConfig::from_options(&[], &[], &[], true);
403        assert!(config.validate().is_ok());
404    }
405}