Skip to main content

mvm_cli/
security_cmd.rs

1use anyhow::Result;
2
3use mvm_core::security::{PostureCheck, SecurityLayer, SecurityPolicy};
4use mvm_core::time;
5use mvm_runtime::shell;
6use mvm_security::posture::SecurityPosture;
7
8use crate::ui;
9
10/// Run the `mvm security status` command.
11///
12/// Probes the host/Lima VM for security feature availability across
13/// [`SecurityLayer`] variants and produces a posture report.
14pub fn run(json: bool) -> Result<()> {
15    let checks = collect_checks();
16    let report = SecurityPosture::evaluate(checks, &time::utc_now());
17
18    if json {
19        println!("{}", serde_json::to_string_pretty(&report)?);
20        return Ok(());
21    }
22
23    render_text(&report);
24    Ok(())
25}
26
27/// Collect posture checks by probing the environment.
28///
29/// Shell-based checks gracefully degrade when Lima is not running.
30fn collect_checks() -> Vec<PostureCheck> {
31    let vm_available = shell::inside_lima() || vm_is_reachable();
32
33    let mut checks = Vec::new();
34
35    // JailerIsolation — jailer binary available inside VM
36    checks.push(if vm_available {
37        vm_check(
38            SecurityLayer::JailerIsolation,
39            "Jailer binary available",
40            "command -v jailer >/dev/null 2>&1 && echo yes || echo no",
41            |out| out.trim() == "yes",
42            "jailer found",
43            "jailer not installed",
44        )
45    } else {
46        no_vm_check(SecurityLayer::JailerIsolation, "Jailer binary available")
47    });
48
49    // SeccompFilter — strict profile file exists
50    checks.push(if vm_available {
51        vm_check(
52            SecurityLayer::SeccompFilter,
53            "Seccomp strict profile",
54            "test -f /var/lib/mvm/seccomp/strict.json && echo yes || echo no",
55            |out| out.trim() == "yes",
56            "/var/lib/mvm/seccomp/strict.json exists",
57            "strict seccomp profile not deployed",
58        )
59    } else {
60        no_vm_check(SecurityLayer::SeccompFilter, "Seccomp strict profile")
61    });
62
63    // NetworkIsolation — iptables available
64    checks.push(if vm_available {
65        vm_check(
66            SecurityLayer::NetworkIsolation,
67            "iptables available",
68            "command -v iptables 2>/dev/null",
69            |out| !out.trim().is_empty(),
70            "iptables found",
71            "iptables not installed",
72        )
73    } else {
74        no_vm_check(SecurityLayer::NetworkIsolation, "iptables available")
75    });
76
77    // AuditLogging — tenants directory exists
78    checks.push(if vm_available {
79        vm_check(
80            SecurityLayer::AuditLogging,
81            "Audit log directory",
82            "test -d /var/lib/mvm/tenants && echo yes || echo no",
83            |out| out.trim() == "yes",
84            "/var/lib/mvm/tenants/ exists",
85            "/var/lib/mvm/tenants/ not found",
86        )
87    } else {
88        no_vm_check(SecurityLayer::AuditLogging, "Audit log directory")
89    });
90
91    // VsockAuth — check default policy (pure logic, no VM needed)
92    let policy = SecurityPolicy::default();
93    checks.push(PostureCheck {
94        layer: SecurityLayer::VsockAuth,
95        name: "Vsock auth enabled".to_string(),
96        passed: policy.require_auth,
97        detail: if policy.require_auth {
98            "require_auth is true".to_string()
99        } else {
100            "require_auth is false (dev mode default)".to_string()
101        },
102    });
103
104    // GuestHardening — at least one built template exists
105    checks.push(if vm_available {
106        vm_check(
107            SecurityLayer::GuestHardening,
108            "Built template exists",
109            "ls /var/lib/mvm/templates/*/current/ 2>/dev/null | head -1",
110            |out| !out.trim().is_empty(),
111            "built template found",
112            "no built templates found",
113        )
114    } else {
115        no_vm_check(SecurityLayer::GuestHardening, "Built template exists")
116    });
117
118    checks
119}
120
121/// Run a shell command inside the VM and produce a posture check.
122fn vm_check(
123    layer: SecurityLayer,
124    name: &str,
125    cmd: &str,
126    is_ok: impl FnOnce(&str) -> bool,
127    ok_detail: &str,
128    fail_detail: &str,
129) -> PostureCheck {
130    let passed = match shell::run_in_vm_stdout(cmd) {
131        Ok(out) => is_ok(&out),
132        Err(_) => false,
133    };
134    PostureCheck {
135        layer,
136        name: name.to_string(),
137        passed,
138        detail: if passed {
139            ok_detail.to_string()
140        } else {
141            fail_detail.to_string()
142        },
143    }
144}
145
146/// Produce a failed check when the VM is not reachable.
147fn no_vm_check(layer: SecurityLayer, name: &str) -> PostureCheck {
148    PostureCheck {
149        layer,
150        name: name.to_string(),
151        passed: false,
152        detail: "Lima VM not running".to_string(),
153    }
154}
155
156/// Quick probe to see if the Lima VM is reachable.
157fn vm_is_reachable() -> bool {
158    shell::run_in_vm_stdout("echo ok").is_ok()
159}
160
161fn render_text(report: &mvm_core::security::PostureReport) {
162    let total = report.checks.len();
163    let passed = report.checks.iter().filter(|c| c.passed).count();
164
165    let score_line = format!(
166        "Security Posture: {:.0}% ({}/{} checks passed)",
167        report.score, passed, total
168    );
169    if report.score >= 80.0 {
170        ui::success(&score_line);
171    } else if report.score >= 50.0 {
172        ui::warn(&score_line);
173    } else {
174        ui::info(&score_line);
175    }
176
177    println!();
178    for check in &report.checks {
179        let tag = layer_tag(&check.layer);
180        let status = if check.passed { "OK" } else { "FAIL" };
181        let pad = 40_usize.saturating_sub(check.name.len());
182        let dots = ".".repeat(pad);
183        ui::status_line(
184            &format!("  [{tag}] {} {dots}", check.name),
185            &format!("{status} ({})", check.detail),
186        );
187    }
188
189    let uncovered = SecurityPosture::uncovered_layers(&report.checks);
190    if !uncovered.is_empty() {
191        let names: Vec<&str> = uncovered.iter().map(|l| layer_name(l)).collect();
192        println!(
193            "\n  Not evaluated ({} layers): {}",
194            uncovered.len(),
195            names.join(", ")
196        );
197    }
198}
199
200fn layer_tag(layer: &SecurityLayer) -> &'static str {
201    match layer {
202        SecurityLayer::JailerIsolation => "JAILER",
203        SecurityLayer::CgroupLimits => "CGROUP",
204        SecurityLayer::SeccompFilter => "SECCOMP",
205        SecurityLayer::NetworkIsolation => "NETWORK",
206        SecurityLayer::VsockAuth => "VSOCK",
207        SecurityLayer::EncryptionAtRest => "ENC-REST",
208        SecurityLayer::EncryptionInTransit => "ENC-TRANSIT",
209        SecurityLayer::AuditLogging => "AUDIT",
210        SecurityLayer::SecretManagement => "SECRETS",
211        SecurityLayer::ConfigImmutability => "CONFIG",
212        SecurityLayer::GuestHardening => "GUEST",
213        SecurityLayer::SupplyChainIntegrity => "SUPPLY",
214    }
215}
216
217fn layer_name(layer: &SecurityLayer) -> &'static str {
218    match layer {
219        SecurityLayer::JailerIsolation => "JailerIsolation",
220        SecurityLayer::CgroupLimits => "CgroupLimits",
221        SecurityLayer::SeccompFilter => "SeccompFilter",
222        SecurityLayer::NetworkIsolation => "NetworkIsolation",
223        SecurityLayer::VsockAuth => "VsockAuth",
224        SecurityLayer::EncryptionAtRest => "EncryptionAtRest",
225        SecurityLayer::EncryptionInTransit => "EncryptionInTransit",
226        SecurityLayer::AuditLogging => "AuditLogging",
227        SecurityLayer::SecretManagement => "SecretManagement",
228        SecurityLayer::ConfigImmutability => "ConfigImmutability",
229        SecurityLayer::GuestHardening => "GuestHardening",
230        SecurityLayer::SupplyChainIntegrity => "SupplyChainIntegrity",
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_posture_check_construction() {
240        let check = PostureCheck {
241            layer: SecurityLayer::JailerIsolation,
242            name: "Jailer binary available".to_string(),
243            passed: true,
244            detail: "jailer found".to_string(),
245        };
246        assert!(check.passed);
247        assert_eq!(check.layer, SecurityLayer::JailerIsolation);
248    }
249
250    #[test]
251    fn test_no_vm_check_always_fails() {
252        let check = no_vm_check(SecurityLayer::SeccompFilter, "Seccomp strict profile");
253        assert!(!check.passed);
254        assert!(check.detail.contains("Lima VM not running"));
255    }
256
257    #[test]
258    fn test_json_output_valid() {
259        let checks = vec![
260            PostureCheck {
261                layer: SecurityLayer::JailerIsolation,
262                name: "Jailer binary available".to_string(),
263                passed: true,
264                detail: "found".to_string(),
265            },
266            PostureCheck {
267                layer: SecurityLayer::VsockAuth,
268                name: "Vsock auth enabled".to_string(),
269                passed: false,
270                detail: "require_auth is false".to_string(),
271            },
272        ];
273        let report = SecurityPosture::evaluate(checks, "2026-02-25T00:00:00Z");
274        let json = serde_json::to_string_pretty(&report).unwrap();
275        assert!(json.contains("\"score\""));
276        assert!(json.contains("JailerIsolation"));
277        assert!(json.contains("VsockAuth"));
278    }
279
280    #[test]
281    fn test_vsock_auth_default_requires_auth() {
282        let policy = SecurityPolicy::default();
283        assert!(policy.require_auth);
284    }
285
286    #[test]
287    fn test_vsock_auth_dev_defaults_permissive() {
288        let policy = SecurityPolicy::dev_defaults();
289        assert!(!policy.require_auth);
290    }
291
292    #[test]
293    fn test_layer_tag_coverage() {
294        for layer in SecurityLayer::all() {
295            let tag = layer_tag(layer);
296            assert!(!tag.is_empty());
297        }
298    }
299
300    #[test]
301    fn test_layer_name_coverage() {
302        for layer in SecurityLayer::all() {
303            let name = layer_name(layer);
304            assert!(!name.is_empty());
305        }
306    }
307}