myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

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 }