Skip to main content

mvm_core/
security.rs

1use serde::{Deserialize, Serialize};
2
3use crate::signing::SignedPayload;
4
5/// Current authenticated protocol version.
6pub const PROTOCOL_VERSION_AUTHENTICATED: u8 = 2;
7
8/// Legacy unauthenticated protocol version.
9pub const PROTOCOL_VERSION_LEGACY: u8 = 1;
10
11// ============================================================================
12// Authenticated vsock frames
13// ============================================================================
14
15/// A versioned, signed vsock frame envelope.
16///
17/// After the initial CONNECT/OK handshake and session establishment,
18/// every frame becomes an `AuthenticatedFrame` containing the Ed25519-signed
19/// inner payload (the original `GuestRequest` or `GuestResponse` JSON).
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AuthenticatedFrame {
22    /// Protocol version (2 = authenticated, 1 = legacy/unauthenticated).
23    pub version: u8,
24    /// Unique per-session identifier (assigned during handshake).
25    pub session_id: String,
26    /// Monotonically increasing sequence number for replay detection.
27    pub sequence: u64,
28    /// ISO 8601 timestamp of frame creation.
29    pub timestamp: String,
30    /// The Ed25519-signed inner payload.
31    pub signed: SignedPayload,
32}
33
34// ============================================================================
35// Session handshake
36// ============================================================================
37
38/// Host → Guest: initiate authenticated session after CONNECT/OK.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SessionHello {
41    /// Protocol version the host supports.
42    pub version: u8,
43    /// Session identifier (UUID v4, generated by host).
44    pub session_id: String,
45    /// Random challenge bytes (32 bytes) the guest must sign to prove key possession.
46    pub challenge: Vec<u8>,
47    /// Host's Ed25519 public key (32 bytes) for the guest to verify host frames.
48    pub host_pubkey: Vec<u8>,
49}
50
51/// Guest → Host: acknowledge session and prove key possession.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SessionHelloAck {
54    /// Protocol version the guest supports.
55    pub version: u8,
56    /// Echo back the session identifier.
57    pub session_id: String,
58    /// Signed challenge bytes proving the guest holds the session key.
59    pub challenge_response: Vec<u8>,
60    /// Guest's Ed25519 public key (32 bytes) for the host to verify guest frames.
61    pub guest_pubkey: Vec<u8>,
62}
63
64// ============================================================================
65// Security policy
66// ============================================================================
67
68/// Per-VM security configuration, provisioned on the config drive.
69///
70/// Controls authentication requirements, access permissions, rate limiting,
71/// and session lifecycle. Immutable after VM boot.
72///
73/// **Default: `require_auth = true`** — authentication is required unless
74/// explicitly opted out for dev/testing via `SecurityPolicy::dev_defaults()`.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SecurityPolicy {
77    /// Require authenticated vsock frames. Default: true.
78    /// Set to false only for dev/testing environments.
79    #[serde(default = "default_true")]
80    pub require_auth: bool,
81
82    /// Access control toggles.
83    #[serde(default)]
84    pub access: AccessPolicy,
85
86    /// Frame rate limiting configuration.
87    #[serde(default)]
88    pub rate_limits: RateLimitPolicy,
89
90    /// Session lifecycle limits.
91    #[serde(default)]
92    pub session: SessionPolicy,
93
94    /// Command blocklist entries for the gate.
95    #[serde(default)]
96    pub blocklist: Vec<BlocklistEntry>,
97}
98
99impl Default for SecurityPolicy {
100    fn default() -> Self {
101        Self {
102            require_auth: true,
103            access: AccessPolicy::default(),
104            rate_limits: RateLimitPolicy::default(),
105            session: SessionPolicy::default(),
106            blocklist: Vec::new(),
107        }
108    }
109}
110
111impl SecurityPolicy {
112    /// Permissive defaults for development and testing environments.
113    /// Authentication is disabled and console access is enabled.
114    pub fn dev_defaults() -> Self {
115        Self {
116            require_auth: false,
117            access: AccessPolicy {
118                console: true,
119                debug_exec: true,
120                ..AccessPolicy::default()
121            },
122            ..Self::default()
123        }
124    }
125}
126
127/// Access control toggles for guest operations.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct AccessPolicy {
130    /// Allow filesystem operations.
131    #[serde(default = "default_true")]
132    pub filesystem: bool,
133
134    /// Allow outbound network access.
135    #[serde(default = "default_true")]
136    pub network: bool,
137
138    /// Allow Nix build operations.
139    #[serde(default = "default_true")]
140    pub build: bool,
141
142    /// Allow host communication (host-bound vsock requests).
143    #[serde(default = "default_true")]
144    pub host_communication: bool,
145
146    /// Allow debug command execution via vsock (dev-only, disabled by default).
147    #[serde(default)]
148    pub debug_exec: bool,
149
150    /// Allow interactive PTY console sessions (dev-only, disabled by default).
151    #[serde(default)]
152    pub console: bool,
153}
154
155impl Default for AccessPolicy {
156    fn default() -> Self {
157        Self {
158            filesystem: true,
159            network: true,
160            build: true,
161            host_communication: true,
162            debug_exec: false,
163            console: false,
164        }
165    }
166}
167
168/// Frame rate limiting configuration.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct RateLimitPolicy {
171    /// Maximum frames per second (0 = unlimited).
172    #[serde(default = "default_rate_fps")]
173    pub frames_per_second: u32,
174
175    /// Maximum frames per minute (0 = unlimited).
176    #[serde(default = "default_rate_fpm")]
177    pub frames_per_minute: u32,
178}
179
180impl Default for RateLimitPolicy {
181    fn default() -> Self {
182        Self {
183            frames_per_second: 100,
184            frames_per_minute: 3000,
185        }
186    }
187}
188
189/// Session lifecycle limits.
190#[derive(Debug, Clone, Default, Serialize, Deserialize)]
191pub struct SessionPolicy {
192    /// Maximum session lifetime in seconds (0 = unlimited).
193    #[serde(default)]
194    pub max_lifetime_secs: u64,
195
196    /// Maximum tasks per session before recycling (0 = unlimited).
197    #[serde(default)]
198    pub max_tasks: u64,
199}
200
201// ============================================================================
202// Command gating
203// ============================================================================
204
205/// Decision from the command gate after evaluating a vsock command.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub enum GateDecision {
208    /// Command is allowed to proceed.
209    Allow,
210    /// Command matched a blocklist entry and is blocked.
211    Blocked {
212        /// The pattern that matched.
213        pattern: String,
214        /// Human-readable reason for blocking.
215        reason: String,
216    },
217    /// Command requires explicit approval before proceeding.
218    RequiresApproval {
219        /// Human-readable reason approval is needed.
220        reason: String,
221    },
222}
223
224/// Verdict from an approval authority (coordinator or dev-mode auto-approve).
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub enum ApprovalVerdict {
227    /// Approved to proceed.
228    Approved,
229    /// Denied with reason.
230    Denied { reason: String },
231    /// Timed out waiting for approval.
232    Timeout,
233}
234
235/// Action to take when a blocklist entry matches.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub enum BlocklistAction {
238    /// Block the command immediately.
239    Block,
240    /// Hold for approval before proceeding.
241    RequireApproval,
242    /// Log the match but allow the command.
243    Log,
244}
245
246/// Severity level for blocklist entries.
247#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
248pub enum BlocklistSeverity {
249    Low,
250    Medium,
251    High,
252    Critical,
253}
254
255/// A single blocklist entry for command gating.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct BlocklistEntry {
258    /// Pattern to match (literal string or glob with `*`/`?` wildcards).
259    pub pattern: String,
260    /// Category of the threat (e.g., "destructive", "exfiltration").
261    pub category: String,
262    /// Severity of the matched command.
263    pub severity: BlocklistSeverity,
264    /// Action to take when the pattern matches.
265    pub action: BlocklistAction,
266}
267
268// ============================================================================
269// Threat classification
270// ============================================================================
271
272/// Threat categories for vsock message classification.
273///
274/// Each category represents a class of security concern. A single message
275/// may trigger findings across multiple categories.
276#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
277pub enum ThreatCategory {
278    /// Credential or secret exposure (API keys, tokens, private keys).
279    SecretExposure,
280    /// Data exfiltration to external endpoints.
281    DataExfiltration,
282    /// Shell injection or code injection attempts.
283    Injection,
284    /// Destructive commands (rm -rf, mkfs, DROP TABLE).
285    Destructive,
286    /// Privilege escalation (sudo, nsenter, setuid).
287    PrivilegeEscalation,
288    /// Supply chain attacks (untrusted installs, impure builds).
289    SupplyChain,
290    /// Access to sensitive system files.
291    SensitiveFileAccess,
292    /// System configuration modification.
293    SystemModification,
294    /// Network abuse (port scanning, reverse shells).
295    NetworkAbuse,
296    /// MicroVM/Firecracker escape and tool poisoning.
297    ToolPoisoning,
298}
299
300/// Severity of a threat finding.
301#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
302pub enum Severity {
303    Info,
304    Low,
305    Medium,
306    High,
307    Critical,
308}
309
310/// A single threat finding produced by the classifier.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct ThreatFinding {
313    /// Threat category.
314    pub category: ThreatCategory,
315    /// Identifier for the pattern that matched (e.g., "aws_access_key", "rm_rf_root").
316    pub pattern_id: String,
317    /// Severity of this finding.
318    pub severity: Severity,
319    /// The text that matched (or a representative snippet).
320    pub matched_text: String,
321    /// Additional context about the finding.
322    pub context: String,
323}
324
325// ============================================================================
326// Security posture
327// ============================================================================
328
329/// A security layer that can be evaluated for posture scoring.
330#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
331pub enum SecurityLayer {
332    JailerIsolation,
333    CgroupLimits,
334    SeccompFilter,
335    NetworkIsolation,
336    VsockAuth,
337    EncryptionAtRest,
338    EncryptionInTransit,
339    AuditLogging,
340    SecretManagement,
341    ConfigImmutability,
342    GuestHardening,
343    SupplyChainIntegrity,
344}
345
346impl SecurityLayer {
347    /// All security layers in evaluation order.
348    pub fn all() -> &'static [SecurityLayer] {
349        &[
350            SecurityLayer::JailerIsolation,
351            SecurityLayer::CgroupLimits,
352            SecurityLayer::SeccompFilter,
353            SecurityLayer::NetworkIsolation,
354            SecurityLayer::VsockAuth,
355            SecurityLayer::EncryptionAtRest,
356            SecurityLayer::EncryptionInTransit,
357            SecurityLayer::AuditLogging,
358            SecurityLayer::SecretManagement,
359            SecurityLayer::ConfigImmutability,
360            SecurityLayer::GuestHardening,
361            SecurityLayer::SupplyChainIntegrity,
362        ]
363    }
364}
365
366/// Result of a single posture check.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct PostureCheck {
369    /// Which security layer this check belongs to.
370    pub layer: SecurityLayer,
371    /// Human-readable check name (e.g., "Jailer enabled for Firecracker").
372    pub name: String,
373    /// Whether the check passed.
374    pub passed: bool,
375    /// Explanation of the result.
376    pub detail: String,
377}
378
379/// Overall posture report aggregating all checks.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct PostureReport {
382    /// Individual check results.
383    pub checks: Vec<PostureCheck>,
384    /// Overall score: `passed_checks / total_checks * 100`.
385    pub score: f64,
386    /// ISO 8601 timestamp of when the report was generated.
387    pub timestamp: String,
388}
389
390fn default_true() -> bool {
391    true
392}
393
394fn default_rate_fps() -> u32 {
395    100
396}
397
398fn default_rate_fpm() -> u32 {
399    3000
400}
401
402// ============================================================================
403// Tests
404// ============================================================================
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn test_authenticated_frame_serde_roundtrip() {
412        let frame = AuthenticatedFrame {
413            version: PROTOCOL_VERSION_AUTHENTICATED,
414            session_id: "sess-001".to_string(),
415            sequence: 42,
416            timestamp: "2026-02-25T00:00:00Z".to_string(),
417            signed: SignedPayload {
418                payload: b"inner request json".to_vec(),
419                signature: vec![0u8; 64],
420                signer_id: "guest-key-1".to_string(),
421            },
422        };
423
424        let json = serde_json::to_string(&frame).unwrap();
425        let parsed: AuthenticatedFrame = serde_json::from_str(&json).unwrap();
426
427        assert_eq!(parsed.version, 2);
428        assert_eq!(parsed.session_id, "sess-001");
429        assert_eq!(parsed.sequence, 42);
430        assert_eq!(parsed.signed.payload, b"inner request json");
431        assert_eq!(parsed.signed.signature.len(), 64);
432    }
433
434    #[test]
435    fn test_session_hello_serde_roundtrip() {
436        let hello = SessionHello {
437            version: PROTOCOL_VERSION_AUTHENTICATED,
438            session_id: "sess-002".to_string(),
439            challenge: vec![1, 2, 3, 4, 5],
440            host_pubkey: vec![0u8; 32],
441        };
442
443        let json = serde_json::to_string(&hello).unwrap();
444        let parsed: SessionHello = serde_json::from_str(&json).unwrap();
445
446        assert_eq!(parsed.version, 2);
447        assert_eq!(parsed.session_id, "sess-002");
448        assert_eq!(parsed.challenge.len(), 5);
449        assert_eq!(parsed.host_pubkey.len(), 32);
450    }
451
452    #[test]
453    fn test_session_hello_ack_serde_roundtrip() {
454        let ack = SessionHelloAck {
455            version: PROTOCOL_VERSION_AUTHENTICATED,
456            session_id: "sess-002".to_string(),
457            challenge_response: vec![9; 64],
458            guest_pubkey: vec![0u8; 32],
459        };
460
461        let json = serde_json::to_string(&ack).unwrap();
462        let parsed: SessionHelloAck = serde_json::from_str(&json).unwrap();
463
464        assert_eq!(parsed.version, 2);
465        assert_eq!(parsed.session_id, "sess-002");
466        assert_eq!(parsed.challenge_response.len(), 64);
467        assert_eq!(parsed.guest_pubkey.len(), 32);
468    }
469
470    #[test]
471    fn test_security_policy_defaults() {
472        let policy = SecurityPolicy::default();
473
474        assert!(policy.require_auth);
475        assert!(policy.access.filesystem);
476        assert!(policy.access.network);
477        assert!(policy.access.build);
478        assert!(policy.access.host_communication);
479        assert!(!policy.access.debug_exec);
480        assert_eq!(policy.rate_limits.frames_per_second, 100);
481        assert_eq!(policy.rate_limits.frames_per_minute, 3000);
482        assert_eq!(policy.session.max_lifetime_secs, 0);
483        assert_eq!(policy.session.max_tasks, 0);
484    }
485
486    #[test]
487    fn test_security_policy_serde_with_defaults() {
488        // Deserialize a minimal JSON — all fields should fill with defaults.
489        let json = "{}";
490        let policy: SecurityPolicy = serde_json::from_str(json).unwrap();
491
492        assert!(policy.require_auth);
493        assert!(policy.access.filesystem);
494        assert_eq!(policy.rate_limits.frames_per_second, 100);
495    }
496
497    #[test]
498    fn test_security_policy_serde_override() {
499        let json = r#"{
500            "require_auth": true,
501            "access": { "build": false, "network": false },
502            "rate_limits": { "frames_per_second": 50 }
503        }"#;
504        let policy: SecurityPolicy = serde_json::from_str(json).unwrap();
505
506        assert!(policy.require_auth);
507        assert!(policy.access.filesystem); // default
508        assert!(!policy.access.build);
509        assert!(!policy.access.network);
510        assert_eq!(policy.rate_limits.frames_per_second, 50);
511        assert_eq!(policy.rate_limits.frames_per_minute, 3000); // default
512    }
513
514    #[test]
515    fn test_security_policy_full_roundtrip() {
516        let policy = SecurityPolicy {
517            require_auth: true,
518            access: AccessPolicy {
519                filesystem: false,
520                network: true,
521                build: false,
522                host_communication: true,
523                debug_exec: true,
524                console: false,
525            },
526            rate_limits: RateLimitPolicy {
527                frames_per_second: 200,
528                frames_per_minute: 6000,
529            },
530            session: SessionPolicy {
531                max_lifetime_secs: 3600,
532                max_tasks: 100,
533            },
534            blocklist: vec![BlocklistEntry {
535                pattern: "rm -rf /".to_string(),
536                category: "destructive".to_string(),
537                severity: BlocklistSeverity::Critical,
538                action: BlocklistAction::Block,
539            }],
540        };
541
542        let json = serde_json::to_string(&policy).unwrap();
543        let parsed: SecurityPolicy = serde_json::from_str(&json).unwrap();
544
545        assert!(parsed.require_auth);
546        assert!(!parsed.access.filesystem);
547        assert!(!parsed.access.build);
548        assert!(parsed.access.debug_exec);
549        assert_eq!(parsed.rate_limits.frames_per_second, 200);
550        assert_eq!(parsed.session.max_lifetime_secs, 3600);
551        assert_eq!(parsed.session.max_tasks, 100);
552        assert_eq!(parsed.blocklist.len(), 1);
553        assert_eq!(parsed.blocklist[0].pattern, "rm -rf /");
554    }
555
556    #[test]
557    fn test_protocol_version_constants() {
558        assert_eq!(PROTOCOL_VERSION_AUTHENTICATED, 2);
559        assert_eq!(PROTOCOL_VERSION_LEGACY, 1);
560    }
561
562    #[test]
563    fn test_gate_decision_serde_roundtrip() {
564        let decisions = vec![
565            GateDecision::Allow,
566            GateDecision::Blocked {
567                pattern: "rm -rf /".to_string(),
568                reason: "destructive".to_string(),
569            },
570            GateDecision::RequiresApproval {
571                reason: "matched pattern: nsenter".to_string(),
572            },
573        ];
574
575        for decision in decisions {
576            let json = serde_json::to_string(&decision).unwrap();
577            let parsed: GateDecision = serde_json::from_str(&json).unwrap();
578            assert_eq!(parsed, decision);
579        }
580    }
581
582    #[test]
583    fn test_approval_verdict_serde_roundtrip() {
584        let verdicts = vec![
585            ApprovalVerdict::Approved,
586            ApprovalVerdict::Denied {
587                reason: "policy violation".to_string(),
588            },
589            ApprovalVerdict::Timeout,
590        ];
591
592        for verdict in verdicts {
593            let json = serde_json::to_string(&verdict).unwrap();
594            let parsed: ApprovalVerdict = serde_json::from_str(&json).unwrap();
595            assert_eq!(parsed, verdict);
596        }
597    }
598
599    #[test]
600    fn test_blocklist_entry_serde_roundtrip() {
601        let entry = BlocklistEntry {
602            pattern: "rm -rf /".to_string(),
603            category: "destructive".to_string(),
604            severity: BlocklistSeverity::Critical,
605            action: BlocklistAction::Block,
606        };
607
608        let json = serde_json::to_string(&entry).unwrap();
609        let parsed: BlocklistEntry = serde_json::from_str(&json).unwrap();
610        assert_eq!(parsed.pattern, "rm -rf /");
611        assert_eq!(parsed.severity, BlocklistSeverity::Critical);
612        assert_eq!(parsed.action, BlocklistAction::Block);
613    }
614
615    #[test]
616    fn test_blocklist_severity_ordering() {
617        assert!(BlocklistSeverity::Low < BlocklistSeverity::Medium);
618        assert!(BlocklistSeverity::Medium < BlocklistSeverity::High);
619        assert!(BlocklistSeverity::High < BlocklistSeverity::Critical);
620    }
621
622    #[test]
623    fn test_blocklist_action_values() {
624        let actions = vec![
625            BlocklistAction::Block,
626            BlocklistAction::RequireApproval,
627            BlocklistAction::Log,
628        ];
629        for action in actions {
630            let json = serde_json::to_string(&action).unwrap();
631            let parsed: BlocklistAction = serde_json::from_str(&json).unwrap();
632            assert_eq!(parsed, action);
633        }
634    }
635
636    #[test]
637    fn test_security_policy_with_blocklist() {
638        let json = r#"{
639            "require_auth": true,
640            "blocklist": [
641                {
642                    "pattern": "rm -rf /",
643                    "category": "destructive",
644                    "severity": "Critical",
645                    "action": "Block"
646                }
647            ]
648        }"#;
649        let policy: SecurityPolicy = serde_json::from_str(json).unwrap();
650
651        assert!(policy.require_auth);
652        assert_eq!(policy.blocklist.len(), 1);
653        assert_eq!(policy.blocklist[0].pattern, "rm -rf /");
654        assert_eq!(policy.blocklist[0].action, BlocklistAction::Block);
655    }
656
657    #[test]
658    fn test_security_policy_empty_blocklist_default() {
659        let json = "{}";
660        let policy: SecurityPolicy = serde_json::from_str(json).unwrap();
661        assert!(policy.blocklist.is_empty());
662    }
663
664    // -- Threat classification tests --
665
666    #[test]
667    fn test_threat_category_serde_roundtrip() {
668        let categories = vec![
669            ThreatCategory::SecretExposure,
670            ThreatCategory::DataExfiltration,
671            ThreatCategory::Injection,
672            ThreatCategory::Destructive,
673            ThreatCategory::PrivilegeEscalation,
674            ThreatCategory::SupplyChain,
675            ThreatCategory::SensitiveFileAccess,
676            ThreatCategory::SystemModification,
677            ThreatCategory::NetworkAbuse,
678            ThreatCategory::ToolPoisoning,
679        ];
680        for cat in categories {
681            let json = serde_json::to_string(&cat).unwrap();
682            let parsed: ThreatCategory = serde_json::from_str(&json).unwrap();
683            assert_eq!(parsed, cat);
684        }
685    }
686
687    #[test]
688    fn test_severity_ordering() {
689        assert!(Severity::Info < Severity::Low);
690        assert!(Severity::Low < Severity::Medium);
691        assert!(Severity::Medium < Severity::High);
692        assert!(Severity::High < Severity::Critical);
693    }
694
695    #[test]
696    fn test_severity_serde_roundtrip() {
697        let severities = vec![
698            Severity::Info,
699            Severity::Low,
700            Severity::Medium,
701            Severity::High,
702            Severity::Critical,
703        ];
704        for sev in severities {
705            let json = serde_json::to_string(&sev).unwrap();
706            let parsed: Severity = serde_json::from_str(&json).unwrap();
707            assert_eq!(parsed, sev);
708        }
709    }
710
711    #[test]
712    fn test_threat_finding_serde_roundtrip() {
713        let finding = ThreatFinding {
714            category: ThreatCategory::SecretExposure,
715            pattern_id: "aws_access_key".to_string(),
716            severity: Severity::Critical,
717            matched_text: "AKIAIOSFODNN7EXAMPLE".to_string(),
718            context: "AWS access key detected".to_string(),
719        };
720
721        let json = serde_json::to_string(&finding).unwrap();
722        let parsed: ThreatFinding = serde_json::from_str(&json).unwrap();
723        assert_eq!(parsed.category, ThreatCategory::SecretExposure);
724        assert_eq!(parsed.pattern_id, "aws_access_key");
725        assert_eq!(parsed.severity, Severity::Critical);
726    }
727
728    // -- Posture types --
729
730    #[test]
731    fn test_security_layer_all_count() {
732        assert_eq!(SecurityLayer::all().len(), 12);
733    }
734
735    #[test]
736    fn test_security_layer_serde_roundtrip() {
737        for layer in SecurityLayer::all() {
738            let json = serde_json::to_string(layer).unwrap();
739            let parsed: SecurityLayer = serde_json::from_str(&json).unwrap();
740            assert_eq!(&parsed, layer);
741        }
742    }
743
744    #[test]
745    fn test_posture_check_serde_roundtrip() {
746        let check = PostureCheck {
747            layer: SecurityLayer::JailerIsolation,
748            name: "Jailer enabled".to_string(),
749            passed: true,
750            detail: "Firecracker runs inside jailer".to_string(),
751        };
752        let json = serde_json::to_string(&check).unwrap();
753        let parsed: PostureCheck = serde_json::from_str(&json).unwrap();
754        assert_eq!(parsed.layer, SecurityLayer::JailerIsolation);
755        assert!(parsed.passed);
756    }
757
758    #[test]
759    fn test_posture_report_serde_roundtrip() {
760        let report = PostureReport {
761            checks: vec![
762                PostureCheck {
763                    layer: SecurityLayer::CgroupLimits,
764                    name: "cgroup v2 limits set".to_string(),
765                    passed: true,
766                    detail: "mem + cpu limits configured".to_string(),
767                },
768                PostureCheck {
769                    layer: SecurityLayer::VsockAuth,
770                    name: "vsock auth enabled".to_string(),
771                    passed: false,
772                    detail: "require_auth is false".to_string(),
773                },
774            ],
775            score: 50.0,
776            timestamp: "2026-02-25T00:00:00Z".to_string(),
777        };
778        let json = serde_json::to_string(&report).unwrap();
779        let parsed: PostureReport = serde_json::from_str(&json).unwrap();
780        assert_eq!(parsed.checks.len(), 2);
781        assert_eq!(parsed.score, 50.0);
782    }
783}