services_hardening_systemd.rs (5411B)
1 #![forbid(unsafe_code)] 2 3 use std::collections::BTreeSet; 4 5 use serde_json::Value; 6 use sha2::{Digest, Sha256}; 7 8 const CONTRACT: &str = 9 include_str!("../contracts/services_hardening/systemd_qualification.v1.json"); 10 const UNIT: &str = include_str!("../packaging/systemd/myc@.service"); 11 const VERIFY_SCRIPT: &str = include_str!("../scripts/verify-systemd.sh"); 12 13 fn contract() -> Value { 14 serde_json::from_str(CONTRACT).expect("systemd qualification contract") 15 } 16 17 fn sha256_hex(bytes: &[u8]) -> String { 18 Sha256::digest(bytes) 19 .iter() 20 .map(|byte| format!("{byte:02x}")) 21 .collect() 22 } 23 24 fn validate_unit(source: &str, authority: &Value) -> Result<(), String> { 25 if source.len() > 16_384 || !source.ends_with('\n') || source.contains(['\0', '\r']) { 26 return Err("invalid unit bytes".to_owned()); 27 } 28 29 let expected = authority["canonical_lines"] 30 .as_array() 31 .ok_or_else(|| "missing canonical lines".to_owned())? 32 .iter() 33 .map(|line| { 34 line.as_str() 35 .ok_or_else(|| "invalid canonical line".to_owned()) 36 }) 37 .collect::<Result<Vec<_>, _>>()?; 38 let actual = source 39 .lines() 40 .filter(|line| !line.is_empty()) 41 .collect::<Vec<_>>(); 42 43 let forbidden = authority["forbidden_directives"] 44 .as_array() 45 .ok_or_else(|| "missing forbidden directives".to_owned())? 46 .iter() 47 .map(|directive| { 48 directive 49 .as_str() 50 .ok_or_else(|| "invalid forbidden directive".to_owned()) 51 }) 52 .collect::<Result<BTreeSet<_>, _>>()?; 53 let mut section = ""; 54 let mut sections = Vec::new(); 55 let mut keys = BTreeSet::new(); 56 for line in &actual { 57 if line.starts_with('[') && line.ends_with(']') { 58 section = &line[1..line.len() - 1]; 59 sections.push(section); 60 continue; 61 } 62 let (key, _) = line 63 .split_once('=') 64 .ok_or_else(|| "invalid directive".to_owned())?; 65 if section.is_empty() || forbidden.contains(key) { 66 return Err("forbidden or unscoped directive".to_owned()); 67 } 68 if !keys.insert(format!("{section}.{key}")) { 69 return Err("duplicate directive".to_owned()); 70 } 71 } 72 if sections != ["Unit", "Service", "Install"] || actual != expected { 73 return Err("unit differs from canonical contract".to_owned()); 74 } 75 Ok(()) 76 } 77 78 #[test] 79 fn systemd_contract_binds_the_exact_fail_closed_unit() { 80 let authority = contract(); 81 assert_eq!(authority["schema"], "radroots.myc.systemd-qualification"); 82 assert_eq!(authority["schema_version"], 1); 83 assert_eq!(authority["service"], "myc"); 84 assert_eq!(authority["unit_path"], "packaging/systemd/myc@.service"); 85 assert_eq!( 86 authority["verification_script"], 87 "scripts/verify-systemd.sh" 88 ); 89 assert_eq!(authority["unit_sha256"], sha256_hex(UNIT.as_bytes())); 90 validate_unit(UNIT, &authority).expect("canonical systemd unit"); 91 92 assert_eq!(authority["runtime_posture"]["type"], "simple"); 93 assert_eq!( 94 authority["runtime_posture"]["readiness"], 95 "cached_cli_no_sd_notify" 96 ); 97 assert_eq!( 98 authority["runtime_posture"]["restartable_exit_codes"], 99 serde_json::json!([1, 3]) 100 ); 101 assert_eq!( 102 authority["runtime_posture"]["nonrestartable_exit_codes"], 103 serde_json::json!([2, 4, 5, 6]) 104 ); 105 assert_eq!(authority["runtime_posture"]["stop_timeout_seconds"], 310); 106 assert_eq!(authority["verification"]["minimum_systemd_major"], 252); 107 assert_eq!(authority["verification"]["maximum_exposure_score"], 3.0); 108 assert_eq!(authority["verification"]["maximum_exposure_percent"], 30); 109 } 110 111 #[test] 112 fn systemd_unit_rejects_aliases_environment_duplicates_and_weaker_posture() { 113 let authority = contract(); 114 let mutations = [ 115 UNIT.replace("ConfigurationDirectory=", "ConfigDirectory="), 116 format!("{UNIT}Environment=MYC_SECRET=forbidden\n"), 117 UNIT.replace( 118 "NoNewPrivileges=yes", 119 "NoNewPrivileges=yes\nNoNewPrivileges=yes", 120 ), 121 UNIT.replace( 122 "RuntimeDirectoryPreserve=restart", 123 "RuntimeDirectoryPreserve=yes", 124 ), 125 UNIT.replace("CapabilityBoundingSet=\n", ""), 126 ]; 127 for mutation in mutations { 128 assert!(validate_unit(&mutation, &authority).is_err()); 129 } 130 } 131 132 #[test] 133 fn systemd_verifier_is_linux_only_bounded_and_forge_agnostic() { 134 for required in [ 135 "systemd 252 or newer is required", 136 "systemd-analyze verify", 137 "--offline=yes --threshold=30", 138 "exact ExecStart was not admitted", 139 ] { 140 assert!(VERIFY_SCRIPT.contains(required), "missing `{required}`"); 141 } 142 for forbidden in ["nix ", "oci", ".github", ".act", "_radroots", "docker"] { 143 assert!(!VERIFY_SCRIPT.to_ascii_lowercase().contains(forbidden)); 144 } 145 assert_eq!( 146 contract()["deferred"], 147 serde_json::json!([ 148 "compatibility_sensitive_memory_deny_write_execute", 149 "compatibility_sensitive_system_call_filter", 150 "resource_limits_step_231", 151 "integration_wave_step_229", 152 "rcld_promotion_step_235", 153 "nix", 154 "oci", 155 "deployment", 156 "production_activation" 157 ]) 158 ); 159 }