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
10pub 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
27fn collect_checks() -> Vec<PostureCheck> {
31 let vm_available = shell::inside_lima() || vm_is_reachable();
32
33 let mut checks = Vec::new();
34
35 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 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 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 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 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 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
121fn 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
146fn 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
156fn 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}