services_hardening_doctor.rs (11589B)
1 #![forbid(unsafe_code)] 2 3 use std::{ 4 collections::BTreeMap, 5 future::pending, 6 sync::{Arc, Mutex}, 7 }; 8 9 use myc::{ 10 MYC_DOCTOR_CHECK_COUNT, MYC_DOCTOR_CONTRACT_VERSION, MYC_DOCTOR_REPORT_MAX_UTF8_BYTES, 11 MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES, MycDoctorAggregateStatus, MycDoctorCheckDefinition, 12 MycDoctorCheckId, MycDoctorCheckStatus, MycDoctorFuture, MycDoctorObservation, MycDoctorProbe, 13 RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, myc_doctor_check_definitions, 14 parse_myc_cli_v1_from, resolve_myc_runtime_context, run_myc_doctor, 15 }; 16 use sha2::{Digest, Sha256}; 17 18 const OPERATOR_CONTRACT: &str = 19 include_str!("../contracts/services_hardening/operator_contract.v1.json"); 20 21 struct TestProbe { 22 outcomes: BTreeMap<MycDoctorCheckId, MycDoctorObservation>, 23 pending: Option<MycDoctorCheckId>, 24 calls: Arc<Mutex<Vec<MycDoctorCheckId>>>, 25 } 26 27 impl TestProbe { 28 fn all(outcome: MycDoctorObservation) -> Self { 29 Self { 30 outcomes: myc_doctor_check_definitions() 31 .iter() 32 .map(|definition| (definition.id(), outcome)) 33 .collect(), 34 pending: None, 35 calls: Arc::new(Mutex::new(Vec::new())), 36 } 37 } 38 39 fn with(mut self, id: MycDoctorCheckId, outcome: MycDoctorObservation) -> Self { 40 self.outcomes.insert(id, outcome); 41 self 42 } 43 44 fn pending(mut self, id: MycDoctorCheckId) -> Self { 45 self.pending = Some(id); 46 self 47 } 48 } 49 50 impl MycDoctorProbe for TestProbe { 51 fn probe(&self, definition: MycDoctorCheckDefinition) -> MycDoctorFuture<'_> { 52 let id = definition.id(); 53 self.calls.lock().expect("calls lock").push(id); 54 if self.pending == Some(id) { 55 return Box::pin(pending()); 56 } 57 let outcome = self.outcomes[&id]; 58 Box::pin(async move { outcome }) 59 } 60 } 61 62 fn runtime() -> (tempfile::TempDir, myc::MycRuntimeContext) { 63 let directory = tempfile::tempdir().expect("temporary root"); 64 let root = directory.path().to_str().expect("UTF-8 path"); 65 let invocation = parse_myc_cli_v1_from([ 66 "myc", 67 "--profile", 68 "repo-local", 69 "--instance", 70 "primary", 71 "--repo-local-root", 72 root, 73 "doctor", 74 ]) 75 .expect("doctor invocation"); 76 let context = resolve_myc_runtime_context( 77 &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), 78 &invocation, 79 ) 80 .expect("runtime context"); 81 (directory, context) 82 } 83 84 #[test] 85 fn exact_inventory_matches_the_operator_contract() { 86 let contract: serde_json::Value = serde_json::from_str(OPERATOR_CONTRACT).expect("contract"); 87 let doctor = contract["doctor"].as_object().expect("doctor"); 88 assert_eq!( 89 doctor 90 .keys() 91 .map(String::as_str) 92 .collect::<std::collections::BTreeSet<_>>(), 93 std::collections::BTreeSet::from([ 94 "aggregate_statuses", 95 "checks", 96 "contract_version", 97 "detached_probe_work", 98 "execution", 99 "pass_requires_all_scope", 100 "probe_future_cancellation", 101 "raw_error_or_path_allowed", 102 "report_max_utf8_bytes", 103 "required_fail_or_timeout_exit", 104 "required_skipped", 105 "shared_schema", 106 "statuses", 107 "summary_max_utf8_bytes", 108 ]) 109 ); 110 assert_eq!(MYC_DOCTOR_CONTRACT_VERSION, 1); 111 assert_eq!(MYC_DOCTOR_CHECK_COUNT, 13); 112 assert_eq!(MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES, 256); 113 assert_eq!(MYC_DOCTOR_REPORT_MAX_UTF8_BYTES, 8_192); 114 assert_eq!(doctor["execution"], "ordered"); 115 assert_eq!(doctor["pass_requires_all_scope"], true); 116 assert_eq!( 117 doctor["probe_future_cancellation"], 118 "drop_stops_or_owns_cleanup" 119 ); 120 assert_eq!(doctor["detached_probe_work"], false); 121 assert_eq!(doctor["required_skipped"], "forbidden"); 122 assert_eq!(doctor["raw_error_or_path_allowed"], false); 123 assert_eq!(doctor["required_fail_or_timeout_exit"], 6); 124 125 let rows = doctor["checks"].as_array().expect("checks"); 126 assert_eq!(rows.len(), MYC_DOCTOR_CHECK_COUNT); 127 for (definition, row) in myc_doctor_check_definitions().iter().zip(rows) { 128 assert_eq!(row["id"], id_name(definition.id())); 129 assert_eq!(row["required"], definition.required()); 130 assert_eq!(row["deadline_ms"], definition.deadline_ms()); 131 assert_eq!( 132 row["remediation_code"], 133 remediation_name(definition.remediation_code()) 134 ); 135 assert_eq!( 136 row["scope"], 137 serde_json::to_value(definition.scope()).expect("scope") 138 ); 139 } 140 } 141 142 #[tokio::test] 143 async fn all_pass_is_canonical_bounded_and_exit_zero() { 144 let (_directory, context) = runtime(); 145 let probe = TestProbe::all(MycDoctorObservation::Pass); 146 let report = run_myc_doctor(&context, &probe).await.expect("report"); 147 assert_eq!(report.service(), "myc"); 148 assert_eq!(report.instance().as_str(), "primary"); 149 assert_eq!(report.status(), MycDoctorAggregateStatus::Pass); 150 assert_eq!(report.exit_code(), 0); 151 assert_eq!(report.checks().len(), MYC_DOCTOR_CHECK_COUNT); 152 assert!( 153 report 154 .checks() 155 .iter() 156 .all(|result| result.status() == MycDoctorCheckStatus::Pass) 157 ); 158 assert_eq!( 159 probe.calls.lock().expect("calls").len(), 160 MYC_DOCTOR_CHECK_COUNT 161 ); 162 163 let bytes = report.canonical_json(); 164 assert!(bytes.len() <= MYC_DOCTOR_REPORT_MAX_UTF8_BYTES); 165 assert!(!bytes.contains(&b'\n')); 166 let wire: serde_json::Value = serde_json::from_slice(bytes).expect("JSON"); 167 assert_eq!(wire["contract_version"], 1); 168 assert_eq!(wire["service"], "myc"); 169 assert_eq!(wire["instance"], "primary"); 170 assert_eq!(wire["status"], "pass"); 171 assert_eq!(wire["checks"].as_array().expect("checks").len(), 13); 172 assert!(String::from_utf8_lossy(bytes).starts_with( 173 "{\"contract_version\":1,\"service\":\"myc\",\"instance\":\"primary\",\"status\":\"pass\",\"checks\":[" 174 )); 175 assert_eq!( 176 hex::encode(Sha256::digest(bytes)), 177 "19d7b33a205ed26fa6cf8c0c77ada75cdea7a1e01bca45efa72f95c65ac645ce" 178 ); 179 } 180 181 #[tokio::test] 182 async fn optional_nonpass_is_degraded_but_successful() { 183 let (_directory, context) = runtime(); 184 for outcome in [MycDoctorObservation::Fail, MycDoctorObservation::Skipped] { 185 let probe = 186 TestProbe::all(MycDoctorObservation::Pass).with(MycDoctorCheckId::ClockSkew, outcome); 187 let report = run_myc_doctor(&context, &probe).await.expect("report"); 188 assert_eq!(report.status(), MycDoctorAggregateStatus::Degraded); 189 assert_eq!(report.exit_code(), 0); 190 assert_ne!(report.checks()[12].status(), MycDoctorCheckStatus::Pass); 191 } 192 } 193 194 #[tokio::test] 195 async fn required_fail_and_skip_are_fail_closed() { 196 let (_directory, context) = runtime(); 197 for outcome in [MycDoctorObservation::Fail, MycDoctorObservation::Skipped] { 198 let probe = 199 TestProbe::all(MycDoctorObservation::Pass).with(MycDoctorCheckId::WriterLock, outcome); 200 let report = run_myc_doctor(&context, &probe).await.expect("report"); 201 assert_eq!(report.status(), MycDoctorAggregateStatus::Fail); 202 assert_eq!(report.exit_code(), 6); 203 assert_eq!(report.checks()[1].status(), MycDoctorCheckStatus::Fail); 204 } 205 } 206 207 #[tokio::test] 208 async fn required_timeout_is_bounded_and_remaining_checks_continue_in_order() { 209 let (_directory, context) = runtime(); 210 let probe = 211 TestProbe::all(MycDoctorObservation::Pass).pending(MycDoctorCheckId::PathsPermissions); 212 let report = run_myc_doctor(&context, &probe).await.expect("report"); 213 assert_eq!(report.status(), MycDoctorAggregateStatus::Fail); 214 assert_eq!(report.exit_code(), 6); 215 assert_eq!(report.checks()[0].status(), MycDoctorCheckStatus::Timeout); 216 let calls = probe.calls.lock().expect("calls").clone(); 217 assert_eq!( 218 calls, 219 myc_doctor_check_definitions() 220 .iter() 221 .map(|definition| definition.id()) 222 .collect::<Vec<_>>() 223 ); 224 } 225 226 #[tokio::test] 227 async fn report_debug_and_error_surface_retain_no_sensitive_values() { 228 let directory = tempfile::tempdir().expect("temporary root"); 229 let root = directory.path().join("secret-root"); 230 let root = root.to_str().expect("UTF-8 path"); 231 let invocation = parse_myc_cli_v1_from([ 232 "myc", 233 "--profile", 234 "repo-local", 235 "--instance", 236 "secret-instance", 237 "--repo-local-root", 238 root, 239 "doctor", 240 ]) 241 .expect("doctor invocation"); 242 let context = resolve_myc_runtime_context( 243 &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), 244 &invocation, 245 ) 246 .expect("runtime context"); 247 let report = run_myc_doctor(&context, &TestProbe::all(MycDoctorObservation::Pass)) 248 .await 249 .expect("report"); 250 let debug = format!("{report:?}"); 251 assert!(!debug.contains("secret-instance")); 252 assert!(!debug.contains("secret-root")); 253 for result in report.checks() { 254 assert!(result.summary().len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES); 255 } 256 257 let rendered = format!("{:?}", myc::MycDoctorErrorKind::OutputTooLarge); 258 assert!(!rendered.contains("secret")); 259 } 260 261 fn id_name(id: MycDoctorCheckId) -> &'static str { 262 match id { 263 MycDoctorCheckId::PathsPermissions => "paths_permissions", 264 MycDoctorCheckId::WriterLock => "writer_lock", 265 MycDoctorCheckId::SqliteSchema => "sqlite_schema", 266 MycDoctorCheckId::SqliteIntegrity => "sqlite_integrity", 267 MycDoctorCheckId::SqliteFreeSpace => "sqlite_free_space", 268 MycDoctorCheckId::IdentityBinding => "identity_binding", 269 MycDoctorCheckId::SignerProvider => "signer_provider", 270 MycDoctorCheckId::AdminBindPolicy => "admin_bind_policy", 271 MycDoctorCheckId::OperationsBindPolicy => "operations_bind_policy", 272 MycDoctorCheckId::NetworkPolicy => "network_policy", 273 MycDoctorCheckId::RequiredRelays => "required_relays", 274 MycDoctorCheckId::OutboxInvariants => "outbox_invariants", 275 MycDoctorCheckId::ClockSkew => "clock_skew", 276 } 277 } 278 279 fn remediation_name(code: myc::MycDoctorRemediationCode) -> &'static str { 280 match code { 281 myc::MycDoctorRemediationCode::CorrectPathPolicy => "correct_path_policy", 282 myc::MycDoctorRemediationCode::ReleaseWriterLock => "release_writer_lock", 283 myc::MycDoctorRemediationCode::RepairSchema => "repair_schema", 284 myc::MycDoctorRemediationCode::RestoreVerifiedState => "restore_verified_state", 285 myc::MycDoctorRemediationCode::FreeStateDiskSpace => "free_state_disk_space", 286 myc::MycDoctorRemediationCode::RestoreIdentityBinding => "restore_identity_binding", 287 myc::MycDoctorRemediationCode::RepairSignerProvider => "repair_signer_provider", 288 myc::MycDoctorRemediationCode::CorrectAdminBindPolicy => "correct_admin_bind_policy", 289 myc::MycDoctorRemediationCode::CorrectOperationsBindPolicy => { 290 "correct_operations_bind_policy" 291 } 292 myc::MycDoctorRemediationCode::CorrectNetworkPolicy => "correct_network_policy", 293 myc::MycDoctorRemediationCode::RestoreRequiredRelays => "restore_required_relays", 294 myc::MycDoctorRemediationCode::RepairOutboxState => "repair_outbox_state", 295 myc::MycDoctorRemediationCode::CorrectClock => "correct_clock", 296 } 297 }