diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs index 927b7ea1c..6d0b9564f 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs @@ -858,6 +858,7 @@ mod tests { keywords: None, record_id: Some(record), activity_id: None, + event_version: None, named_data: named_data .iter() .map(|(name, value)| IntuneNamedValue { diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs index b58d660a4..a39f696d1 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs @@ -575,6 +575,7 @@ mod tests { keywords: None, record_id: Some(1), activity_id: None, + event_version: None, named_data: named_data .iter() .map(|(name, value)| IntuneNamedValue { diff --git a/crates/cmtraceopen-parser/src/intune/device/windows/compliance/reducer.rs b/crates/cmtraceopen-parser/src/intune/device/windows/compliance/reducer.rs index ec546207b..3daa89da6 100644 --- a/crates/cmtraceopen-parser/src/intune/device/windows/compliance/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/device/windows/compliance/reducer.rs @@ -797,6 +797,7 @@ mod tests { keywords: None, record_id: None, activity_id: None, + event_version: None, named_data: vec![ crate::intune::evidence::IntuneNamedValue { name: "SettingUri".to_owned(), diff --git a/crates/cmtraceopen-parser/src/intune/device/windows/compliance/sources.rs b/crates/cmtraceopen-parser/src/intune/device/windows/compliance/sources.rs index f5c5b60c7..a3f7f24ea 100644 --- a/crates/cmtraceopen-parser/src/intune/device/windows/compliance/sources.rs +++ b/crates/cmtraceopen-parser/src/intune/device/windows/compliance/sources.rs @@ -671,6 +671,7 @@ mod tests { keywords: None, record_id: None, activity_id: None, + event_version: None, named_data: data .iter() .map(|(name, value)| IntuneNamedValue { diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/mod.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/mod.rs index 97bd4b220..b300b910e 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/mod.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/mod.rs @@ -1,10 +1,118 @@ //! Windows Autopilot identity, profile, and OOBE evidence outside ESP. //! -//! Owner: issue #362 of epic #356. Implementation pending. +//! Owner: issue #362 of epic #356. //! -//! ESP remains a sibling reducer in `crate::esp` and may be correlated through explicit enrollment/session keys; it is not renamed into Autopilot. +//! # Where the boundary with ESP sits //! -//! This is a reserved slot created by the parser-family skeleton so that issue -//! #362 can be implemented without editing any file shared with a sibling issue. -//! Add source classification, reduction, and findings submodules here, and -//! consume the shared contracts in [`crate::intune::evidence`]. +//! Autopilot and the Enrollment Status Page are **sibling contracts**, not one +//! contract under two names. This module owns device registration and identity, +//! profile discovery, retrieval and application, OOBE mode and deployment +//! profile facts, diagnostics-page/export evidence, and the handoff into +//! enrollment and ESP. `crate::esp` continues to own app and policy progress and +//! blocking status *after* that handoff, and nothing here re-derives it. +//! +//! The only thing crossing the boundary is an explicit key. An +//! [`AutopilotEspSessionFact`] supplied by the ESP side binds to this analysis +//! when it shares an enrollment, correlation, activity, or device identifier; +//! overlapping timestamps alone can only ever produce +//! [`AutopilotEspLinkState::TimeOnlyCandidate`] at low confidence, and are +//! refused outright when the collection's timezone is missing or unrecognizable. +//! +//! # What stays native +//! +//! EVTX decoding, registry reads, diagnostics-export generation, and live OOBE +//! interaction are all outside this crate; see the wasm32 invariant in the crate +//! root. What crosses the boundary is a set of versioned, self-declaring +//! documents plus the shared +//! [`NormalizedWindowsEvent`](crate::intune::normalized::NormalizedWindowsEvent) +//! type. Nothing here performs I/O. +//! +//! # Shape +//! +//! Deliberately the same shape as `crate::esp`, so the two read side by side: +//! +//! 1. [`sources`] declares the input contract and detects document schemas; +//! 2. [`normalize`] classifies records against Microsoft's documented event +//! table, refusing to interpret anything outside it; +//! 3. [`reduce_autopilot_bundle`] folds observations into an immutable +//! [`AutopilotSnapshot`]; +//! 4. [`derive_findings`] turns that snapshot into +//! [`IntuneFinding`](crate::intune::evidence::IntuneFinding)s, each citing +//! evidence or a coverage gap and each naming the next smallest artifact; +//! 5. [`redacted_export_projection`] makes a snapshot safe to share. +//! +//! ``` +//! use cmtraceopen_parser::intune::enrollment::windows::autopilot::{ +//! reduce_autopilot_bundle, AutopilotBundleInput, AutopilotCaptureMetadata, +//! AutopilotCaptureState, AutopilotOutcome, AutopilotSourceInput, +//! }; +//! +//! let events = r#"{ +//! "autopilotDocument": "autopilot.events", +//! "documentVersion": 1, +//! "events": [{ +//! "context": { +//! "evidenceRef": { "evidenceId": "ap:0", "sourceArtifactId": "autopilot-channel" }, +//! "provenance": { +//! "sourceKind": "eventLog", "sourceArtifactId": "autopilot-channel", +//! "filePath": null, "lineNumber": null, "recordNumber": 1, +//! "registry": null, "event": null +//! }, +//! "sourceTimestamp": null, +//! "observedAtUtc": "2026-07-31T09:00:00Z", +//! "sensitivity": "public", "parseState": "parsed", "accessState": "available" +//! }, +//! "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", +//! "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", +//! "eventId": 815, +//! "level": "error", +//! "task": null, "keywords": null, "recordId": 1, "activityId": null, +//! "namedData": [], +//! "message": "ZtdDeviceHasNoAssignedProfile - No profile assigned to the device." +//! }] +//! }"#; +//! +//! let snapshot = reduce_autopilot_bundle(&AutopilotBundleInput { +//! generated_at_utc: "2026-07-31T09:05:00Z".to_string(), +//! capture: AutopilotCaptureMetadata { +//! timezone: Some("UTC".to_string()), +//! ..AutopilotCaptureMetadata::default() +//! }, +//! sources: vec![AutopilotSourceInput { +//! artifact_id: "autopilot-channel".to_string(), +//! family: "autopilotEvents".to_string(), +//! capture_state: AutopilotCaptureState::Captured, +//! original_basename: Some("autopilot-events.json".to_string()), +//! sanitized_source_path: None, +//! content: Some(events.to_string()), +//! ..AutopilotSourceInput::default() +//! }], +//! events: Vec::new(), +//! }); +//! +//! assert_eq!(snapshot.outcome, AutopilotOutcome::NoProfileCandidate); +//! assert!(snapshot.findings_are_evidence_backed()); +//! ``` + +pub mod models; +pub mod normalize; +pub mod redaction; +pub mod reducer; +pub mod rules; +pub mod sources; + +pub use models::*; +pub use normalize::{ + classify_event, error_code_from_token, extract_error_code, extract_oobe_setting, + extract_policy_pair, extract_profile_state, is_autopilot_channel, +}; +pub use redaction::{redact_text, redacted_export_projection}; +pub use reducer::reduce_autopilot_bundle; +pub use rules::derive_findings; +pub use sources::{ + capture_is_validated, classify_timezone, detect_document, is_validated_schema_version, + is_validated_windows_build, AutopilotBundleInput, AutopilotCaptureState, + AutopilotDocumentDetection, AutopilotDocumentKind, AutopilotEspSessionDocument, + AutopilotEventsDocument, AutopilotReportDocument, AutopilotReportSection, AutopilotSourceInput, + AUTOPILOT_DOCUMENT_VERSION, AUTOPILOT_EVENT_CHANNEL, AUTOPILOT_EVENT_PROVIDER, +}; diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs new file mode 100644 index 000000000..c0758a141 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs @@ -0,0 +1,560 @@ +//! Public types for Windows Autopilot identity, profile, and OOBE evidence. +//! +//! These describe *what the evidence showed*, never what the reducer guessed. +//! Every state that implies an outcome is reachable only from an explicit, +//! citable record; everything else lands in +//! [`AutopilotOutcome::InsufficientEvidence`] alongside a named next artifact. +//! +//! The Autopilot contract is a **sibling** of `crate::esp`, not a superset of +//! it. Autopilot owns device identity/registration, profile discovery, +//! retrieval and application, OOBE facts, and the handoff into enrollment and +//! ESP. ESP continues to own app/profile progress and blocking status *after* +//! that handoff. The only thing the two share is an explicit correlation key; +//! see [`AutopilotEspLinkage`]. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::intune::evidence::{ + intune_raw_preserving_string_enum, IntuneArtifactCoverage, IntuneErrorCode, IntuneEvidenceRef, + IntuneFinding, IntuneFindingConfidence, IntuneNamedValue, IntuneObservationContext, + IntuneParseState, +}; + +/// Schema version of the Autopilot snapshot contract. +/// +/// Bump only on a breaking change. Adding an optional field, or a variant to +/// one of the raw-preserving enums below, is additive. +pub const AUTOPILOT_SNAPSHOT_SCHEMA_VERSION: u32 = 1; + +intune_raw_preserving_string_enum! { + /// The deployment mode a profile declared. + /// + /// Raw-preserving: Microsoft adds modes without warning, and an + /// unrecognized mode must survive export verbatim rather than becoming a + /// mode this build happens to know. + pub enum AutopilotDeploymentProfileType { + UserDriven => "userDriven", + SelfDeploying => "selfDeploying", + PreProvisioning => "preProvisioning", + HybridJoin => "hybridJoin", + ExistingDevice => "existingDevice", + } +} + +intune_raw_preserving_string_enum! { + /// An OOBE setting override token, as the Autopilot provider wrote it. + /// + /// Values such as `AUTOPILOT_OOBE_SETTINGS_AAD_JOIN_ONLY` come straight + /// from event 109 and are not a closed set. + pub enum AutopilotOobeSetting { + AadJoinOnly => "AUTOPILOT_OOBE_SETTINGS_AAD_JOIN_ONLY", + SkipKeyboard => "AUTOPILOT_OOBE_SETTINGS_SKIP_KEYBOARD", + SkipEula => "AUTOPILOT_OOBE_SETTINGS_SKIP_EULA", + } +} + +intune_raw_preserving_string_enum! { + /// A `ProfileState_*` token from event 153's state transition message. + /// + /// `ProfileUnknown` is Windows' own `ProfileState_Unknown` token, which is + /// a real observed state and quite distinct from the macro-generated + /// `Unknown(String)` catch-all for a token this build does not recognize. + pub enum AutopilotProfileStateToken { + ProfileUnknown => "ProfileState_Unknown", + Available => "ProfileState_Available", + Unavailable => "ProfileState_Unavailable", + Provisioned => "ProfileState_Provisioned", + } +} + +/// A record-level signal, classified from a validated source contract. +/// +/// Event-derived variants carry the Microsoft-documented event ID they are +/// keyed on. Every event ID outside that documented table classifies as +/// [`AutopilotSignal::Unclassified`] and is retained as raw evidence: an +/// undocumented event may not carry terminal semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotSignal { + /// Event 160: `AutopilotRetrieveSettings beginning acquisition.` + ProfileAcquisitionStarted, + /// Event 164: internet available to attempt policy download. + NetworkAvailableForDownload, + /// Event 100: `Autopilot policy [name] not found.` Documented as typically + /// transient, so this is explicitly **not** a terminal failure. + ProfilePolicyNotFound, + /// Event 161: `AutopilotManager retrieve settings succeeded.` + ProfileRetrieveSucceeded, + /// Event 153: `AutopilotManager reported the state changed from X to Y.` + ProfileStateChanged, + /// Event 111: `AutopilotRetrieveSettings succeeded.` + ProfileSettingsRetrieved, + /// Events 101, 103, 109: an OOBE setting was retrieved and processed. + OobeSettingObserved, + /// Event 163: download not required, the device is already provisioned. + DeviceAlreadyProvisioned, + /// Event 171: failed to set TPM identity confirmed. + TpmIdentityFailed, + /// Event 172: failed to set the Autopilot profile as available. + ProfileApplicationFailed, + /// Event 807: `ZtdDeviceIsNotRegistered`. + DeviceNotRegistered, + /// Event 809: the assigned profile no longer exists. + AssignedProfileMissing, + /// Event 815: no profile assigned and no tenant default. + NoAssignedProfile, + /// Event 908: serial number or product key mismatch. + IdentityMismatch, + /// A diagnostics-report or supplied-fact section, classified by its own + /// declared section kind rather than by an event ID. + ReportSection, + /// A normalized ESP session fact supplied by the sibling reducer. + EspSessionFact, + /// A record from a validated channel carrying no documented meaning. + Unclassified, +} + +impl AutopilotSignal { + /// Whether this signal by itself proves a terminal Autopilot failure. + pub fn is_terminal_failure(self) -> bool { + matches!( + self, + Self::TpmIdentityFailed + | Self::ProfileApplicationFailed + | Self::DeviceNotRegistered + | Self::AssignedProfileMissing + | Self::NoAssignedProfile + | Self::IdentityMismatch + ) + } +} + +intune_raw_preserving_string_enum! { + /// What a diagnostics-report section describes. + pub enum AutopilotSectionKind { + DeviceIdentity => "deviceIdentity", + ProfileRetrieval => "profileRetrieval", + ProfileApplication => "profileApplication", + OobeMode => "oobeMode", + EnrollmentHandoff => "enrollmentHandoff", + EspHandoff => "espHandoff", + NetworkService => "networkService", + DiagnosticsPage => "diagnosticsPage", + } +} + +intune_raw_preserving_string_enum! { + /// What a diagnostics-report section reported. + /// + /// `NotFound` and `Failed` are separate on purpose: "the service had + /// nothing for this device" and "the attempt errored" lead to different + /// next steps and different findings. + pub enum AutopilotSectionOutcome { + Succeeded => "succeeded", + Failed => "failed", + NotFound => "notFound", + Retrying => "retrying", + Mismatch => "mismatch", + Observed => "observed", + } +} + +/// Whether the collection's declared timezone can be trusted. +/// +/// The pure crate carries no timezone database, so this classifies the +/// declared value's *shape* only: a fixed UTC offset, `UTC`/`Z`, or an +/// `Area/City` identifier. Anything else is [`AutopilotTimezoneState::Invalid`], +/// which is a coverage fact, not a parse failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotTimezoneState { + Declared, + Missing, + Invalid, +} + +/// Whether observation ordering may rely on wall-clock time. +/// +/// `Unreliable` forbids every time-based inference in the reducer, including +/// time-proximity correlation with an ESP session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotTimeBasis { + Utc, + Unreliable, +} + +/// Collection-side metadata supplied with a bundle. +/// +/// None of this can be discovered by the pure crate; a native collector states +/// it, and the reducer refuses terminal semantics when the declared Windows +/// build or Autopilot schema is one this build has no validated rules for. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotCaptureMetadata { + pub collected_at_utc: Option, + pub windows_build: Option, + pub autopilot_schema_version: Option, + pub timezone: Option, +} + +/// One document the reducer was handed, and what became of it. +/// +/// This is the reducer's own judgment about the bytes, kept separate from the +/// collector's declared capture state so a collector that reports success over +/// unreadable content cannot launder it into coverage. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotDocumentReport { + pub artifact_id: String, + pub declared_kind: Option, + pub declared_version: Option, + pub parse_state: IntuneParseState, + pub detail: Option, + pub observation_ids: Vec, +} + +/// A classified Autopilot record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotObservation { + pub observation_id: String, + pub context: IntuneObservationContext, + pub signal: AutopilotSignal, + pub channel: Option, + pub provider: Option, + pub event_id: Option, + pub event_version: Option, + pub activity_id: Option, + pub section_kind: Option, + pub section_outcome: Option, + pub error: Option, + pub named_data: Vec, + /// Verbatim record text. Sensitive: Autopilot messages quote tenant names, + /// serial numbers, and UPNs. + pub message: Option, +} + +impl AutopilotObservation { + /// The evidence pointer this observation is cited by. + pub fn evidence_ref(&self) -> IntuneEvidenceRef { + self.context.evidence_ref.clone() + } + + /// Look up one named-data value, case-insensitively. + pub fn named(&self, name: &str) -> Option<&str> { + self.named_data + .iter() + .find(|value| value.name.eq_ignore_ascii_case(name)) + .map(|value| value.value.as_str()) + } +} + +/// Identity and registration evidence for the device being provisioned. +/// +/// Every field is `Option` because absence is itself a diagnostic fact: +/// a bundle with no serial number cannot be used to prove a serial mismatch. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotDeviceIdentity { + pub serial_number: Option, + pub hardware_hash: Option, + pub product_key_id: Option, + pub ztd_registration_id: Option, + pub entra_device_id: Option, + pub managed_device_id: Option, + pub tenant_id: Option, + pub tenant_domain: Option, + pub device_name: Option, + pub registration_state: AutopilotRegistrationState, + pub evidence: Vec, +} + +/// Whether the device's Autopilot registration was proven. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotRegistrationState { + /// No registration evidence was present either way. + #[default] + Unknown, + Registered, + /// The service explicitly reported the device as not registered. + NotRegistered, + /// Registered identity and observed hardware identity disagree. + Mismatch, +} + +/// Profile discovery, retrieval, and application evidence. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotProfileState { + pub profile_id: Option, + pub profile_name: Option, + pub deployment_profile_type: Option, + pub candidate_state: AutopilotProfileCandidateState, + pub last_state_token: Option, + pub retrieved: bool, + pub applied: bool, + pub error: Option, + pub evidence: Vec, +} + +/// Whether a profile candidate was ever proven to exist for this device. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotProfileCandidateState { + #[default] + Unknown, + /// A profile was assigned and made available to the device. + Available, + /// The service explicitly reported no assigned profile and no default. + NoneAssigned, + /// A profile was assigned but the referenced profile no longer exists. + AssignedButMissing, + /// Acquisition started and is still pending; documented as transient. + Pending, +} + +/// OOBE facts observed for this deployment. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotOobeState { + pub settings: Vec, + pub already_provisioned: bool, + pub evidence: Vec, +} + +/// One OOBE setting override and the state it was reported in. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotOobeSettingObservation { + pub setting: AutopilotOobeSetting, + pub state: Option, + pub evidence: IntuneEvidenceRef, +} + +/// The handoff out of the local Autopilot phase. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotHandoff { + pub enrollment_observed: bool, + pub esp_observed: bool, + pub enrollment_id: Option, + pub correlation_id: Option, + pub evidence: Vec, +} + +/// How an Autopilot bundle relates to an ESP session. +/// +/// Linkage is a claim about identity, not about time. `Linked` requires an +/// explicit shared enrollment, correlation, activity, or device key. Overlapping +/// timestamps alone can only ever produce [`AutopilotEspLinkState::TimeOnlyCandidate`] +/// at low confidence, and are refused outright when the time basis is unreliable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotEspLinkage { + pub state: AutopilotEspLinkState, + pub confidence: IntuneFindingConfidence, + /// The keys that actually matched, empty for every non-`Linked` state. + pub matched_keys: Vec, + pub esp_session_ids: Vec, + pub evidence: Vec, +} + +impl Default for AutopilotEspLinkage { + fn default() -> Self { + Self { + state: AutopilotEspLinkState::NotObserved, + confidence: IntuneFindingConfidence::Low, + matched_keys: Vec::new(), + esp_session_ids: Vec::new(), + evidence: Vec::new(), + } + } +} + +/// The relationship between Autopilot evidence and ESP evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotEspLinkState { + /// No ESP facts were supplied and no handoff was observed. + NotObserved, + /// A shared explicit key bound the two. + Linked, + /// ESP facts exist but share no key; only the time ranges overlap. + TimeOnlyCandidate, + /// A handoff into ESP was observed but no ESP facts were supplied. + EvidenceMissing, + /// The same key resolves to more than one ESP session. + Conflicting, +} + +/// One explicit key that bound Autopilot evidence to ESP evidence. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotCorrelationKey { + pub kind: AutopilotCorrelationKeyKind, + pub value: String, +} + +/// Which identity a correlation key expresses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotCorrelationKeyKind { + EnrollmentId, + CorrelationId, + ActivityId, + EntraDeviceId, + ManagedDeviceId, +} + +/// A supplied, already-normalized ESP session fact. +/// +/// This is a read-only projection of the sibling reducer's output. Nothing here +/// re-derives ESP state; the Autopilot reducer only checks whether an explicit +/// key binds the two. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotEspSessionFact { + pub session_id: String, + pub enrollment_id: Option, + pub correlation_id: Option, + pub activity_id: Option, + pub entra_device_id: Option, + pub managed_device_id: Option, + pub started_at_utc: Option, + pub phase: Option, + pub evidence: IntuneEvidenceRef, +} + +/// Two sources that cannot both be true. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotConflict { + pub conflict_id: String, + pub kind: AutopilotConflictKind, + pub detail: String, + /// The distinct values observed, sorted, so the conflict is reproducible. + pub values: Vec, + pub evidence: Vec, +} + +/// What kind of contradiction was observed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotConflictKind { + ProfileIdentifier, + EspSessionIdentifier, + DeviceIdentity, +} + +/// The furthest local Autopilot phase with direct evidence. +/// +/// This is the furthest phase *proven*, never the furthest phase assumed. The +/// ordering is deliberate: `PartialOrd` is what lets the reducer take a maximum +/// rather than track a state machine that could regress on out-of-order input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotPhase { + /// Nothing was proven. + NoEvidence, + /// Device identity or registration evidence exists. + IdentityObserved, + /// Profile acquisition began. + ProfileDiscovery, + /// A profile was retrieved. + ProfileRetrieved, + /// A profile's OOBE settings were applied. + ProfileApplied, + /// The handoff into MDM enrollment was observed. + EnrollmentHandoff, + /// The handoff into ESP / Device Preparation was observed. + EspHandoff, +} + +/// The reduced diagnosis for the local Autopilot phase. +/// +/// Exactly one outcome is reported. The reducer's precedence puts identity and +/// profile-availability failures ahead of downstream symptoms, because a device +/// that is not registered cannot meaningfully be described as having a network +/// problem. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotOutcome { + /// The local Autopilot phase completed and handed off. + Completed, + /// Handoff into ESP was observed but no ESP evidence was supplied. + HandoffReachedEspEvidenceMissing, + /// No profile was assigned to this device. + NoProfileCandidate, + /// A profile exists but could not be retrieved. + ProfileRetrievalFailure, + /// A profile was retrieved but could not be applied. + ProfileApplicationFailure, + /// Registered identity and observed hardware identity disagree. + IdentityRegistrationMismatch, + /// The device is not registered, or registration evidence is absent. + MissingRegistrationEvidence, + /// A network or service symptom with no proven cause. + NetworkOrServiceSymptom, + /// Acquisition is pending or explicitly retrying; documented as transient. + RetryDeferred, + /// Sources contradict each other. + ContradictoryEvidence, + /// A document, Windows build, or Autopilot schema this build has no + /// validated rules for. Terminal semantics are refused. + UnknownSchema, + /// Nothing in the bundle supports any conclusion. + InsufficientEvidence, +} + +/// The immutable public result of reducing an Autopilot bundle. +/// +/// Every field is derived; nothing here is mutated after +/// [`crate::intune::enrollment::windows::autopilot::reduce_autopilot_bundle`] +/// returns. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotSnapshot { + pub schema_version: u32, + pub generated_at_utc: String, + pub capture: AutopilotCaptureMetadata, + pub timezone_state: AutopilotTimezoneState, + pub time_basis: AutopilotTimeBasis, + pub identity: AutopilotDeviceIdentity, + pub profile: AutopilotProfileState, + pub oobe: AutopilotOobeState, + pub handoff: AutopilotHandoff, + pub esp_linkage: AutopilotEspLinkage, + pub phase: AutopilotPhase, + pub outcome: AutopilotOutcome, + pub confidence: IntuneFindingConfidence, + /// The smallest artifacts that would advance this diagnosis, in the order a + /// human should collect them. + pub next_evidence_requests: Vec, + pub observations: Vec, + /// Records from a validated channel that carried no documented meaning. + /// Retained so an undocumented event stays visible instead of vanishing. + pub unclassified_observation_ids: Vec, + pub documents: Vec, + pub conflicts: Vec, + pub coverage: Vec, + pub findings: Vec, +} + +impl AutopilotSnapshot { + /// Look up one observation by id. + pub fn observation(&self, observation_id: &str) -> Option<&AutopilotObservation> { + self.observations + .iter() + .find(|observation| observation.observation_id == observation_id) + } + + /// Whether every finding cites evidence or a coverage gap. + /// + /// The reducer already refuses to emit an uncited finding; this exists so a + /// consumer can assert the invariant rather than trust it. + pub fn findings_are_evidence_backed(&self) -> bool { + self.findings.iter().all(IntuneFinding::is_evidence_backed) + } +} diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs new file mode 100644 index 000000000..7b63a1db3 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs @@ -0,0 +1,345 @@ +//! Classification of normalized Autopilot records into typed signals. +//! +//! # The validated event contract +//! +//! Only the event IDs Microsoft documents for the Autopilot channel carry +//! meaning here. The table below is transcribed from *Windows Autopilot +//! troubleshooting FAQ*, "What do the different Event IDs mean in the Windows +//! Autopilot event log entries in Event Viewer?". +//! +//! | ID | Documented message | +//! |-----|-----------------------------------------------------------------------| +//! | 100 | `Autopilot policy [name] not found.` (documented as usually transient) | +//! | 101 | `AutopilotGetPolicyDwordByName succeeded` | +//! | 103 | `AutopilotGetPolicyStringByName succeeded` | +//! | 109 | `AutopilotGetOobeSettingsOverride succeeded` | +//! | 111 | `AutopilotRetrieveSettings succeeded.` | +//! | 153 | `AutopilotManager reported the state changed from [x] to [y].` | +//! | 160 | `AutopilotRetrieveSettings beginning acquisition.` | +//! | 161 | `AutopilotManager retrieve settings succeeded.` | +//! | 163 | download not required; device already provisioned | +//! | 164 | internet available to attempt policy download | +//! | 171 | `AutopilotManager failed to set TPM identity confirmed. HRESULT=[x]` | +//! | 172 | `AutopilotManager failed to set Autopilot profile as available.` | +//! | 807 | `ZtdDeviceIsNotRegistered` | +//! | 809 | `ZtdDeviceHasNoAssignedProfile - Assigned profile does not exist.` | +//! | 815 | `ZtdDeviceHasNoAssignedProfile - No profile assigned […] no default.` | +//! | 908 | `SerialNumberMismatch` / `ProductKeyIdMismatch` | +//! +//! Anything outside this table classifies as +//! [`AutopilotSignal::Unclassified`]. That is the whole point: an undocumented +//! event may not be promoted into a terminal state on a guess, and an event +//! from an unrelated MDM channel may not contribute identifiers at all. + +use std::sync::OnceLock; + +use regex::Regex; + +use crate::intune::evidence::{IntuneErrorCode, IntuneNamedValue}; +use crate::intune::normalized::NormalizedWindowsEvent; + +use super::models::{AutopilotOobeSetting, AutopilotProfileStateToken, AutopilotSignal}; +use super::sources::{AUTOPILOT_EVENT_CHANNEL, AUTOPILOT_EVENT_PROVIDER}; + +/// Whether a record came from the validated Autopilot channel. +/// +/// Both the provider and the channel must match. A `ManagementService` record +/// from the same provider is a different contract and is not Autopilot +/// evidence, so provider alone is not enough. +pub fn is_autopilot_channel(event: &NormalizedWindowsEvent) -> bool { + event + .provider + .trim() + .eq_ignore_ascii_case(AUTOPILOT_EVENT_PROVIDER) + && event + .channel + .trim() + .eq_ignore_ascii_case(AUTOPILOT_EVENT_CHANNEL) +} + +/// Classify one normalized event against the documented table. +/// +/// Returns `None` when the record is not Autopilot evidence at all, which is +/// different from `Some(Unclassified)`: the former is silently ignored, the +/// latter is retained and surfaced as a visible gap. +pub fn classify_event(event: &NormalizedWindowsEvent) -> Option { + if !is_autopilot_channel(event) { + return None; + } + Some(match event.event_id { + 160 => AutopilotSignal::ProfileAcquisitionStarted, + 164 => AutopilotSignal::NetworkAvailableForDownload, + 100 => AutopilotSignal::ProfilePolicyNotFound, + 161 => AutopilotSignal::ProfileRetrieveSucceeded, + 153 => AutopilotSignal::ProfileStateChanged, + 111 => AutopilotSignal::ProfileSettingsRetrieved, + 101 | 103 | 109 => AutopilotSignal::OobeSettingObserved, + 163 => AutopilotSignal::DeviceAlreadyProvisioned, + 171 => AutopilotSignal::TpmIdentityFailed, + 172 => AutopilotSignal::ProfileApplicationFailed, + 807 => AutopilotSignal::DeviceNotRegistered, + 809 => AutopilotSignal::AssignedProfileMissing, + 815 => AutopilotSignal::NoAssignedProfile, + 908 => AutopilotSignal::IdentityMismatch, + _ => AutopilotSignal::Unclassified, + }) +} + +// ── Message extraction ────────────────────────────────────────────────────── + +fn hresult_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"(?i)\bHRESULT\s*=\s*(?P0x[0-9a-f]{1,16}|-?\d{1,20})") + .expect("hresult regex must compile") + }) +} + +fn profile_state_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new( + r"(?i)state changed from\s+(?PProfileState_[A-Za-z0-9_]+)\s+to\s+(?PProfileState_[A-Za-z0-9_]+)", + ) + .expect("profile state regex must compile") + }) +} + +fn oobe_setting_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + // Event 109 is documented both as `OOBE setting = NAME` and as + // `OOBE setting NAME`, so the `=` is optional. + Regex::new( + r"(?i)OOBE setting\s*=?\s*(?P[A-Z][A-Z0-9_]{3,})\s*;\s*state\s*=\s*(?P[A-Za-z0-9_]+)", + ) + .expect("oobe setting regex must compile") + }) +} + +fn policy_pair_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new( + r"(?i)policy name\s*=\s*(?P[^;]+?)\s*;\s*(?:policy )?value\s*=\s*(?P[^;]+?)\s*\.?$", + ) + .expect("policy pair regex must compile") + }) +} + +/// Extract an `HRESULT=` token from a rendered message. +/// +/// The raw token is always kept. Only an unambiguously parseable value gets a +/// decimal and a canonical hex form; a token we cannot read stays raw rather +/// than becoming a number the log never stated. +pub fn extract_error_code(message: Option<&str>) -> Option { + let captures = hresult_re().captures(message?)?; + Some(error_code_from_token(captures.name("code")?.as_str())) +} + +/// Build an [`IntuneErrorCode`] from a raw token in any of the forms Intune +/// uses: `0x80070002`, `-2147024894`, or an unsigned decimal. +pub fn error_code_from_token(raw: &str) -> IntuneErrorCode { + let trimmed = raw.trim(); + let decimal = if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + u64::from_str_radix(hex, 16) + .ok() + .and_then(|value| u32::try_from(value).ok()) + .map(|value| i64::from(value as i32)) + .or_else(|| u64::from_str_radix(hex, 16).ok().map(|value| value as i64)) + } else { + trimmed.parse::().ok() + }; + let hex = decimal.and_then(|value| { + u32::try_from(value) + .ok() + .or_else(|| i32::try_from(value).ok().map(|value| value as u32)) + .map(|value| format!("0x{value:08X}")) + }); + IntuneErrorCode { + raw: trimmed.to_owned(), + decimal, + hex, + } +} + +/// Extract the `ProfileState_*` transition an event 153 message reports. +pub fn extract_profile_state(message: Option<&str>) -> Option { + let captures = profile_state_re().captures(message?)?; + Some(profile_state_token(captures.name("to")?.as_str())) +} + +fn profile_state_token(raw: &str) -> AutopilotProfileStateToken { + serde_json::from_value(serde_json::Value::String(raw.to_owned())) + .unwrap_or_else(|_| AutopilotProfileStateToken::Unknown(raw.to_owned())) +} + +/// Extract the OOBE setting name and state an event 109 message reports. +pub fn extract_oobe_setting(message: Option<&str>) -> Option<(AutopilotOobeSetting, String)> { + let captures = oobe_setting_re().captures(message?)?; + let setting = captures.name("setting")?.as_str(); + let state = captures.name("state")?.as_str().to_owned(); + let setting = serde_json::from_value(serde_json::Value::String(setting.to_owned())) + .unwrap_or_else(|_| AutopilotOobeSetting::Unknown(setting.to_owned())); + Some((setting, state)) +} + +/// Extract the `policy name = …; policy value = …` pair events 101 and 103 +/// report, so a caller keeps the pair without this module modeling every +/// possible setting name. +pub fn extract_policy_pair(message: Option<&str>) -> Option { + let captures = policy_pair_re().captures(message?)?; + Some(IntuneNamedValue { + name: captures.name("name")?.as_str().trim().to_owned(), + value: captures.name("value")?.as_str().trim().to_owned(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::intune::evidence::{ + IntuneAccessState, IntuneEvidenceRef, IntuneObservationContext, IntuneParseState, + IntuneProvenance, IntuneSensitivity, IntuneSourceKind, + }; + use crate::intune::normalized::NormalizedEventLevel; + + fn event(channel: &str, provider: &str, event_id: u32) -> NormalizedWindowsEvent { + NormalizedWindowsEvent { + context: IntuneObservationContext { + evidence_ref: IntuneEvidenceRef { + evidence_id: "e".to_owned(), + source_artifact_id: "a".to_owned(), + }, + provenance: IntuneProvenance { + source_kind: IntuneSourceKind::EventLog, + source_artifact_id: "a".to_owned(), + file_path: None, + line_number: None, + record_number: None, + registry: None, + event: None, + }, + source_timestamp: None, + observed_at_utc: "2026-07-31T00:00:00Z".to_owned(), + sensitivity: IntuneSensitivity::Public, + parse_state: IntuneParseState::Parsed, + access_state: IntuneAccessState::Available, + }, + channel: channel.to_owned(), + provider: provider.to_owned(), + event_id, + level: NormalizedEventLevel::Information, + task: None, + keywords: None, + record_id: None, + activity_id: None, + event_version: None, + named_data: Vec::new(), + message: None, + } + } + + #[test] + fn a_record_from_another_channel_of_the_same_provider_is_not_autopilot_evidence() { + let sibling = event( + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService", + AUTOPILOT_EVENT_PROVIDER, + 161, + ); + assert_eq!(classify_event(&sibling), None); + } + + #[test] + fn documented_event_ids_map_to_their_documented_meaning() { + for (id, expected) in [ + (160, AutopilotSignal::ProfileAcquisitionStarted), + (161, AutopilotSignal::ProfileRetrieveSucceeded), + (100, AutopilotSignal::ProfilePolicyNotFound), + (807, AutopilotSignal::DeviceNotRegistered), + (815, AutopilotSignal::NoAssignedProfile), + (908, AutopilotSignal::IdentityMismatch), + ] { + let record = event(AUTOPILOT_EVENT_CHANNEL, AUTOPILOT_EVENT_PROVIDER, id); + assert_eq!(classify_event(&record), Some(expected), "event {id}"); + } + } + + #[test] + fn an_undocumented_event_id_stays_unclassified_rather_than_guessing() { + let record = event(AUTOPILOT_EVENT_CHANNEL, AUTOPILOT_EVENT_PROVIDER, 4242); + assert_eq!( + classify_event(&record), + Some(AutopilotSignal::Unclassified), + "an undocumented event must never acquire terminal semantics" + ); + } + + #[test] + fn a_transient_policy_not_found_is_never_a_terminal_failure() { + assert!(!AutopilotSignal::ProfilePolicyNotFound.is_terminal_failure()); + assert!(AutopilotSignal::NoAssignedProfile.is_terminal_failure()); + } + + #[test] + fn hresult_tokens_keep_their_raw_form_and_gain_both_derived_forms() { + let code = extract_error_code(Some( + "AutopilotManager failed to set TPM identity confirmed. HRESULT=0x801C03EA", + )) + .expect("an HRESULT must be extracted"); + assert_eq!(code.raw, "0x801C03EA"); + assert_eq!(code.hex.as_deref(), Some("0x801C03EA")); + assert_eq!(code.decimal, Some(-2_145_647_638)); + } + + #[test] + fn a_message_without_an_hresult_yields_no_error_code() { + assert!(extract_error_code(Some("AutopilotRetrieveSettings succeeded.")).is_none()); + assert!(extract_error_code(None).is_none()); + } + + #[test] + fn a_profile_state_transition_reports_the_destination_state() { + assert_eq!( + extract_profile_state(Some( + "AutopilotManager reported the state changed from ProfileState_Unknown to ProfileState_Available." + )), + Some(AutopilotProfileStateToken::Available) + ); + } + + #[test] + fn an_unrecognized_profile_state_round_trips_verbatim() { + let token = extract_profile_state(Some( + "state changed from ProfileState_Unknown to ProfileState_SomethingNew", + )) + .expect("a token must be extracted"); + assert_eq!( + token, + AutopilotProfileStateToken::Unknown("ProfileState_SomethingNew".to_owned()) + ); + } + + #[test] + fn an_oobe_setting_override_yields_the_setting_and_its_state() { + let (setting, state) = extract_oobe_setting(Some( + "AutopilotGetOobeSettingsOverride succeeded: OOBE setting = AUTOPILOT_OOBE_SETTINGS_AAD_JOIN_ONLY; state = enabled.", + )) + .expect("an OOBE setting must be extracted"); + assert_eq!(setting, AutopilotOobeSetting::AadJoinOnly); + assert_eq!(state, "enabled"); + } + + #[test] + fn a_policy_pair_is_kept_without_modeling_every_setting_name() { + let pair = extract_policy_pair(Some( + "AutopilotGetPolicyStringByName succeeded: policy name = CloudAssignedTenantDomain; policy value = contoso.example.", + )) + .expect("a policy pair must be extracted"); + assert_eq!(pair.name, "CloudAssignedTenantDomain"); + assert_eq!(pair.value, "contoso.example"); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs new file mode 100644 index 000000000..7b6dbe659 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs @@ -0,0 +1,337 @@ +//! Deterministic privacy projection for the Autopilot snapshot. +//! +//! Autopilot evidence is unusually identity-dense: serial numbers, hardware +//! hashes, tenant domains, device names, and UPNs are the *subject* of the +//! analysis rather than incidental. A projection that simply dropped them would +//! destroy the diagnosis; one that dropped nothing would make the export +//! unshareable. +//! +//! So every masked value becomes a stable token derived only from the value +//! itself. Two records that named the same device still visibly name the same +//! device, and an explicit Autopilot-to-ESP correlation key still matches its +//! ESP counterpart after masking. Masking is idempotent: a token cannot itself +//! match a rule. +//! +//! # What survives, and why +//! +//! Tenant *object* identifiers survive in the clear: the Autopilot profile id, +//! the enrollment id, the correlation id, the activity id, and the ESP session +//! id. Those are the entire reason an export is shareable -- without them the +//! reader cannot line the export up against Intune or against the sibling ESP +//! analysis, and none of them describes a person or a machine. +//! +//! *Device and user* identity does not survive: serial number, hardware hash, +//! product key, ZTD id, Entra and managed device ids, tenant id and domain, +//! device name, user principal name, and the admin-authored profile name. +//! `entraDeviceId` is both device identity and a correlation key, which is +//! exactly why masking is deterministic rather than destructive. +//! +//! Every whole-value mask is computed over the trimmed, lowercased value under +//! a single token kind, so the same identifier masks identically no matter +//! which field or which casing it arrived in. +//! +//! The hash is deliberately non-cryptographic and unsalted. It exists to make +//! equal values look equal across an export, not to resist an attacker who +//! already knows the serial number they are looking for. + +use std::sync::OnceLock; + +use regex::Regex; + +use crate::intune::evidence::{IntuneFinding, IntuneNamedValue}; + +use super::models::*; + +/// Named-data and report-value keys whose values are masked in an export. +/// +/// Deliberately excludes `profileId`, `enrollmentId`, `correlationId`, +/// `activityId`, and `espSessionId`; see the module docs. +const SENSITIVE_VALUE_KEYS: [&str; 12] = [ + "serialNumber", + "hardwareHash", + "productKeyId", + "ztdRegistrationId", + "entraDeviceId", + "managedDeviceId", + "tenantId", + "tenantDomain", + "deviceName", + "userPrincipalName", + "upn", + "profileName", +]; + +/// The single token kind used for every whole-value mask. +/// +/// One kind rather than one per field is what makes a masked `serialNumber` +/// visibly equal to the same serial masked inside a record's named data. The +/// field name already says what the value was, so a per-field kind bought +/// nothing and cost cross-field equality. +const VALUE_KIND: &str = "redacted"; + +/// FNV-1a, stable across runs, platforms, and process restarts, which +/// `DefaultHasher` explicitly is not. +fn stable_token(kind: &str, value: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("[{kind}:{hash:016x}]") +} + +/// Mask one whole value, normalizing case and surrounding space first so the +/// same identifier always produces the same token. +fn mask_value(value: &str) -> String { + if is_token(value) { + return value.to_owned(); + } + stable_token(VALUE_KIND, &value.trim().to_ascii_lowercase()) +} + +fn upn_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") + .expect("upn regex must compile") + }) +} + +/// The profile segment of a user path, in either slash direction. +/// +/// The leading `[` exclusion is what keeps the projection idempotent: an +/// already-masked `[user:…]` segment must not be masked a second time. +fn user_path_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"(?i)(?P[\\/]Users[\\/])(?P[^\\/\r\n\x22\[][^\\/\r\n\x22]*)") + .expect("user path regex must compile") + }) +} + +/// A hardware hash or similar long opaque blob embedded in free text. +/// +/// Bounded at 32 characters so a GUID (32 hex digits plus dashes, matched in +/// runs of at most 12) and an eight-digit HRESULT are both left readable; those +/// are diagnostic grammar, not identity. +fn opaque_blob_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"\b[A-Za-z0-9+/=]{40,}\b").expect("opaque blob regex must compile") + }) +} + +/// Mask the sensitive spans inside a free-text value. +pub fn redact_text(value: &str) -> String { + let masked = upn_re().replace_all(value, |captures: ®ex::Captures<'_>| { + stable_token("upn", &captures[0]) + }); + let masked = user_path_re().replace_all(&masked, |captures: ®ex::Captures<'_>| { + format!( + "{}{}", + &captures["prefix"], + stable_token("user", &captures["user"]) + ) + }); + opaque_blob_re() + .replace_all(&masked, |captures: ®ex::Captures<'_>| { + stable_token("blob", &captures[0]) + }) + .into_owned() +} + +fn redact_opt(value: &Option) -> Option { + value.as_ref().map(|value| mask_value(value)) +} + +/// Whether a value is already a mask, which is what makes the projection +/// idempotent for whole-value fields. +fn is_token(value: &str) -> bool { + // Only the exact format `[redacted:<16 lowercase hex chars>]` produced by + // stable_token() is considered already-masked. Arbitrary `[foo:bar]` + // values (e.g. event-log setting tokens) must still go through redaction. + let Some(inner) = value.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else { + return false; + }; + let Some(hex) = inner.strip_prefix("redacted:") else { + return false; + }; + hex.len() == 16 && hex.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Return a copy of `snapshot` safe to export by default. +/// +/// Idempotent: `redacted_export_projection(&redacted_export_projection(&s))` +/// serializes identically to `redacted_export_projection(&s)`. +pub fn redacted_export_projection(snapshot: &AutopilotSnapshot) -> AutopilotSnapshot { + let mut projected = snapshot.clone(); + + projected.identity = AutopilotDeviceIdentity { + serial_number: redact_opt(&snapshot.identity.serial_number), + hardware_hash: redact_opt(&snapshot.identity.hardware_hash), + product_key_id: redact_opt(&snapshot.identity.product_key_id), + ztd_registration_id: redact_opt(&snapshot.identity.ztd_registration_id), + entra_device_id: redact_opt(&snapshot.identity.entra_device_id), + managed_device_id: redact_opt(&snapshot.identity.managed_device_id), + tenant_id: redact_opt(&snapshot.identity.tenant_id), + tenant_domain: redact_opt(&snapshot.identity.tenant_domain), + device_name: redact_opt(&snapshot.identity.device_name), + registration_state: snapshot.identity.registration_state, + evidence: snapshot.identity.evidence.clone(), + }; + + // `profile_id` survives: it is a tenant object identifier and the only way + // to line an export up against the Intune profile it describes. The + // admin-authored display name does not survive. + projected.profile.profile_name = redact_opt(&snapshot.profile.profile_name); + + for observation in &mut projected.observations { + observation.message = observation.message.as_deref().map(redact_text); + observation.context.provenance.file_path = + redact_opt(&observation.context.provenance.file_path); + redact_named_values(&mut observation.named_data); + } + + for conflict in &mut projected.conflicts { + conflict.detail = redact_text(&conflict.detail); + conflict.values = conflict + .values + .iter() + .map(|value| redact_conflict_value(value)) + .collect(); + } + + for key in &mut projected.esp_linkage.matched_keys { + if !is_token(&key.value) { + key.value = stable_token(VALUE_KIND, &key.value); + } + } + + for entry in &mut projected.coverage { + entry.detail = entry.detail.as_deref().map(redact_text); + } + + projected.next_evidence_requests = projected + .next_evidence_requests + .iter() + .map(|request| redact_text(request)) + .collect(); + + for finding in &mut projected.findings { + redact_finding(finding); + } + + projected +} + +fn redact_named_values(values: &mut [IntuneNamedValue]) { + for value in values { + if SENSITIVE_VALUE_KEYS + .iter() + .any(|key| key.eq_ignore_ascii_case(&value.name)) + { + if !is_token(&value.value) { + value.value = stable_token(VALUE_KIND, &value.value); + } + } else { + value.value = redact_text(&value.value); + } + } +} + +/// Conflict values are written as `name=value` by the reducer for report +/// sections and as bare values for named-key conflicts, so both shapes are +/// handled rather than assuming one. +fn redact_conflict_value(value: &str) -> String { + match value.split_once('=') { + Some((name, raw)) + if SENSITIVE_VALUE_KEYS + .iter() + .any(|key| key.eq_ignore_ascii_case(name)) => + { + if is_token(raw) { + value.to_owned() + } else { + format!("{name}={}", stable_token(VALUE_KIND, raw)) + } + } + _ => { + if is_token(value) { + value.to_owned() + } else { + stable_token(VALUE_KIND, value) + } + } + } +} + +fn redact_finding(finding: &mut IntuneFinding) { + finding.summary = redact_text(&finding.summary); + finding.recommended_checks = finding + .recommended_checks + .iter() + .map(|check| redact_text(check)) + .collect(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn equal_values_mask_to_equal_tokens_so_correlation_survives() { + let left = stable_token(VALUE_KIND, "5CD1234ABC"); + let right = stable_token(VALUE_KIND, "5CD1234ABC"); + assert_eq!(left, right); + assert_ne!(left, stable_token(VALUE_KIND, "5CD1234ABD")); + } + + #[test] + fn masking_free_text_is_idempotent() { + let once = redact_text("signed in as synthetic.user@example.invalid"); + let twice = redact_text(&once); + assert_eq!(once, twice); + assert!(!once.contains('@')); + } + + #[test] + fn a_user_profile_segment_with_a_space_is_masked_in_full() { + let masked = redact_text(r"D:\Users\Synthetic Person\provisioning.log"); + assert!(!masked.contains("Synthetic"), "got {masked}"); + assert!(!masked.contains("Person"), "got {masked}"); + assert!(masked.contains(r"\Users\"), "the shape must survive"); + } + + #[test] + fn a_guid_and_an_hresult_survive_because_they_are_diagnostic_grammar() { + let text = "profile 11111111-2222-3333-4444-555555555555 failed HRESULT=0x801C03EA"; + assert_eq!(redact_text(text), text); + } + + #[test] + fn a_long_opaque_blob_is_masked() { + let hash = "A".repeat(64); + let masked = redact_text(&format!("hardware hash {hash}")); + assert!(!masked.contains(&hash), "got {masked}"); + } + + #[test] + fn an_already_masked_whole_value_is_left_alone() { + let token = stable_token(VALUE_KIND, "abc"); + assert!(is_token(&token)); + assert_eq!(redact_conflict_value(&token), token); + assert_eq!( + redact_conflict_value(&format!("serialNumber={token}")), + format!("serialNumber={token}") + ); + } + + #[test] + fn a_non_sensitive_conflict_key_still_masks_its_value() { + // Falling through to the bare-value branch is deliberate: a conflict + // value the reducer could not attribute to a known key is more likely + // to be identity than not. + let masked = redact_conflict_value("someOtherKey=5CD1234ABC"); + assert!(!masked.contains("5CD1234ABC"), "got {masked}"); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs new file mode 100644 index 000000000..c45345c86 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs @@ -0,0 +1,1502 @@ +//! Reduction of supplied Autopilot evidence into an immutable snapshot. +//! +//! The shape follows the ESP sibling in `crate::esp`: typed observations that +//! each carry an [`IntuneObservationContext`], then a single pass that folds +//! them into a snapshot, then `derive_findings` over that snapshot. Nothing +//! here performs I/O, and nothing mutates the snapshot after it is returned. +//! +//! Three rules constrain every inference below: +//! +//! 1. A conclusion needs an explicit record. Absence of a signal is a +//! conclusion only when the artifact that would have carried it was fully +//! captured; see [`AutopilotCaptureState::supports_negative_conclusion`]. +//! 2. Cross-sibling linkage needs an explicit shared key. Overlapping time +//! ranges can only ever produce a low-confidence candidate. +//! 3. An unknown Windows build, Autopilot schema, or document version refuses +//! terminal semantics outright rather than reasoning from a guess. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::intune::evidence::{ + IntuneAccessState, IntuneArtifactCoverage, IntuneArtifactStatus, IntuneErrorCode, + IntuneEvidenceRef, IntuneFindingConfidence, IntuneNamedValue, IntuneObservationContext, + IntuneParseState, IntuneProvenance, IntuneSensitivity, IntuneSourceKind, IntuneTimestampKind, +}; +use crate::intune::normalized::NormalizedWindowsEvent; + +use super::models::*; +use super::normalize::{ + classify_event, extract_error_code, extract_oobe_setting, extract_policy_pair, + extract_profile_state, +}; +use super::rules::derive_findings; +use super::sources::{ + capture_is_validated, classify_timezone, detect_document, AutopilotBundleInput, + AutopilotCaptureState, AutopilotDocumentDetection, AutopilotDocumentKind, + AutopilotEspSessionDocument, AutopilotEventsDocument, AutopilotReportDocument, + AutopilotReportSection, AutopilotSourceInput, AUTOPILOT_EVENT_CHANNEL, +}; + +/// Named-data and report-value keys the reducer lifts into typed identity +/// fields. Anything not listed stays in `named_data`, untouched. +const IDENTITY_KEYS: [&str; 9] = [ + "serialNumber", + "hardwareHash", + "productKeyId", + "ztdRegistrationId", + "entraDeviceId", + "managedDeviceId", + "tenantId", + "tenantDomain", + "deviceName", +]; + +/// Reduce one supplied Autopilot bundle into an immutable snapshot. +/// +/// Total: every input shape produces a snapshot. A bundle the reducer cannot +/// interpret yields [`AutopilotOutcome::InsufficientEvidence`] or +/// [`AutopilotOutcome::UnknownSchema`] plus coverage explaining why, never an +/// error and never a silent empty result. +pub fn reduce_autopilot_bundle(bundle: &AutopilotBundleInput) -> AutopilotSnapshot { + let timezone_state = classify_timezone(bundle.capture.timezone.as_deref()); + + let mut ingest = Ingest { + observed_at_utc: bundle.generated_at_utc.clone(), + ..Ingest::default() + }; + for source in &bundle.sources { + ingest.absorb_source(source); + } + for (index, event) in bundle.events.iter().enumerate() { + ingest.absorb_native_event(index, event); + } + + let mut observations = ingest.observations; + let time_basis = time_basis(timezone_state, &observations); + sort_observations(&mut observations, time_basis); + + let identity = reduce_identity(&observations, &ingest.sections); + let profile = reduce_profile(&observations, &ingest.sections); + let oobe = reduce_oobe(&observations); + let handoff = reduce_handoff(&observations, &ingest.sections); + let conflicts = detect_conflicts(&observations, &ingest.sections, &ingest.esp_sessions); + let esp_linkage = reduce_esp_linkage( + &observations, + &ingest.esp_sessions, + &handoff, + &conflicts, + time_basis, + ); + + let coverage = ingest.coverage; + // A malformed document is deliberately *not* part of this gate. Unreadable + // bytes are a coverage gap, reported as `parseFailed` coverage and its own + // finding; they say nothing about whether this build understands the + // device's Autopilot schema, which is what refusing terminal semantics is + // supposed to express. + let capture_validated = capture_is_validated(&bundle.capture) && !ingest.unsupported_documents; + + let phase = reduce_phase(&identity, &profile, &handoff); + let outcome = reduce_outcome( + capture_validated, + &conflicts, + &identity, + &profile, + &handoff, + &esp_linkage, + &observations, + &ingest.sections, + ); + let confidence = reduce_confidence(outcome, capture_validated, time_basis, &coverage); + let next_evidence_requests = next_evidence_requests(outcome, &esp_linkage, &coverage); + + let unclassified_observation_ids = observations + .iter() + .filter(|observation| observation.signal == AutopilotSignal::Unclassified) + .map(|observation| observation.observation_id.clone()) + .collect(); + + let mut snapshot = AutopilotSnapshot { + schema_version: AUTOPILOT_SNAPSHOT_SCHEMA_VERSION, + generated_at_utc: bundle.generated_at_utc.clone(), + capture: bundle.capture.clone(), + timezone_state, + time_basis, + identity, + profile, + oobe, + handoff, + esp_linkage, + phase, + outcome, + confidence, + next_evidence_requests, + observations, + unclassified_observation_ids, + documents: ingest.documents, + conflicts, + coverage, + findings: Vec::new(), + }; + snapshot.findings = derive_findings(&snapshot); + snapshot +} + +// ── Ingest ────────────────────────────────────────────────────────────────── + +#[derive(Default)] +struct Ingest { + observed_at_utc: String, + observations: Vec, + sections: Vec, + esp_sessions: Vec, + documents: Vec, + coverage: Vec, + /// Artifacts whose document declared that the channel it carries is + /// truncated. Their coverage is forced to `Capped`, because a partial + /// channel cannot support "this signal never appeared". + incomplete_artifacts: BTreeSet, + unsupported_documents: bool, +} + +impl Ingest { + fn absorb_source(&mut self, source: &AutopilotSourceInput) { + let declared_status = source.capture_state.declared_status(); + let Some(content) = source.content.as_deref() else { + self.push_coverage(source, declared_status, None); + return; + }; + + match detect_document(content) { + AutopilotDocumentDetection::Supported { kind, version } => { + match self.absorb_payload(source, &kind, content) { + Ok(observation_ids) => { + self.documents.push(AutopilotDocumentReport { + artifact_id: source.artifact_id.clone(), + declared_kind: Some(kind_wire(&kind)), + declared_version: Some(version), + parse_state: IntuneParseState::Parsed, + detail: None, + observation_ids, + }); + if self.incomplete_artifacts.contains(&source.artifact_id) + && declared_status == IntuneArtifactStatus::Available + { + self.push_coverage( + source, + IntuneArtifactStatus::Capped, + Some( + "The document declares the event channel incomplete, so the \ + absence of a signal in it proves nothing." + .to_owned(), + ), + ); + } else { + self.push_coverage(source, declared_status, None); + } + } + // The envelope was valid but the payload was not. That is a + // malformed document, not a supported one with no records: + // collapsing the two would let corrupt content masquerade + // as proof that nothing happened. + Err(detail) => { + self.documents.push(AutopilotDocumentReport { + artifact_id: source.artifact_id.clone(), + declared_kind: Some(kind_wire(&kind)), + declared_version: Some(version), + parse_state: IntuneParseState::Malformed, + detail: Some(detail.clone()), + observation_ids: Vec::new(), + }); + self.push_coverage( + source, + escalate(declared_status, IntuneParseState::Malformed), + Some(detail), + ); + } + } + } + AutopilotDocumentDetection::Unsupported { + declared_kind, + declared_version, + detail, + } => { + self.unsupported_documents = true; + self.documents.push(AutopilotDocumentReport { + artifact_id: source.artifact_id.clone(), + declared_kind, + declared_version, + parse_state: IntuneParseState::Unsupported, + detail: Some(detail.clone()), + observation_ids: Vec::new(), + }); + self.push_coverage( + source, + escalate(declared_status, IntuneParseState::Unsupported), + Some(detail), + ); + } + AutopilotDocumentDetection::Malformed { detail } => { + self.documents.push(AutopilotDocumentReport { + artifact_id: source.artifact_id.clone(), + declared_kind: None, + declared_version: None, + parse_state: IntuneParseState::Malformed, + detail: Some(detail.clone()), + observation_ids: Vec::new(), + }); + self.push_coverage( + source, + escalate(declared_status, IntuneParseState::Malformed), + Some(detail), + ); + } + } + } + + /// Parse a detected document's payload. + /// + /// `Err` carries why the payload could not be read, so the caller can + /// record a malformed document instead of an empty supported one. + fn absorb_payload( + &mut self, + source: &AutopilotSourceInput, + kind: &AutopilotDocumentKind, + content: &str, + ) -> Result, String> { + let mut ids = Vec::new(); + match kind { + AutopilotDocumentKind::Events => { + let document = serde_json::from_str::(content) + .map_err(|error| format!("events payload could not be read: {error}"))?; + if document.channel_complete == Some(false) { + self.incomplete_artifacts.insert(source.artifact_id.clone()); + } + for (index, event) in document.events.iter().enumerate() { + if let Some(observation) = + observation_from_event(&source.artifact_id, index, event) + { + ids.push(observation.observation_id.clone()); + self.observations.push(observation); + } + } + } + AutopilotDocumentKind::DiagnosticsReport | AutopilotDocumentKind::IdentityFacts => { + let document = serde_json::from_str::(content) + .map_err(|error| format!("report payload could not be read: {error}"))?; + for section in &document.sections { + let observation = observation_from_section(section); + ids.push(observation.observation_id.clone()); + self.observations.push(observation); + self.sections.push(section.clone()); + } + } + AutopilotDocumentKind::EspSession => { + let document = serde_json::from_str::(content) + .map_err(|error| format!("ESP session payload could not be read: {error}"))?; + for session in &document.sessions { + let observation = observation_from_esp_session(session); + ids.push(observation.observation_id.clone()); + self.observations.push(observation); + self.esp_sessions.push(session.clone()); + } + } + // Detection never yields `Supported` for an unknown kind. + AutopilotDocumentKind::Unknown(_) => {} + } + Ok(ids) + } + + fn absorb_native_event(&mut self, index: usize, event: &NormalizedWindowsEvent) { + let artifact_id = event.context.provenance.source_artifact_id.clone(); + if let Some(observation) = observation_from_event(&artifact_id, index, event) { + self.observations.push(observation); + } + } + + fn push_coverage( + &mut self, + source: &AutopilotSourceInput, + status: IntuneArtifactStatus, + detail: Option, + ) { + let evidence = self + .observations + .iter() + .filter(|observation| { + observation.context.evidence_ref.source_artifact_id == source.artifact_id + }) + .map(AutopilotObservation::evidence_ref) + .collect::>(); + self.coverage.push(IntuneArtifactCoverage { + artifact_id: source.artifact_id.clone(), + family: source.family.clone(), + status, + detail: detail.or_else(|| coverage_detail(source)), + observed_at_utc: self.observed_at_utc.clone(), + evidence, + }); + } +} + +fn coverage_detail(source: &AutopilotSourceInput) -> Option { + match source.capture_state { + AutopilotCaptureState::Capped => Some( + "The artifact was truncated, so the absence of a signal in it proves nothing." + .to_owned(), + ), + AutopilotCaptureState::Absent => source + .original_basename + .as_ref() + .map(|name| format!("{name} was not present on the device.")), + AutopilotCaptureState::AccessDenied => Some( + "The collector was not permitted to read this artifact; re-collect elevated." + .to_owned(), + ), + AutopilotCaptureState::Skipped => { + Some("The collector was told not to read this artifact.".to_owned()) + } + _ => None, + } +} + +/// Worsen a declared coverage status with what the reducer found in the bytes. +/// +/// A collector that reports success over content the reducer cannot read must +/// not be able to launder that into coverage, so parse outcomes only ever move +/// the status in the pessimistic direction. +/// +/// Statuses that are already a positive statement by the collector are left +/// alone. `Missing`, `PermissionDenied`, and `Skipped` had no bytes, so they +/// cannot also be malformed; `ParseFailed` already says exactly this; and +/// `Unsupported` means the collector recognized the source and declined it, +/// which stays true whatever its bytes turn out to contain. +fn escalate(declared: IntuneArtifactStatus, parse_state: IntuneParseState) -> IntuneArtifactStatus { + if matches!( + declared, + IntuneArtifactStatus::Missing + | IntuneArtifactStatus::PermissionDenied + | IntuneArtifactStatus::Skipped + | IntuneArtifactStatus::ParseFailed + | IntuneArtifactStatus::Unsupported + ) { + return declared; + } + match parse_state { + IntuneParseState::Malformed => IntuneArtifactStatus::ParseFailed, + IntuneParseState::Unsupported => IntuneArtifactStatus::Unsupported, + IntuneParseState::Parsed | IntuneParseState::Raw => declared, + } +} + +fn kind_wire(kind: &AutopilotDocumentKind) -> String { + serde_json::to_value(kind) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_default() +} + +// ── Observation construction ──────────────────────────────────────────────── + +fn observation_from_event( + artifact_id: &str, + index: usize, + event: &NormalizedWindowsEvent, +) -> Option { + let signal = classify_event(event)?; + // Prefer the evidence id the collector already assigned: it stays stable if + // the events are ever re-ordered, which a positional id would not. + let observation_id = if event.context.evidence_ref.evidence_id.trim().is_empty() { + format!("{artifact_id}:{index}") + } else { + event.context.evidence_ref.evidence_id.clone() + }; + let mut named_data = event.named_data.clone(); + if let Some(pair) = extract_policy_pair(event.message.as_deref()) { + if !named_data + .iter() + .any(|value| value.name.eq_ignore_ascii_case(&pair.name)) + { + named_data.push(pair); + } + } + Some(AutopilotObservation { + observation_id, + context: event.context.clone(), + signal, + channel: Some(event.channel.clone()), + provider: Some(event.provider.clone()), + event_id: Some(event.event_id), + event_version: event.event_version, + activity_id: event.activity_id.clone(), + section_kind: None, + section_outcome: None, + error: extract_error_code(event.message.as_deref()), + named_data, + message: event.message.clone(), + }) +} + +fn observation_from_section(section: &AutopilotReportSection) -> AutopilotObservation { + AutopilotObservation { + observation_id: section.context.evidence_ref.evidence_id.clone(), + context: section.context.clone(), + signal: AutopilotSignal::ReportSection, + channel: None, + provider: None, + event_id: None, + event_version: None, + activity_id: section + .values + .iter() + .find(|value| value.name.eq_ignore_ascii_case("activityId")) + .map(|value| value.value.clone()), + section_kind: Some(section.kind.clone()), + section_outcome: Some(section.outcome.clone()), + error: section.error.clone(), + named_data: section.values.clone(), + message: section.message.clone(), + } +} + +fn observation_from_esp_session(session: &AutopilotEspSessionFact) -> AutopilotObservation { + let mut named_data = vec![IntuneNamedValue { + name: "espSessionId".to_owned(), + value: session.session_id.clone(), + }]; + for (name, value) in [ + ("enrollmentId", session.enrollment_id.as_ref()), + ("correlationId", session.correlation_id.as_ref()), + ("activityId", session.activity_id.as_ref()), + ("entraDeviceId", session.entra_device_id.as_ref()), + ("managedDeviceId", session.managed_device_id.as_ref()), + ("espPhase", session.phase.as_ref()), + ] { + if let Some(value) = value { + named_data.push(IntuneNamedValue { + name: name.to_owned(), + value: value.clone(), + }); + } + } + AutopilotObservation { + observation_id: session.evidence.evidence_id.clone(), + context: IntuneObservationContext { + evidence_ref: session.evidence.clone(), + provenance: IntuneProvenance { + source_kind: IntuneSourceKind::SuppliedFact, + source_artifact_id: session.evidence.source_artifact_id.clone(), + file_path: None, + line_number: None, + record_number: None, + registry: None, + event: None, + }, + source_timestamp: None, + observed_at_utc: session.started_at_utc.clone().unwrap_or_default(), + sensitivity: IntuneSensitivity::Sensitive, + parse_state: IntuneParseState::Parsed, + access_state: IntuneAccessState::Available, + }, + signal: AutopilotSignal::EspSessionFact, + channel: None, + provider: None, + event_id: None, + event_version: None, + activity_id: session.activity_id.clone(), + section_kind: None, + section_outcome: None, + error: None, + named_data, + message: None, + } +} + +// ── Time ──────────────────────────────────────────────────────────────────── + +/// Decide whether wall-clock time may be used for anything. +/// +/// Both conditions must hold: the collection declared a timezone whose shape is +/// recognizable, and every timestamp that exists was normalized from an +/// explicit UTC or offset form. A bare local timestamp with no declared zone +/// would otherwise silently inherit whatever zone the reducing machine happens +/// to sit in, which is exactly the wrong-timezone inference that cannot be +/// audited afterwards. +fn time_basis( + timezone_state: AutopilotTimezoneState, + observations: &[AutopilotObservation], +) -> AutopilotTimeBasis { + if timezone_state != AutopilotTimezoneState::Declared { + return AutopilotTimeBasis::Unreliable; + } + let all_normalized = observations.iter().all(|observation| { + observation + .context + .source_timestamp + .as_ref() + .is_none_or(|timestamp| { + timestamp.normalized_utc.is_some() + && matches!( + timestamp.kind, + IntuneTimestampKind::Utc | IntuneTimestampKind::Offset + ) + }) + }); + if all_normalized { + AutopilotTimeBasis::Utc + } else { + AutopilotTimeBasis::Unreliable + } +} + +/// Order observations deterministically. +/// +/// When the time basis is unreliable the sort deliberately ignores timestamps +/// entirely and falls back to record id then observation id, so the ordering is +/// reproducible without implying a chronology the evidence does not support. +fn sort_observations(observations: &mut [AutopilotObservation], basis: AutopilotTimeBasis) { + observations.sort_by(|left, right| { + let left_time = sort_time(left, basis); + let right_time = sort_time(right, basis); + left_time + .cmp(&right_time) + .then_with(|| { + left.context + .provenance + .record_number + .cmp(&right.context.provenance.record_number) + }) + .then_with(|| left.observation_id.cmp(&right.observation_id)) + }); +} + +fn sort_time(observation: &AutopilotObservation, basis: AutopilotTimeBasis) -> Option<&str> { + if basis != AutopilotTimeBasis::Utc { + return None; + } + observation + .context + .source_timestamp + .as_ref() + .and_then(|timestamp| timestamp.normalized_utc.as_deref()) +} + +// ── Field reduction ───────────────────────────────────────────────────────── + +/// An observation is assessable only when its record was fully available and +/// parsed. Malformed or access-denied records carry no semantic signal. +fn is_assessable(observation: &AutopilotObservation) -> bool { + observation.context.access_state == IntuneAccessState::Available + && observation.context.parse_state == IntuneParseState::Parsed +} + +fn signal_observations( + observations: &[AutopilotObservation], + signal: AutopilotSignal, +) -> impl Iterator { + observations + .iter() + .filter(move |observation| is_assessable(observation) && observation.signal == signal) +} + +fn has_signal(observations: &[AutopilotObservation], signal: AutopilotSignal) -> bool { + signal_observations(observations, signal).next().is_some() +} + +fn sections_of<'a>( + sections: &'a [AutopilotReportSection], + kind: &AutopilotSectionKind, +) -> impl Iterator { + let kind = kind.clone(); + sections.iter().filter(move |section| section.kind == kind) +} + +/// Collect the distinct values a named key takes across every source. +/// +/// Returned sorted and deduplicated so that "more than one value" is a +/// reproducible fact rather than an artifact of iteration order. +fn distinct_values( + observations: &[AutopilotObservation], + key: &str, +) -> BTreeMap> { + let mut values: BTreeMap> = BTreeMap::new(); + for observation in observations.iter().filter(|obs| is_assessable(obs)) { + let Some(value) = observation.named(key) else { + continue; + }; + let value = value.trim(); + if value.is_empty() { + continue; + } + values + .entry(value.to_owned()) + .or_default() + .push(observation.evidence_ref()); + } + values +} + +fn single_value(observations: &[AutopilotObservation], key: &str) -> Option { + let values = distinct_values(observations, key); + let mut keys = values.into_keys(); + let first = keys.next()?; + // More than one distinct value is a conflict, reported separately. Picking + // one here would hide it. + if keys.next().is_some() { + return None; + } + Some(first) +} + +fn reduce_identity( + observations: &[AutopilotObservation], + sections: &[AutopilotReportSection], +) -> AutopilotDeviceIdentity { + let mut identity = AutopilotDeviceIdentity { + serial_number: single_value(observations, "serialNumber"), + hardware_hash: single_value(observations, "hardwareHash"), + product_key_id: single_value(observations, "productKeyId"), + ztd_registration_id: single_value(observations, "ztdRegistrationId"), + entra_device_id: single_value(observations, "entraDeviceId"), + managed_device_id: single_value(observations, "managedDeviceId"), + tenant_id: single_value(observations, "tenantId"), + tenant_domain: single_value(observations, "tenantDomain"), + device_name: single_value(observations, "deviceName"), + registration_state: AutopilotRegistrationState::Unknown, + evidence: Vec::new(), + }; + + let mut evidence = Vec::new(); + let mut state = AutopilotRegistrationState::Unknown; + + for section in sections_of(sections, &AutopilotSectionKind::DeviceIdentity) { + evidence.push(section.context.evidence_ref.clone()); + state = merge_registration_state( + state, + match section.outcome { + AutopilotSectionOutcome::Succeeded | AutopilotSectionOutcome::Observed => { + AutopilotRegistrationState::Registered + } + AutopilotSectionOutcome::NotFound => AutopilotRegistrationState::NotRegistered, + AutopilotSectionOutcome::Mismatch => AutopilotRegistrationState::Mismatch, + _ => AutopilotRegistrationState::Unknown, + }, + ); + } + for observation in signal_observations(observations, AutopilotSignal::DeviceNotRegistered) { + evidence.push(observation.evidence_ref()); + state = merge_registration_state(state, AutopilotRegistrationState::NotRegistered); + } + for observation in signal_observations(observations, AutopilotSignal::IdentityMismatch) { + evidence.push(observation.evidence_ref()); + state = merge_registration_state(state, AutopilotRegistrationState::Mismatch); + } + for observation in observations.iter().filter(|observation| { + IDENTITY_KEYS + .iter() + .any(|key| observation.named(key).is_some()) + }) { + evidence.push(observation.evidence_ref()); + } + + identity.registration_state = state; + identity.evidence = normalized_evidence(evidence); + identity +} + +/// Combine two registration observations. +/// +/// A mismatch outranks everything: a device can be both registered and +/// mismatched, and the mismatch is the fact that matters. `NotRegistered` +/// outranks `Registered` for the same reason a failure outranks a success -- +/// the negative statement is the one that blocks provisioning. +fn merge_registration_state( + current: AutopilotRegistrationState, + incoming: AutopilotRegistrationState, +) -> AutopilotRegistrationState { + fn rank(state: AutopilotRegistrationState) -> u8 { + match state { + AutopilotRegistrationState::Unknown => 0, + AutopilotRegistrationState::Registered => 1, + AutopilotRegistrationState::NotRegistered => 2, + AutopilotRegistrationState::Mismatch => 3, + } + } + if rank(incoming) > rank(current) { + incoming + } else { + current + } +} + +fn reduce_profile( + observations: &[AutopilotObservation], + sections: &[AutopilotReportSection], +) -> AutopilotProfileState { + let mut evidence = Vec::new(); + let mut candidate = AutopilotProfileCandidateState::Unknown; + let mut retrieved = false; + let mut applied = false; + let mut error: Option = None; + let mut last_state_token = None; + + for observation in observations { + match observation.signal { + AutopilotSignal::ProfileAcquisitionStarted + | AutopilotSignal::ProfilePolicyNotFound + | AutopilotSignal::NetworkAvailableForDownload => { + evidence.push(observation.evidence_ref()); + candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Pending); + } + AutopilotSignal::ProfileRetrieveSucceeded + | AutopilotSignal::ProfileSettingsRetrieved => { + evidence.push(observation.evidence_ref()); + retrieved = true; + candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Available); + } + AutopilotSignal::ProfileStateChanged => { + evidence.push(observation.evidence_ref()); + let token = extract_profile_state(observation.message.as_deref()); + if matches!( + token, + Some(AutopilotProfileStateToken::Available) + | Some(AutopilotProfileStateToken::Provisioned) + ) { + applied = true; + candidate = + raise_candidate(candidate, AutopilotProfileCandidateState::Available); + } + if token.is_some() { + last_state_token = token; + } + } + AutopilotSignal::DeviceAlreadyProvisioned => { + evidence.push(observation.evidence_ref()); + applied = true; + candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Available); + } + AutopilotSignal::NoAssignedProfile => { + evidence.push(observation.evidence_ref()); + candidate = AutopilotProfileCandidateState::NoneAssigned; + } + AutopilotSignal::AssignedProfileMissing => { + evidence.push(observation.evidence_ref()); + candidate = AutopilotProfileCandidateState::AssignedButMissing; + } + AutopilotSignal::ProfileApplicationFailed => { + evidence.push(observation.evidence_ref()); + error = error.or_else(|| observation.error.clone()); + } + _ => {} + } + } + + for section in sections_of(sections, &AutopilotSectionKind::ProfileRetrieval) { + evidence.push(section.context.evidence_ref.clone()); + match section.outcome { + AutopilotSectionOutcome::Succeeded => { + retrieved = true; + candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Available); + } + AutopilotSectionOutcome::NotFound => { + candidate = AutopilotProfileCandidateState::NoneAssigned; + } + AutopilotSectionOutcome::Retrying => { + candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Pending); + } + _ => {} + } + error = error.or_else(|| section.error.clone()); + } + for section in sections_of(sections, &AutopilotSectionKind::ProfileApplication) { + evidence.push(section.context.evidence_ref.clone()); + if section.outcome == AutopilotSectionOutcome::Succeeded { + applied = true; + } + error = error.or_else(|| section.error.clone()); + } + + AutopilotProfileState { + profile_id: single_value(observations, "profileId"), + profile_name: single_value(observations, "profileName"), + deployment_profile_type: single_value(observations, "deploymentProfileType") + .map(|value| deployment_profile_type(&value)), + candidate_state: candidate, + last_state_token, + retrieved, + applied, + error, + evidence: normalized_evidence(evidence), + } +} + +/// Raise a candidate state, never lowering one that was explicitly proven. +/// +/// `NoneAssigned` and `AssignedButMissing` are assigned directly rather than +/// through this function: those are explicit service statements and must be +/// able to overwrite an earlier optimistic `Pending`. +fn raise_candidate( + current: AutopilotProfileCandidateState, + incoming: AutopilotProfileCandidateState, +) -> AutopilotProfileCandidateState { + fn rank(state: AutopilotProfileCandidateState) -> u8 { + match state { + AutopilotProfileCandidateState::Unknown => 0, + AutopilotProfileCandidateState::Pending => 1, + AutopilotProfileCandidateState::NoneAssigned => 2, + AutopilotProfileCandidateState::AssignedButMissing => 2, + AutopilotProfileCandidateState::Available => 3, + } + } + if rank(incoming) > rank(current) { + incoming + } else { + current + } +} + +fn deployment_profile_type(raw: &str) -> AutopilotDeploymentProfileType { + serde_json::from_value(serde_json::Value::String(raw.to_owned())) + .unwrap_or_else(|_| AutopilotDeploymentProfileType::Unknown(raw.to_owned())) +} + +fn reduce_oobe(observations: &[AutopilotObservation]) -> AutopilotOobeState { + let mut settings = Vec::new(); + let mut evidence = Vec::new(); + let mut already_provisioned = false; + + for observation in signal_observations(observations, AutopilotSignal::OobeSettingObserved) { + evidence.push(observation.evidence_ref()); + if let Some((setting, state)) = extract_oobe_setting(observation.message.as_deref()) { + settings.push(AutopilotOobeSettingObservation { + setting, + state: Some(state), + evidence: observation.evidence_ref(), + }); + } + } + for observation in signal_observations(observations, AutopilotSignal::DeviceAlreadyProvisioned) + { + evidence.push(observation.evidence_ref()); + already_provisioned = true; + } + + AutopilotOobeState { + settings, + already_provisioned, + evidence: normalized_evidence(evidence), + } +} + +fn reduce_handoff( + observations: &[AutopilotObservation], + sections: &[AutopilotReportSection], +) -> AutopilotHandoff { + let mut handoff = AutopilotHandoff::default(); + let mut evidence = Vec::new(); + + for section in sections_of(sections, &AutopilotSectionKind::EnrollmentHandoff) { + if matches!( + section.outcome, + AutopilotSectionOutcome::Succeeded | AutopilotSectionOutcome::Observed + ) { + handoff.enrollment_observed = true; + evidence.push(section.context.evidence_ref.clone()); + } + } + for section in sections_of(sections, &AutopilotSectionKind::EspHandoff) { + if matches!( + section.outcome, + AutopilotSectionOutcome::Succeeded | AutopilotSectionOutcome::Observed + ) { + handoff.esp_observed = true; + evidence.push(section.context.evidence_ref.clone()); + } + } + + handoff.enrollment_id = single_value(observations, "enrollmentId"); + handoff.correlation_id = single_value(observations, "correlationId"); + handoff.evidence = normalized_evidence(evidence); + handoff +} + +// ── Conflicts ─────────────────────────────────────────────────────────────── + +fn detect_conflicts( + observations: &[AutopilotObservation], + sections: &[AutopilotReportSection], + esp_sessions: &[AutopilotEspSessionFact], +) -> Vec { + let mut conflicts = Vec::new(); + + for (key, kind, conflict_id) in [ + ( + "profileId", + AutopilotConflictKind::ProfileIdentifier, + "conflicting-profile-id", + ), + ( + "serialNumber", + AutopilotConflictKind::DeviceIdentity, + "conflicting-serial-number", + ), + ( + "entraDeviceId", + AutopilotConflictKind::DeviceIdentity, + "conflicting-entra-device-id", + ), + ] { + let values = distinct_values(observations, key); + if values.len() < 2 { + continue; + } + let evidence = values.values().flatten().cloned().collect::>(); + conflicts.push(AutopilotConflict { + conflict_id: conflict_id.to_owned(), + kind, + detail: format!( + "{} distinct {key} values were observed across the supplied sources.", + values.len() + ), + values: values.into_keys().collect(), + evidence: normalized_evidence(evidence), + }); + } + + // One correlation key resolving to more than one ESP session means the + // bundle cannot say which session it handed off to. + let mut by_key: BTreeMap> = BTreeMap::new(); + for session in esp_sessions { + for key in session_keys(session) { + by_key + .entry(key) + .or_default() + .insert(session.session_id.clone()); + } + } + let ambiguous = by_key + .iter() + .filter(|(_, sessions)| sessions.len() > 1) + .collect::>(); + if !ambiguous.is_empty() { + let mut sessions = BTreeSet::new(); + for (_, ids) in &ambiguous { + sessions.extend(ids.iter().cloned()); + } + let evidence = esp_sessions + .iter() + .filter(|session| sessions.contains(&session.session_id)) + .map(|session| session.evidence.clone()) + .collect::>(); + conflicts.push(AutopilotConflict { + conflict_id: "conflicting-esp-session-id".to_owned(), + kind: AutopilotConflictKind::EspSessionIdentifier, + detail: "A single correlation key resolves to more than one ESP session.".to_owned(), + values: sessions.into_iter().collect(), + evidence: normalized_evidence(evidence), + }); + } + + // A report section that explicitly reports a mismatch is a conflict the + // collector already detected; carrying it here keeps the two paths uniform. + for section in sections + .iter() + .filter(|section| section.outcome == AutopilotSectionOutcome::Mismatch) + { + conflicts.push(AutopilotConflict { + conflict_id: format!("reported-mismatch-{}", section.section_id), + kind: AutopilotConflictKind::DeviceIdentity, + detail: section + .message + .clone() + .unwrap_or_else(|| "A report section reported an identity mismatch.".to_owned()), + values: section + .values + .iter() + .map(|value| format!("{}={}", value.name, value.value)) + .collect(), + evidence: vec![section.context.evidence_ref.clone()], + }); + } + + conflicts.sort_by(|left, right| left.conflict_id.cmp(&right.conflict_id)); + conflicts +} + +fn session_keys(session: &AutopilotEspSessionFact) -> Vec { + [ + ( + AutopilotCorrelationKeyKind::EnrollmentId, + session.enrollment_id.as_ref(), + ), + ( + AutopilotCorrelationKeyKind::CorrelationId, + session.correlation_id.as_ref(), + ), + ( + AutopilotCorrelationKeyKind::ActivityId, + session.activity_id.as_ref(), + ), + ( + AutopilotCorrelationKeyKind::EntraDeviceId, + session.entra_device_id.as_ref(), + ), + ( + AutopilotCorrelationKeyKind::ManagedDeviceId, + session.managed_device_id.as_ref(), + ), + ] + .into_iter() + .filter_map(|(kind, value)| { + let value = value?.trim(); + (!value.is_empty()).then(|| AutopilotCorrelationKey { + kind, + value: value.to_ascii_lowercase(), + }) + }) + .collect() +} + +/// The explicit keys the Autopilot side of the bundle can offer. +/// +/// Deliberately skips [`AutopilotSignal::EspSessionFact`] observations: an ESP +/// fact matching its own key would be a tautology, not a correlation. +fn autopilot_keys(observations: &[AutopilotObservation]) -> BTreeSet { + const NAMED_KEYS: [(&str, AutopilotCorrelationKeyKind); 5] = [ + ("enrollmentId", AutopilotCorrelationKeyKind::EnrollmentId), + ("correlationId", AutopilotCorrelationKeyKind::CorrelationId), + ("activityId", AutopilotCorrelationKeyKind::ActivityId), + ("entraDeviceId", AutopilotCorrelationKeyKind::EntraDeviceId), + ( + "managedDeviceId", + AutopilotCorrelationKeyKind::ManagedDeviceId, + ), + ]; + + let mut keys = BTreeSet::new(); + for observation in observations + .iter() + .filter(|observation| observation.signal != AutopilotSignal::EspSessionFact) + { + for (name, kind) in NAMED_KEYS { + let Some(value) = observation.named(name).map(str::trim) else { + continue; + }; + if !value.is_empty() { + keys.insert(AutopilotCorrelationKey { + kind, + value: value.to_ascii_lowercase(), + }); + } + } + if let Some(activity_id) = observation + .activity_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + keys.insert(AutopilotCorrelationKey { + kind: AutopilotCorrelationKeyKind::ActivityId, + value: activity_id.to_ascii_lowercase(), + }); + } + } + keys +} + +fn reduce_esp_linkage( + observations: &[AutopilotObservation], + esp_sessions: &[AutopilotEspSessionFact], + handoff: &AutopilotHandoff, + conflicts: &[AutopilotConflict], + time_basis: AutopilotTimeBasis, +) -> AutopilotEspLinkage { + if esp_sessions.is_empty() { + return AutopilotEspLinkage { + state: if handoff.esp_observed { + AutopilotEspLinkState::EvidenceMissing + } else { + AutopilotEspLinkState::NotObserved + }, + confidence: if handoff.esp_observed { + IntuneFindingConfidence::High + } else { + IntuneFindingConfidence::Low + }, + matched_keys: Vec::new(), + esp_session_ids: Vec::new(), + evidence: normalized_evidence(handoff.evidence.clone()), + }; + } + + if conflicts + .iter() + .any(|conflict| conflict.kind == AutopilotConflictKind::EspSessionIdentifier) + { + return AutopilotEspLinkage { + state: AutopilotEspLinkState::Conflicting, + confidence: IntuneFindingConfidence::High, + matched_keys: Vec::new(), + esp_session_ids: sorted_unique( + esp_sessions + .iter() + .map(|session| session.session_id.clone()), + ), + evidence: normalized_evidence( + esp_sessions + .iter() + .map(|session| session.evidence.clone()) + .collect(), + ), + }; + } + + let local_keys = autopilot_keys(observations); + + let mut matched_keys = BTreeSet::new(); + let mut matched_sessions = BTreeSet::new(); + let mut evidence = Vec::new(); + for session in esp_sessions { + for key in session_keys(session) { + if local_keys.contains(&key) { + matched_keys.insert(key); + matched_sessions.insert(session.session_id.clone()); + evidence.push(session.evidence.clone()); + } + } + } + + if matched_keys.is_empty() { + // ESP facts exist but nothing binds them via an explicit key. + // Only emit TimeOnlyCandidate when the collection's time basis is UTC + // (timezone was declared and recognized) AND there is an actual + // overlap between Autopilot observation timestamps and ESP session + // start times. An unreliable time basis or a non-overlapping window + // cannot support even a low-confidence proximity claim. + let state = if time_basis == AutopilotTimeBasis::Utc + && has_time_overlap(observations, esp_sessions) + { + AutopilotEspLinkState::TimeOnlyCandidate + } else { + AutopilotEspLinkState::NotObserved + }; + return AutopilotEspLinkage { + state, + confidence: IntuneFindingConfidence::Low, + matched_keys: Vec::new(), + esp_session_ids: if state == AutopilotEspLinkState::TimeOnlyCandidate { + sorted_unique(esp_sessions.iter().map(|s| s.session_id.clone())) + } else { + Vec::new() + }, + evidence: if state == AutopilotEspLinkState::TimeOnlyCandidate { + normalized_evidence(esp_sessions.iter().map(|s| s.evidence.clone()).collect()) + } else { + Vec::new() + }, + }; + } + + for observation in observations + .iter() + .filter(|observation| observation.signal != AutopilotSignal::EspSessionFact) + { + if session_key_values(&matched_keys).any(|value| observation_mentions(observation, value)) { + evidence.push(observation.evidence_ref()); + } + } + + // A single Autopilot phase can only have produced one ESP session. + // If matched keys resolve to more than one session, the identity space + // is ambiguous; emit Conflicting rather than silently merging them. + if matched_sessions.len() > 1 { + return AutopilotEspLinkage { + state: AutopilotEspLinkState::Conflicting, + confidence: IntuneFindingConfidence::High, + matched_keys: matched_keys.into_iter().collect(), + esp_session_ids: matched_sessions.into_iter().collect(), + evidence: normalized_evidence(evidence), + }; + } + + AutopilotEspLinkage { + state: AutopilotEspLinkState::Linked, + confidence: IntuneFindingConfidence::High, + matched_keys: matched_keys.into_iter().collect(), + esp_session_ids: matched_sessions.into_iter().collect(), + evidence: normalized_evidence(evidence), + } +} + +fn session_key_values(keys: &BTreeSet) -> impl Iterator { + keys.iter().map(|key| key.value.as_str()) +} + +/// Whether Autopilot observation timestamps and ESP session timestamps overlap. +/// Uses UTC string comparison on ISO-8601 timestamps; imprecise but sufficient +/// for a low-confidence time-only candidate gate (not for a hard conclusion). +fn has_time_overlap( + observations: &[AutopilotObservation], + esp_sessions: &[AutopilotEspSessionFact], +) -> bool { + let ap_times: Vec<&str> = observations + .iter() + .filter_map(|obs| { + obs.context + .source_timestamp + .as_ref() + .and_then(|ts| ts.normalized_utc.as_deref()) + .filter(|ts| !ts.is_empty()) + }) + .collect(); + let esp_times: Vec<&str> = esp_sessions + .iter() + .filter_map(|session| { + session + .started_at_utc + .as_deref() + .filter(|ts| !ts.is_empty()) + }) + .collect(); + if ap_times.is_empty() || esp_times.is_empty() { + return false; + } + // Find the min/max of Autopilot times and check if any ESP time falls within. + let ap_min = ap_times.iter().min().copied().unwrap_or(""); + let ap_max = ap_times.iter().max().copied().unwrap_or(""); + esp_times + .iter() + .any(|esp_t| *esp_t >= ap_min && *esp_t <= ap_max) +} + +fn observation_mentions(observation: &AutopilotObservation, value: &str) -> bool { + observation + .named_data + .iter() + .any(|named| named.value.trim().eq_ignore_ascii_case(value)) + || observation + .activity_id + .as_deref() + .is_some_and(|activity| activity.trim().eq_ignore_ascii_case(value)) +} + +// ── Phase, outcome, confidence ────────────────────────────────────────────── + +fn reduce_phase( + identity: &AutopilotDeviceIdentity, + profile: &AutopilotProfileState, + handoff: &AutopilotHandoff, +) -> AutopilotPhase { + let mut phase = AutopilotPhase::NoEvidence; + if identity.registration_state != AutopilotRegistrationState::Unknown + || !identity.evidence.is_empty() + { + phase = phase.max(AutopilotPhase::IdentityObserved); + } + if profile.candidate_state != AutopilotProfileCandidateState::Unknown { + phase = phase.max(AutopilotPhase::ProfileDiscovery); + } + if profile.retrieved { + phase = phase.max(AutopilotPhase::ProfileRetrieved); + } + if profile.applied { + phase = phase.max(AutopilotPhase::ProfileApplied); + } + if handoff.enrollment_observed { + phase = phase.max(AutopilotPhase::EnrollmentHandoff); + } + if handoff.esp_observed { + phase = phase.max(AutopilotPhase::EspHandoff); + } + phase +} + +#[allow(clippy::too_many_arguments)] +fn reduce_outcome( + capture_validated: bool, + conflicts: &[AutopilotConflict], + identity: &AutopilotDeviceIdentity, + profile: &AutopilotProfileState, + handoff: &AutopilotHandoff, + esp_linkage: &AutopilotEspLinkage, + observations: &[AutopilotObservation], + sections: &[AutopilotReportSection], +) -> AutopilotOutcome { + // An unvalidated build, schema, or document version refuses every terminal + // conclusion. The evidence is still retained; only its meaning is withheld. + if !capture_validated { + return AutopilotOutcome::UnknownSchema; + } + if !conflicts.is_empty() { + return AutopilotOutcome::ContradictoryEvidence; + } + if identity.registration_state == AutopilotRegistrationState::Mismatch + || has_signal(observations, AutopilotSignal::TpmIdentityFailed) + { + return AutopilotOutcome::IdentityRegistrationMismatch; + } + if identity.registration_state == AutopilotRegistrationState::NotRegistered { + return AutopilotOutcome::MissingRegistrationEvidence; + } + if matches!( + profile.candidate_state, + AutopilotProfileCandidateState::NoneAssigned + | AutopilotProfileCandidateState::AssignedButMissing + ) { + return AutopilotOutcome::NoProfileCandidate; + } + if sections_of(sections, &AutopilotSectionKind::ProfileRetrieval) + .any(|section| section.outcome == AutopilotSectionOutcome::Failed) + { + return AutopilotOutcome::ProfileRetrievalFailure; + } + if has_signal(observations, AutopilotSignal::ProfileApplicationFailed) + || sections_of(sections, &AutopilotSectionKind::ProfileApplication) + .any(|section| section.outcome == AutopilotSectionOutcome::Failed) + { + return AutopilotOutcome::ProfileApplicationFailure; + } + if profile.applied && handoff.esp_observed { + return match esp_linkage.state { + AutopilotEspLinkState::EvidenceMissing => { + AutopilotOutcome::HandoffReachedEspEvidenceMissing + } + _ => AutopilotOutcome::Completed, + }; + } + // A network or service symptom is only reported once every proven cause + // above has been ruled out; that is what "without proven cause" means. + if sections_of(sections, &AutopilotSectionKind::NetworkService) + .any(|section| matches!(section.outcome, AutopilotSectionOutcome::Failed)) + { + return AutopilotOutcome::NetworkOrServiceSymptom; + } + if sections_of(sections, &AutopilotSectionKind::ProfileRetrieval) + .any(|section| section.outcome == AutopilotSectionOutcome::Retrying) + || (has_signal(observations, AutopilotSignal::ProfilePolicyNotFound) && !profile.retrieved) + { + return AutopilotOutcome::RetryDeferred; + } + AutopilotOutcome::InsufficientEvidence +} + +fn reduce_confidence( + outcome: AutopilotOutcome, + capture_validated: bool, + time_basis: AutopilotTimeBasis, + coverage: &[IntuneArtifactCoverage], +) -> IntuneFindingConfidence { + if matches!( + outcome, + AutopilotOutcome::UnknownSchema + | AutopilotOutcome::InsufficientEvidence + | AutopilotOutcome::NetworkOrServiceSymptom + ) { + return IntuneFindingConfidence::Low; + } + let coverage_is_complete = coverage + .iter() + .all(|entry| entry.status == IntuneArtifactStatus::Available); + if !capture_validated || time_basis != AutopilotTimeBasis::Utc || !coverage_is_complete { + return IntuneFindingConfidence::Medium; + } + IntuneFindingConfidence::High +} + +/// Name the smallest artifacts that would advance the diagnosis. +/// +/// Ordered most-specific first, so a human collects one file rather than a +/// whole bundle when one file would settle it. +fn next_evidence_requests( + outcome: AutopilotOutcome, + esp_linkage: &AutopilotEspLinkage, + coverage: &[IntuneArtifactCoverage], +) -> Vec { + let mut requests: Vec = match outcome { + AutopilotOutcome::NoProfileCandidate => vec![ + "The device's Autopilot registration and assigned deployment profile in Intune".to_owned(), + format!("{AUTOPILOT_EVENT_CHANNEL} events 809 and 815"), + ], + AutopilotOutcome::ProfileRetrievalFailure => vec![ + format!("{AUTOPILOT_EVENT_CHANNEL} events 160, 100, and 161"), + "The Autopilot section of an MdmDiagnosticsTool export (-area Autopilot)".to_owned(), + ], + AutopilotOutcome::ProfileApplicationFailure => vec![ + format!("{AUTOPILOT_EVENT_CHANNEL} events 172 and 153"), + "TpmHliInfo_Output.txt from the same diagnostics bundle".to_owned(), + ], + AutopilotOutcome::IdentityRegistrationMismatch => vec![ + "The registered serial number and hardware hash for this device in Intune".to_owned(), + format!("{AUTOPILOT_EVENT_CHANNEL} events 908 and 171"), + ], + AutopilotOutcome::MissingRegistrationEvidence => vec![ + format!("{AUTOPILOT_EVENT_CHANNEL} event 807"), + "MdmDiagReport_RegistryDump.reg from the same diagnostics bundle".to_owned(), + ], + AutopilotOutcome::NetworkOrServiceSymptom => vec![ + "A network trace or proxy log covering the Autopilot service endpoints".to_owned(), + format!("{AUTOPILOT_EVENT_CHANNEL} event 164"), + ], + AutopilotOutcome::RetryDeferred => vec![format!( + "A later capture of {AUTOPILOT_EVENT_CHANNEL} once acquisition settles" + )], + AutopilotOutcome::HandoffReachedEspEvidenceMissing => { + vec!["The ESP diagnostics bundle for the correlated enrollment".to_owned()] + } + AutopilotOutcome::ContradictoryEvidence => { + vec!["A fresh single-pass collection, so the sources share one point in time".to_owned()] + } + AutopilotOutcome::UnknownSchema => vec![ + "A diagnostics export from a Windows build this build has validated rules for" + .to_owned(), + ], + AutopilotOutcome::InsufficientEvidence => vec![ + format!("{AUTOPILOT_EVENT_CHANNEL} (microsoft-windows-moderndeployment-diagnostics-provider-autopilot.evtx)"), + "An MdmDiagnosticsTool export covering the Autopilot area".to_owned(), + ], + AutopilotOutcome::Completed => Vec::new(), + }; + + if esp_linkage.state == AutopilotEspLinkState::TimeOnlyCandidate { + requests.push( + "An enrollment, correlation, or device identifier shared by the Autopilot and ESP evidence" + .to_owned(), + ); + } + for entry in coverage + .iter() + .filter(|entry| entry.status != IntuneArtifactStatus::Available) + { + requests.push(format!( + "Re-collect {} ({})", + entry.artifact_id, + status_wire(&entry.status) + )); + } + requests.dedup(); + requests +} + +fn status_wire(status: &IntuneArtifactStatus) -> String { + serde_json::to_value(status) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_default() +} + +// ── Shared helpers ────────────────────────────────────────────────────────── + +/// Sort and de-duplicate evidence so a snapshot is byte-identical across runs. +pub(super) fn normalized_evidence(mut evidence: Vec) -> Vec { + evidence.sort(); + evidence.dedup(); + evidence +} + +fn sorted_unique(values: impl IntoIterator) -> Vec { + values + .into_iter() + .collect::>() + .into_iter() + .collect() +} diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs new file mode 100644 index 000000000..52e06d050 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs @@ -0,0 +1,761 @@ +//! Findings derived from an immutable Autopilot snapshot. +//! +//! Shaped exactly like `crate::esp::rules`: one public entry point, one private +//! `push_*` per rule, and a shared constructor that **refuses to build a finding +//! citing neither evidence nor a coverage gap**. That refusal is the enforcement +//! point for the conservative-findings rule; a rule that finds nothing simply +//! returns nothing rather than asserting something unsupported. +//! +//! Every finding names the next smallest artifact that would settle it. "Collect +//! the whole MDM bundle" is not an answer when one event ID would do. + +use crate::intune::evidence::{ + IntuneArtifactStatus, IntuneEvidenceRef, IntuneFinding, IntuneFindingConfidence, + IntuneFindingSeverity, IntuneParseState, +}; + +use super::models::*; +use super::reducer::normalized_evidence; +use super::sources::AUTOPILOT_EVENT_CHANNEL; + +/// Derive stable, read-only findings from a snapshot. +/// +/// Deterministic: the rules run in a fixed order and each one sorts its own +/// evidence, so two runs over the same snapshot produce byte-identical output. +pub fn derive_findings(snapshot: &AutopilotSnapshot) -> Vec { + let mut findings = Vec::new(); + + push_unknown_schema(snapshot, &mut findings); + push_malformed_document(snapshot, &mut findings); + push_contradictory_evidence(snapshot, &mut findings); + push_identity_mismatch(snapshot, &mut findings); + push_missing_registration(snapshot, &mut findings); + push_no_profile_candidate(snapshot, &mut findings); + push_profile_retrieval_failure(snapshot, &mut findings); + push_profile_application_failure(snapshot, &mut findings); + push_network_or_service_symptom(snapshot, &mut findings); + push_retry_deferred(snapshot, &mut findings); + push_esp_evidence_missing(snapshot, &mut findings); + push_esp_time_only_candidate(snapshot, &mut findings); + push_esp_linked(snapshot, &mut findings); + push_unreliable_timestamps(snapshot, &mut findings); + push_coverage_gaps(snapshot, &mut findings); + push_unclassified_records(snapshot, &mut findings); + push_completed(snapshot, &mut findings); + + findings +} + +// ── Schema and integrity ──────────────────────────────────────────────────── + +fn push_unknown_schema(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + let unsupported = snapshot + .documents + .iter() + .filter(|document| document.parse_state == IntuneParseState::Unsupported) + .collect::>(); + let build_unvalidated = snapshot.outcome == AutopilotOutcome::UnknownSchema + && unsupported.is_empty() + && snapshot.capture.windows_build.is_some(); + if unsupported.is_empty() && !build_unvalidated { + return; + } + + let mut summary = if unsupported.is_empty() { + format!( + "Windows build {} has no validated Autopilot rules in this build, so terminal \ + semantics are withheld and the evidence is retained raw.", + snapshot + .capture + .windows_build + .as_deref() + .unwrap_or("unknown") + ) + } else { + format!( + "{} supplied document(s) declare a kind or version this build has no validated \ + rules for. Their bytes are retained as raw evidence; their meaning is not.", + unsupported.len() + ) + }; + if let Some(detail) = unsupported + .first() + .and_then(|document| document.detail.clone()) + { + summary.push(' '); + summary.push_str(&detail); + } + + push( + findings, + finding( + "autopilot-unknown-schema", + IntuneFindingSeverity::Warning, + IntuneFindingConfidence::High, + "Autopilot evidence uses a schema or build this release cannot interpret", + &summary, + &[ + "Confirm the Windows build and Autopilot diagnostics schema of the source device." + .to_owned(), + "Re-collect on a build with validated rules, or upgrade CMTrace Open.".to_owned(), + ], + snapshot + .coverage + .iter() + .filter(|entry| entry.status == IntuneArtifactStatus::Unsupported) + .flat_map(|entry| entry.evidence.iter().cloned()) + .collect(), + unsupported + .iter() + .map(|document| document.artifact_id.clone()) + .chain( + build_unvalidated + .then(|| { + snapshot + .coverage + .iter() + .map(|entry| entry.artifact_id.clone()) + }) + .into_iter() + .flatten(), + ) + .collect(), + ), + ); +} + +fn push_malformed_document(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + let malformed = snapshot + .documents + .iter() + .filter(|document| document.parse_state == IntuneParseState::Malformed) + .collect::>(); + if malformed.is_empty() { + return; + } + let detail = malformed + .iter() + .filter_map(|document| document.detail.clone()) + .next() + .unwrap_or_else(|| "The content is not a tagged Autopilot document.".to_owned()); + push( + findings, + finding( + "autopilot-report-section-malformed", + IntuneFindingSeverity::Warning, + IntuneFindingConfidence::High, + "An Autopilot report section could not be parsed", + &format!( + "{} supplied document(s) could not be interpreted, so any signal they carried is \ + absent from this analysis rather than proven absent. {detail}", + malformed.len() + ), + &[ + "Re-export the Autopilot section of the MDM diagnostics report.".to_owned(), + "Confirm the export was not truncated in transit.".to_owned(), + ], + Vec::new(), + malformed + .iter() + .map(|document| document.artifact_id.clone()) + .collect(), + ), + ); +} + +fn push_contradictory_evidence(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.conflicts.is_empty() { + return; + } + let summary = snapshot + .conflicts + .iter() + .map(|conflict| conflict.detail.clone()) + .collect::>() + .join(" "); + push( + findings, + finding( + "autopilot-contradictory-sources", + IntuneFindingSeverity::Error, + IntuneFindingConfidence::High, + "Supplied Autopilot sources contradict each other", + &format!( + "{summary} No single Autopilot outcome can be asserted while the sources disagree." + ), + &[ + "Re-collect the Autopilot evidence in one pass so every source describes the same \ + point in time." + .to_owned(), + "Confirm the bundle was not assembled from two devices or two provisioning \ + attempts." + .to_owned(), + ], + snapshot + .conflicts + .iter() + .flat_map(|conflict| conflict.evidence.iter().cloned()) + .collect(), + Vec::new(), + ), + ); +} + +// ── Identity and registration ─────────────────────────────────────────────── + +fn push_identity_mismatch(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if terminal_semantics_withheld(snapshot) { + return; + } + let tpm_failures = signal_evidence(snapshot, AutopilotSignal::TpmIdentityFailed); + if snapshot.identity.registration_state != AutopilotRegistrationState::Mismatch + && tpm_failures.is_empty() + { + return; + } + let mut evidence = snapshot.identity.evidence.clone(); + evidence.extend(tpm_failures); + push( + findings, + finding( + "autopilot-identity-registration-mismatch", + IntuneFindingSeverity::Blocker, + IntuneFindingConfidence::High, + "The device's hardware identity does not match its Autopilot registration", + "A cited record reports a serial number, product key, or TPM identity that disagrees \ + with the identity registered for this device. Provisioning cannot proceed until the \ + registration matches the hardware.", + &[ + "Compare the registered serial number and hardware hash in Intune with the \ + device's own values." + .to_owned(), + format!("Read {AUTOPILOT_EVENT_CHANNEL} events 908 and 171 for the exact token."), + "Deregister and re-register the device if the hardware was replaced.".to_owned(), + ], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +fn push_missing_registration(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if terminal_semantics_withheld(snapshot) + || snapshot.identity.registration_state != AutopilotRegistrationState::NotRegistered + { + return; + } + let mut evidence = signal_evidence(snapshot, AutopilotSignal::DeviceNotRegistered); + evidence.extend(snapshot.identity.evidence.iter().cloned()); + push( + findings, + finding( + "autopilot-device-not-registered", + IntuneFindingSeverity::Blocker, + IntuneFindingConfidence::High, + "The device is not registered for Windows Autopilot", + "A cited record reports the device as not registered (ZtdDeviceIsNotRegistered), so \ + no deployment profile can be assigned to it.", + &[ + "Confirm the device's hardware hash was imported into Intune.".to_owned(), + "MdmDiagReport_RegistryDump.reg from the same bundle shows the registration state \ + the client actually saw." + .to_owned(), + ], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +// ── Profile ───────────────────────────────────────────────────────────────── + +fn push_no_profile_candidate(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if terminal_semantics_withheld(snapshot) { + return; + } + let (title, summary) = match snapshot.profile.candidate_state { + AutopilotProfileCandidateState::NoneAssigned => ( + "No Autopilot deployment profile is assigned to this device", + "A cited record reports that no profile is assigned to the device and that the tenant \ + has no default profile, so there is nothing for Autopilot to apply.", + ), + AutopilotProfileCandidateState::AssignedButMissing => ( + "The assigned Autopilot deployment profile no longer exists", + "A cited record reports that the profile assigned to this device has been deleted \ + without being unassigned first.", + ), + _ => return, + }; + let mut evidence = signal_evidence(snapshot, AutopilotSignal::NoAssignedProfile); + evidence.extend(signal_evidence( + snapshot, + AutopilotSignal::AssignedProfileMissing, + )); + evidence.extend(snapshot.profile.evidence.iter().cloned()); + push( + findings, + finding( + "autopilot-no-profile-candidate", + IntuneFindingSeverity::Blocker, + IntuneFindingConfidence::High, + title, + summary, + &[ + "Check the device's Autopilot device record and its group/profile assignment." + .to_owned(), + format!("Read {AUTOPILOT_EVENT_CHANNEL} events 809 and 815."), + ], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +fn push_profile_retrieval_failure(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.outcome != AutopilotOutcome::ProfileRetrievalFailure { + return; + } + let mut summary = "A cited record reports that profile retrieval failed after acquisition \ + began, so the device never received a profile to apply." + .to_owned(); + if let Some(error) = &snapshot.profile.error { + summary.push_str(&format!(" The reported code was {}.", error.raw)); + } + push( + findings, + finding( + "autopilot-profile-retrieval-failed", + IntuneFindingSeverity::Blocker, + IntuneFindingConfidence::High, + "The Autopilot deployment profile could not be retrieved", + &summary, + &[ + format!( + "Read {AUTOPILOT_EVENT_CHANNEL} events 160, 100, and 161 in order to see how \ + far acquisition got." + ), + "Confirm the Autopilot service endpoints are reachable from the provisioning \ + network." + .to_owned(), + ], + normalized_evidence(snapshot.profile.evidence.clone()), + Vec::new(), + ), + ); +} + +fn push_profile_application_failure( + snapshot: &AutopilotSnapshot, + findings: &mut Vec, +) { + if snapshot.outcome != AutopilotOutcome::ProfileApplicationFailure { + return; + } + let mut summary = "A cited record reports that the retrieved profile could not be made \ + available to the device, so its OOBE settings were never applied." + .to_owned(); + if let Some(error) = &snapshot.profile.error { + summary.push_str(&format!(" The reported code was {}.", error.raw)); + } + let mut evidence = signal_evidence(snapshot, AutopilotSignal::ProfileApplicationFailed); + evidence.extend(snapshot.profile.evidence.iter().cloned()); + push( + findings, + finding( + "autopilot-profile-application-failed", + IntuneFindingSeverity::Blocker, + IntuneFindingConfidence::High, + "The Autopilot deployment profile could not be applied", + &summary, + &[ + format!("Read {AUTOPILOT_EVENT_CHANNEL} events 172 and 153 for the HRESULT."), + "TpmHliInfo_Output.txt from the same bundle shows whether TPM attestation is the \ + underlying constraint." + .to_owned(), + ], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +fn push_network_or_service_symptom( + snapshot: &AutopilotSnapshot, + findings: &mut Vec, +) { + if snapshot.outcome != AutopilotOutcome::NetworkOrServiceSymptom { + return; + } + push( + findings, + finding( + "autopilot-network-symptom-without-cause", + IntuneFindingSeverity::Warning, + // Deliberately low: a connectivity symptom with every proven cause + // ruled out is a lead, not a diagnosis. + IntuneFindingConfidence::Low, + "A network or service symptom was reported with no proven cause", + "A cited record reports a network or service problem, but nothing in the bundle shows \ + which request failed or why. This is a lead to follow, not a root cause.", + &[ + "Capture a network trace or proxy log covering the Autopilot service endpoints." + .to_owned(), + format!( + "Confirm whether {AUTOPILOT_EVENT_CHANNEL} event 164 was ever logged for this \ + attempt." + ), + ], + normalized_evidence( + snapshot + .observations + .iter() + .filter(|observation| { + observation.section_kind.as_ref() + == Some(&AutopilotSectionKind::NetworkService) + }) + .map(AutopilotObservation::evidence_ref) + .collect(), + ), + Vec::new(), + ), + ); +} + +fn push_retry_deferred(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.outcome != AutopilotOutcome::RetryDeferred { + return; + } + push( + findings, + finding( + "autopilot-acquisition-deferred", + IntuneFindingSeverity::Info, + IntuneFindingConfidence::Medium, + "Autopilot profile acquisition is still retrying", + "Acquisition began and the profile was reported as not yet found. Microsoft documents \ + this state as usually transient, so it is not treated as a failure here.", + &[format!( + "Re-collect {AUTOPILOT_EVENT_CHANNEL} once the device settles, and compare event \ + 100 against a later event 161." + )], + normalized_evidence( + signal_evidence(snapshot, AutopilotSignal::ProfilePolicyNotFound) + .into_iter() + .chain(signal_evidence( + snapshot, + AutopilotSignal::ProfileAcquisitionStarted, + )) + .collect(), + ), + Vec::new(), + ), + ); +} + +// ── ESP sibling linkage ───────────────────────────────────────────────────── + +fn push_esp_evidence_missing(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.esp_linkage.state != AutopilotEspLinkState::EvidenceMissing { + return; + } + push( + findings, + finding( + "autopilot-esp-evidence-missing", + IntuneFindingSeverity::Warning, + IntuneFindingConfidence::High, + "Autopilot handed off to ESP but no ESP evidence was supplied", + "The local Autopilot phase reached the Enrollment Status Page handoff. What happened \ + after that point is owned by the ESP reducer, and this bundle contains none of its \ + evidence, so the deployment cannot be called complete or failed.", + &[ + "Collect the ESP diagnostics bundle for the same enrollment and analyze it with \ + the ESP workspace." + .to_owned(), + "Carry the enrollment or correlation identifier across so the two analyses bind \ + explicitly." + .to_owned(), + ], + normalized_evidence(snapshot.esp_linkage.evidence.clone()), + Vec::new(), + ), + ); +} + +fn push_esp_time_only_candidate(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.esp_linkage.state != AutopilotEspLinkState::TimeOnlyCandidate { + return; + } + push( + findings, + finding( + "autopilot-esp-link-time-only", + IntuneFindingSeverity::Warning, + // Never above low: this is the rule that stops a time-only join + // from being presented as a correlation. + IntuneFindingConfidence::Low, + "ESP evidence exists but shares no identifier with the Autopilot evidence", + "The supplied ESP session facts carry no enrollment, correlation, activity, or device \ + identifier in common with the Autopilot evidence. They may describe the same \ + provisioning attempt or a different one; proximity in time is not proof.", + &[ + "Re-collect both sides in one pass so they share an enrollment or correlation \ + identifier." + .to_owned(), + "Compare the Entra device id recorded by each side.".to_owned(), + ], + normalized_evidence(snapshot.esp_linkage.evidence.clone()), + Vec::new(), + ), + ); +} + +fn push_esp_linked(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.esp_linkage.state != AutopilotEspLinkState::Linked { + return; + } + push( + findings, + finding( + "autopilot-esp-session-linked", + IntuneFindingSeverity::Info, + IntuneFindingConfidence::High, + "Autopilot evidence is bound to an ESP session by an explicit key", + &format!( + "{} explicit correlation key(s) bind this Autopilot evidence to ESP session(s) {}. \ + ESP remains the owner of everything after the handoff.", + snapshot.esp_linkage.matched_keys.len(), + snapshot.esp_linkage.esp_session_ids.join(", ") + ), + &["Continue the investigation in the ESP workspace for the cited session.".to_owned()], + normalized_evidence(snapshot.esp_linkage.evidence.clone()), + Vec::new(), + ), + ); +} + +// ── Coverage and time ─────────────────────────────────────────────────────── + +fn push_unreliable_timestamps(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.time_basis == AutopilotTimeBasis::Utc { + return; + } + let (title, why) = match snapshot.timezone_state { + AutopilotTimezoneState::Missing => ( + "The capture declared no timezone", + "no timezone was declared for the collection", + ), + AutopilotTimezoneState::Invalid => ( + "The capture declared an unrecognizable timezone", + "the declared timezone is not a recognizable UTC offset or region identifier", + ), + AutopilotTimezoneState::Declared => ( + "Some Autopilot timestamps could not be normalized", + "at least one record carried a wall-clock time with no explicit offset", + ), + }; + push( + findings, + finding( + "autopilot-timestamps-unreliable", + IntuneFindingSeverity::Warning, + IntuneFindingConfidence::High, + title, + &format!( + "Ordering fell back to record identity because {why}. No conclusion in this \ + analysis relies on wall-clock time, and time-proximity correlation with an ESP \ + session is refused outright." + ), + &[ + "Re-collect with the device's timezone recorded alongside the evidence.".to_owned(), + "Prefer sources that emit an explicit UTC offset per record.".to_owned(), + ], + Vec::new(), + // The gap is the capture itself, not any one artifact, so every + // artifact in the bundle is named rather than singling one out. + snapshot + .coverage + .iter() + .map(|entry| entry.artifact_id.clone()) + .collect(), + ), + ); +} + +fn push_coverage_gaps(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + let gaps = snapshot + .coverage + .iter() + .filter(|entry| { + matches!( + entry.status, + IntuneArtifactStatus::Missing + | IntuneArtifactStatus::PermissionDenied + | IntuneArtifactStatus::Capped + | IntuneArtifactStatus::Skipped + ) + }) + .collect::>(); + if gaps.is_empty() { + return; + } + let detail = gaps + .iter() + .filter_map(|entry| entry.detail.clone()) + .collect::>() + .join(" "); + push( + findings, + finding( + "autopilot-evidence-coverage-gap", + IntuneFindingSeverity::Warning, + IntuneFindingConfidence::High, + "Expected Autopilot evidence was incomplete", + &format!( + "{} expected artifact(s) were missing, truncated, skipped, or unreadable. The \ + absence of a signal in them proves nothing. {detail}", + gaps.len() + ), + &[ + "Re-collect the cited artifacts, elevated, before ruling anything out.".to_owned(), + format!( + "microsoft-windows-moderndeployment-diagnostics-provider-autopilot.evtx carries \ + {AUTOPILOT_EVENT_CHANNEL} in full." + ), + ], + gaps.iter() + .flat_map(|entry| entry.evidence.iter().cloned()) + .collect(), + gaps.iter() + .map(|entry| entry.artifact_id.clone()) + .collect(), + ), + ); +} + +fn push_unclassified_records(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.unclassified_observation_ids.is_empty() { + return; + } + let evidence = snapshot + .unclassified_observation_ids + .iter() + .filter_map(|id| snapshot.observation(id)) + .map(AutopilotObservation::evidence_ref) + .collect::>(); + push( + findings, + finding( + "autopilot-undocumented-events-retained", + IntuneFindingSeverity::Info, + IntuneFindingConfidence::High, + "Autopilot records with no documented meaning were retained", + &format!( + "{} record(s) came from the Autopilot channel with an event ID this build has no \ + documented meaning for. They are kept as raw evidence and deliberately given no \ + terminal semantics.", + snapshot.unclassified_observation_ids.len() + ), + &[ + "Read the cited records directly; their message text may still be informative." + .to_owned(), + ], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +fn push_completed(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.outcome != AutopilotOutcome::Completed { + return; + } + let mut evidence = snapshot.profile.evidence.clone(); + evidence.extend(snapshot.handoff.evidence.iter().cloned()); + push( + findings, + finding( + "autopilot-phase-completed", + IntuneFindingSeverity::Info, + IntuneFindingConfidence::High, + "The local Autopilot phase completed and handed off", + "A profile was retrieved and applied, and the handoff into enrollment and the \ + Enrollment Status Page was observed. Everything after this point belongs to the ESP \ + analysis, not to Autopilot.", + &["Review the correlated ESP session for what happened after the handoff.".to_owned()], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +// ── Construction ──────────────────────────────────────────────────────────── + +/// Whether this snapshot may assert a terminal cause at all. +/// +/// When the Windows build, Autopilot schema, or a supplied document version is +/// one this release has no validated rules for, the reducer reports +/// [`AutopilotOutcome::UnknownSchema`]. Rules that key on a *state* rather than +/// on the outcome must consult this, or a blocking finding sails past the very +/// refusal the outcome exists to express. Rules that already key on +/// `snapshot.outcome` are gated by construction. +/// +/// Evidence-level rules -- coverage gaps, malformed documents, unreliable +/// timestamps, ESP linkage -- are deliberately *not* gated: those describe the +/// bundle, not a cause, and stay true whatever the schema turns out to mean. +fn terminal_semantics_withheld(snapshot: &AutopilotSnapshot) -> bool { + snapshot.outcome == AutopilotOutcome::UnknownSchema +} + +fn signal_evidence( + snapshot: &AutopilotSnapshot, + signal: AutopilotSignal, +) -> Vec { + snapshot + .observations + .iter() + .filter(|observation| observation.signal == signal) + .map(AutopilotObservation::evidence_ref) + .collect() +} + +/// Build a finding, or nothing when it would cite neither evidence nor a gap. +/// +/// This is the single enforcement point for the invariant +/// [`IntuneFinding::is_evidence_backed`] describes. Keeping it here means no +/// individual rule can forget it. +#[allow(clippy::too_many_arguments)] +fn finding( + id: &str, + severity: IntuneFindingSeverity, + confidence: IntuneFindingConfidence, + title: &str, + summary: &str, + recommended_checks: &[String], + evidence: Vec, + coverage_gap_ids: Vec, +) -> Option { + let evidence = normalized_evidence(evidence); + let mut coverage_gap_ids = coverage_gap_ids; + coverage_gap_ids.sort(); + coverage_gap_ids.dedup(); + if evidence.is_empty() && coverage_gap_ids.is_empty() { + return None; + } + Some(IntuneFinding { + finding_id: id.to_owned(), + severity, + confidence, + title: title.to_owned(), + summary: summary.to_owned(), + recommended_checks: recommended_checks.to_vec(), + evidence, + coverage_gap_ids, + }) +} + +fn push(findings: &mut Vec, finding: Option) { + if let Some(finding) = finding { + findings.push(finding); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs new file mode 100644 index 000000000..9df1167ef --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs @@ -0,0 +1,528 @@ +//! The input contract: what a native collector hands the pure reducer. +//! +//! EVTX decoding, registry reads, diagnostics-export generation, and live OOBE +//! interaction are all native concerns and stay outside this crate. What +//! crosses the boundary is a set of *documents*: serializable, versioned, and +//! self-identifying. +//! +//! # Explicit schema detection +//! +//! A document is only interpreted when it says what it is. Every supported +//! document declares `autopilotDocument` (its kind) and `documentVersion`. +//! Content that omits the tag is [`IntuneParseState::Malformed`]; content that +//! declares a kind or version this build has no rules for is +//! [`IntuneParseState::Unsupported`] and is retained as raw evidence without +//! terminal semantics. Nothing is sniffed, and identifiers are never scraped +//! out of unrelated MDM report text. + +use std::sync::OnceLock; + +use regex::Regex; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::intune::evidence::{ + intune_raw_preserving_string_enum, IntuneAccessState, IntuneArtifactStatus, IntuneErrorCode, + IntuneNamedValue, IntuneObservationContext, +}; +use crate::intune::normalized::NormalizedWindowsEvent; + +use super::models::{ + AutopilotCaptureMetadata, AutopilotEspSessionFact, AutopilotSectionKind, + AutopilotSectionOutcome, AutopilotTimezoneState, +}; + +/// The only document version this build has validated rules for. +pub const AUTOPILOT_DOCUMENT_VERSION: u32 = 1; + +/// The Autopilot diagnostics channel, per Microsoft's troubleshooting guidance. +pub const AUTOPILOT_EVENT_CHANNEL: &str = + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot"; + +/// The provider that owns [`AUTOPILOT_EVENT_CHANNEL`]. +pub const AUTOPILOT_EVENT_PROVIDER: &str = + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider"; + +intune_raw_preserving_string_enum! { + /// What a supplied document contains. + pub enum AutopilotDocumentKind { + Events => "autopilot.events", + DiagnosticsReport => "autopilot.mdmDiagnosticsReport", + IdentityFacts => "autopilot.identityFacts", + EspSession => "autopilot.espSession", + } +} + +/// What the collector says happened to an artifact. +/// +/// This mirrors [`IntuneAccessState`] because only the collector can know it: +/// the pure crate cannot distinguish "the file was not there" from "we were not +/// allowed to read it" from bytes it never received. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotCaptureState { + Captured, + Capped, + Absent, + AccessDenied, + Skipped, + Unsupported, + ParseFailed, +} + +impl AutopilotCaptureState { + /// The coverage status this capture state implies before the reducer has + /// looked at any bytes. + pub fn declared_status(self) -> IntuneArtifactStatus { + match self { + Self::Captured => IntuneArtifactStatus::Available, + Self::Capped => IntuneArtifactStatus::Capped, + Self::Absent => IntuneArtifactStatus::Missing, + Self::AccessDenied => IntuneArtifactStatus::PermissionDenied, + Self::Skipped => IntuneArtifactStatus::Skipped, + Self::Unsupported => IntuneArtifactStatus::Unsupported, + Self::ParseFailed => IntuneArtifactStatus::ParseFailed, + } + } + + /// The observation-level access state records from this artifact carry. + pub fn access_state(self) -> IntuneAccessState { + match self { + Self::Captured => IntuneAccessState::Available, + Self::Capped => IntuneAccessState::Capped, + Self::Absent => IntuneAccessState::Missing, + Self::AccessDenied => IntuneAccessState::PermissionDenied, + Self::Skipped => IntuneAccessState::Skipped, + Self::Unsupported => IntuneAccessState::Unsupported, + Self::ParseFailed => IntuneAccessState::Failed, + } + } + + /// Whether the absence of a signal in this artifact proves anything. + /// + /// A capped or partially collected channel cannot support a negative + /// conclusion, which is the whole reason `Capped` is not folded into + /// `Available`. + pub fn supports_negative_conclusion(self) -> bool { + self == Self::Captured + } +} + +/// One artifact the collector attempted, and its bytes when it got any. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutopilotSourceInput { + pub artifact_id: String, + /// Coarse grouping used by coverage, e.g. `autopilotEvents` or `mdmReport`. + pub family: String, + pub capture_state: AutopilotCaptureState, + pub original_basename: Option, + /// Already sanitized by the collector; never a live host path. + pub sanitized_source_path: Option, + pub content: Option, + /// Coarse artifact kind tag for rotation/fragment accounting. + pub source_kind_tag: Option, + /// Rotation index when a channel was split across multiple files. + pub rotation_index: Option, + /// Fragment index within a rotation, for further sub-splits. + pub fragment_index: Option, +} + +impl Default for AutopilotSourceInput { + fn default() -> Self { + Self { + artifact_id: String::new(), + family: String::new(), + capture_state: AutopilotCaptureState::Absent, + original_basename: None, + sanitized_source_path: None, + content: None, + source_kind_tag: None, + rotation_index: None, + fragment_index: None, + } + } +} + +/// Everything the reducer is given for one device. +#[derive(Debug, Clone, Default)] +pub struct AutopilotBundleInput { + /// The pure crate has no clock; the caller states when this ran. + pub generated_at_utc: String, + pub capture: AutopilotCaptureMetadata, + pub sources: Vec, + /// Events a native adapter already normalized, in addition to any carried + /// by an `autopilot.events` document in `sources`. + pub events: Vec, +} + +// ── Document envelope ─────────────────────────────────────────────────────── + +/// What detection concluded about one document's bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AutopilotDocumentDetection { + /// A kind and version this build has rules for. + Supported { + kind: AutopilotDocumentKind, + version: u32, + }, + /// Well-formed JSON declaring a kind or version this build cannot + /// interpret. The bytes remain valid evidence; their meaning does not. + Unsupported { + declared_kind: Option, + declared_version: Option, + detail: String, + }, + /// Not parseable as a tagged Autopilot document at all. + Malformed { detail: String }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentEnvelope { + autopilot_document: Option, + document_version: Option, +} + +/// Classify a document's bytes without interpreting its payload. +/// +/// Detection is deliberately separate from parsing so that an unknown-version +/// document produces a precise coverage fact rather than a parse error that +/// looks like corruption. +pub fn detect_document(content: &str) -> AutopilotDocumentDetection { + let envelope: DocumentEnvelope = match serde_json::from_str(content) { + Ok(envelope) => envelope, + Err(error) => { + return AutopilotDocumentDetection::Malformed { + detail: format!("content is not a JSON object: {error}"), + } + } + }; + + let Some(declared_kind) = envelope.autopilot_document else { + return AutopilotDocumentDetection::Malformed { + detail: "document does not declare autopilotDocument".to_owned(), + }; + }; + let Some(declared_version) = envelope.document_version else { + return AutopilotDocumentDetection::Unsupported { + declared_kind: Some(declared_kind), + declared_version: None, + detail: "document does not declare documentVersion".to_owned(), + }; + }; + + let kind: AutopilotDocumentKind = + match serde_json::from_value(serde_json::Value::String(declared_kind.clone())) { + Ok(kind) => kind, + // The raw-preserving enum never fails to decode a string, so this arm + // is unreachable in practice; treating it as unsupported rather than + // panicking keeps the reducer total. + Err(error) => { + return AutopilotDocumentDetection::Unsupported { + declared_kind: Some(declared_kind), + declared_version: Some(declared_version), + detail: format!("document kind could not be decoded: {error}"), + } + } + }; + + if let AutopilotDocumentKind::Unknown(raw) = &kind { + return AutopilotDocumentDetection::Unsupported { + declared_kind: Some(raw.clone()), + declared_version: Some(declared_version), + detail: format!("no validated rules for document kind {raw}"), + }; + } + if declared_version != AUTOPILOT_DOCUMENT_VERSION { + return AutopilotDocumentDetection::Unsupported { + detail: format!( + "no validated rules for {declared_kind} version {declared_version}; \ + this build understands version {AUTOPILOT_DOCUMENT_VERSION}" + ), + declared_kind: Some(declared_kind), + declared_version: Some(declared_version), + }; + } + + AutopilotDocumentDetection::Supported { + kind, + version: declared_version, + } +} + +// ── Document payloads ─────────────────────────────────────────────────────── + +/// An `autopilot.events` payload: normalized Windows event records. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotEventsDocument { + #[serde(default)] + pub events: Vec, + /// True when the collector knows the channel was truncated at either end. + /// A truncated channel cannot support "this never happened". + #[serde(default)] + pub channel_complete: Option, +} + +/// One section of an MDM diagnostics report or diagnostics-page export. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotReportSection { + pub context: IntuneObservationContext, + pub section_id: String, + pub kind: AutopilotSectionKind, + pub outcome: AutopilotSectionOutcome, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub values: Vec, + #[serde(default)] + pub message: Option, +} + +/// An `autopilot.mdmDiagnosticsReport` or `autopilot.identityFacts` payload. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotReportDocument { + #[serde(default)] + pub sections: Vec, +} + +/// An `autopilot.espSession` payload: facts from the sibling ESP reducer. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotEspSessionDocument { + #[serde(default)] + pub sessions: Vec, +} + +// ── Capture metadata validation ───────────────────────────────────────────── + +/// Windows builds this build has validated Autopilot rules for. +/// +/// The documented event vocabulary is stable across supported Windows 10 and +/// 11 servicing, so the gate is the *major* build line rather than an exact +/// build. A build outside this set is not an error; it means terminal +/// semantics are refused and the evidence is kept raw. +const VALIDATED_BUILD_PREFIXES: [&str; 6] = + ["10.0.19", "10.0.22", "10.0.25", "10.0.26", "19", "22"]; + +/// Whether the declared Windows build is one with validated rules. +/// +/// An undeclared build is *not* treated as unvalidated: many collectors cannot +/// read it, and refusing every conclusion on that basis would make the reducer +/// useless. An explicitly declared but unrecognized build is what triggers +/// refusal, because that is a positive statement the reducer disagrees with. +pub fn is_validated_windows_build(build: Option<&str>) -> bool { + let Some(build) = build.map(str::trim).filter(|build| !build.is_empty()) else { + return true; + }; + VALIDATED_BUILD_PREFIXES + .iter() + .any(|prefix| build.starts_with(prefix)) +} + +/// Whether the declared Autopilot schema version is one with validated rules. +pub fn is_validated_schema_version(version: Option<&str>) -> bool { + let Some(version) = version.map(str::trim).filter(|value| !value.is_empty()) else { + return true; + }; + version == "1" || version == "1.0" +} + +fn utc_offset_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"^(?i)(?:UTC|Z|GMT)?(?:[+-](?:[01]\d|2[0-3]):?[0-5]\d)?$") + .expect("utc offset regex must compile") + }) +} + +fn iana_zone_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"^[A-Za-z][A-Za-z_+-]*(?:/[A-Za-z0-9][A-Za-z0-9_+-]*){1,2}$") + .expect("iana zone regex must compile") + }) +} + +/// A Windows time zone identifier such as `Pacific Standard Time`. +/// +/// Windows collectors report this form, not an IANA name, so omitting it made +/// the single most common real input classify as invalid and needlessly +/// downgraded the time basis. Punctuation beyond spaces, hyphens, and periods +/// is excluded, which is what still rejects an annotated value like +/// `Pacific Standard Time (device local)`. +fn windows_zone_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"^[A-Za-z][A-Za-z .\-]{2,}$").expect("windows zone regex must compile") + }) +} + +/// Classify the declared collection timezone. +/// +/// The pure crate carries no timezone database, so this checks *shape* only: a +/// fixed UTC offset, a bare `UTC`/`Z`/`GMT`, an `Area/City` identifier, or a +/// Windows time zone name. Claiming more than that would be inventing a +/// validation the crate cannot perform, so `Declared` means "this looks like a +/// timezone", never "this timezone exists". +pub fn classify_timezone(timezone: Option<&str>) -> AutopilotTimezoneState { + let Some(timezone) = timezone.map(str::trim) else { + return AutopilotTimezoneState::Missing; + }; + if timezone.is_empty() { + return AutopilotTimezoneState::Missing; + } + let looks_like_offset = utc_offset_re().is_match(timezone) + && timezone + .chars() + .any(|character| character.is_ascii_alphanumeric()); + if looks_like_offset + || iana_zone_re().is_match(timezone) + || windows_zone_re().is_match(timezone) + { + AutopilotTimezoneState::Declared + } else { + AutopilotTimezoneState::Invalid + } +} + +/// Whether the whole capture declares a context this build can reason about. +pub fn capture_is_validated(capture: &AutopilotCaptureMetadata) -> bool { + is_validated_windows_build(capture.windows_build.as_deref()) + && is_validated_schema_version(capture.autopilot_schema_version.as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_tagged_current_version_document_is_supported() { + let detection = detect_document( + r#"{"autopilotDocument":"autopilot.events","documentVersion":1,"events":[]}"#, + ); + assert_eq!( + detection, + AutopilotDocumentDetection::Supported { + kind: AutopilotDocumentKind::Events, + version: 1, + } + ); + } + + #[test] + fn an_untagged_document_is_malformed_not_unsupported() { + // The distinction matters: unsupported means "we know what this is and + // refuse to interpret it", malformed means "this is not our document". + let detection = detect_document(r#"{"events":[]}"#); + assert!(matches!( + detection, + AutopilotDocumentDetection::Malformed { .. } + )); + } + + #[test] + fn a_future_version_is_unsupported_and_keeps_the_declared_values() { + let detection = detect_document( + r#"{"autopilotDocument":"autopilot.events","documentVersion":9,"events":[]}"#, + ); + match detection { + AutopilotDocumentDetection::Unsupported { + declared_kind, + declared_version, + .. + } => { + assert_eq!(declared_kind.as_deref(), Some("autopilot.events")); + assert_eq!(declared_version, Some(9)); + } + other => panic!("expected unsupported, got {other:?}"), + } + } + + #[test] + fn an_unknown_kind_is_unsupported_and_preserves_the_raw_token() { + let detection = detect_document( + r#"{"autopilotDocument":"autopilot.somethingNew","documentVersion":1}"#, + ); + match detection { + AutopilotDocumentDetection::Unsupported { declared_kind, .. } => { + assert_eq!(declared_kind.as_deref(), Some("autopilot.somethingNew")); + } + other => panic!("expected unsupported, got {other:?}"), + } + } + + #[test] + fn non_json_content_is_malformed() { + assert!(matches!( + detect_document("not a document"), + AutopilotDocumentDetection::Malformed { .. } + )); + } + + #[test] + fn timezone_shapes_are_classified_without_a_timezone_database() { + assert_eq!( + classify_timezone(Some("UTC")), + AutopilotTimezoneState::Declared + ); + assert_eq!( + classify_timezone(Some("UTC-05:00")), + AutopilotTimezoneState::Declared + ); + assert_eq!( + classify_timezone(Some("America/New_York")), + AutopilotTimezoneState::Declared + ); + assert_eq!(classify_timezone(None), AutopilotTimezoneState::Missing); + assert_eq!( + classify_timezone(Some(" ")), + AutopilotTimezoneState::Missing + ); + } + + #[test] + fn a_windows_time_zone_name_is_accepted_but_an_annotated_one_is_not() { + // Windows collectors report this form, so rejecting it downgraded the + // time basis on the most common real input there is. + assert_eq!( + classify_timezone(Some("Pacific Standard Time")), + AutopilotTimezoneState::Declared + ); + for junk in [ + "Pacific Standard Time (device local)", + "Pacific Standard Time?", + "??", + ] { + assert_eq!( + classify_timezone(Some(junk)), + AutopilotTimezoneState::Invalid, + "{junk:?} must not pass as a timezone" + ); + } + } + + #[test] + fn an_undeclared_build_is_not_treated_as_unvalidated() { + // Refusing every conclusion when the collector simply could not read + // the build would make the reducer useless on the majority of bundles. + assert!(is_validated_windows_build(None)); + assert!(is_validated_windows_build(Some("10.0.22631.4317"))); + assert!(!is_validated_windows_build(Some("10.0.99999.1"))); + } + + #[test] + fn capture_states_bind_to_coverage_and_access_states() { + assert_eq!( + AutopilotCaptureState::Capped.declared_status(), + IntuneArtifactStatus::Capped + ); + assert_eq!( + AutopilotCaptureState::Capped.access_state(), + IntuneAccessState::Capped + ); + assert!(!AutopilotCaptureState::Capped.supports_negative_conclusion()); + assert!(AutopilotCaptureState::Captured.supports_negative_conclusion()); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/normalized.rs b/crates/cmtraceopen-parser/src/intune/normalized.rs index 18f6c4098..c8b951474 100644 --- a/crates/cmtraceopen-parser/src/intune/normalized.rs +++ b/crates/cmtraceopen-parser/src/intune/normalized.rs @@ -57,6 +57,7 @@ pub struct NormalizedWindowsEvent { pub keywords: Option, pub record_id: Option, pub activity_id: Option, + pub event_version: Option, pub named_data: Vec, pub message: Option, } @@ -153,6 +154,7 @@ mod tests { keywords: None, record_id: Some(7), activity_id: None, + event_version: None, named_data: vec![IntuneNamedValue { name: "NodeUri".to_owned(), value: "./Device/Vendor/MSFT/Policy".to_owned(), diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/evidence/autopilot-channel/current/autopilot-events.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/evidence/autopilot-channel/current/autopilot-events.json new file mode 100644 index 000000000..5a01c4b7d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/evidence/autopilot-channel/current/autopilot-events.json @@ -0,0 +1,313 @@ +{"_comment": "SYNTHETIC FIXTURE: user-driven Autopilot channel, happy path", + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 160, + "recordId": 1 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:01Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:01Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 160, + "level": "information", + "task": null, + "keywords": null, + "recordId": 1, + "activityId": null, + "namedData": [], + "message": "AutopilotRetrieveSettings beginning acquisition." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 2, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 164, + "recordId": 2 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:02Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:02Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 164, + "level": "information", + "task": null, + "keywords": null, + "recordId": 2, + "activityId": null, + "namedData": [], + "message": "AutopilotManager determined Internet is available to attempt policy download." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e3", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 3, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 161, + "recordId": 3 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:09Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:09Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 161, + "level": "information", + "task": null, + "keywords": null, + "recordId": 3, + "activityId": null, + "namedData": [ + { + "name": "profileId", + "value": "11111111-2222-3333-4444-555555555555" + }, + { + "name": "profileName", + "value": "Synthetic User-Driven" + }, + { + "name": "deploymentProfileType", + "value": "userDriven" + } + ], + "message": "AutopilotManager retrieve settings succeeded." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e4", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 4, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 111, + "recordId": 4 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:10Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:10Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 111, + "level": "information", + "task": null, + "keywords": null, + "recordId": 4, + "activityId": null, + "namedData": [], + "message": "AutopilotRetrieveSettings succeeded." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e5", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 5, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 153, + "recordId": 5 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:11Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:11Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 153, + "level": "information", + "task": null, + "keywords": null, + "recordId": 5, + "activityId": null, + "namedData": [], + "message": "AutopilotManager reported the state changed from ProfileState_Unknown to ProfileState_Available." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e6", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 6, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 109, + "recordId": 6 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:12Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:12Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 109, + "level": "information", + "task": null, + "keywords": null, + "recordId": 6, + "activityId": null, + "namedData": [], + "message": "AutopilotGetOobeSettingsOverride succeeded: OOBE setting = AUTOPILOT_OOBE_SETTINGS_AAD_JOIN_ONLY; state = enabled." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e7", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 7, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 103, + "recordId": 7 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:13Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:13Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 103, + "level": "information", + "task": null, + "keywords": null, + "recordId": 7, + "activityId": null, + "namedData": [], + "message": "AutopilotGetPolicyStringByName succeeded: policy name = CloudAssignedTenantDomain; policy value = contoso.example." + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/evidence/mdm-diagnostics-report/current/autopilot-report.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/evidence/mdm-diagnostics-report/current/autopilot-report.json new file mode 100644 index 000000000..ae0fcc77e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/evidence/mdm-diagnostics-report/current/autopilot-report.json @@ -0,0 +1,129 @@ +{"_comment": "SYNTHETIC FIXTURE: MDM diagnostics report with identity and both handoffs", + "autopilotDocument": "autopilot.mdmDiagnosticsReport", + "documentVersion": 1, + "sections": [ + { + "context": { + "evidenceRef": { + "evidenceId": "rp-s1", + "sourceArtifactId": "mdm-diagnostics-report" + }, + "provenance": { + "sourceKind": "diagnosticReport", + "sourceArtifactId": "mdm-diagnostics-report", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": "parsed", + "accessState": "available" + }, + "sectionId": "deviceIdentity", + "kind": "deviceIdentity", + "outcome": "observed", + "error": null, + "values": [ + { + "name": "serialNumber", + "value": "SYNTH-SERIAL-0001" + }, + { + "name": "entraDeviceId", + "value": "abcdabcd-1234-5678-9012-abcdabcdabcd" + }, + { + "name": "managedDeviceId", + "value": "fedcfedc-4321-8765-2109-fedcfedcfedc" + }, + { + "name": "tenantId", + "value": "00000000-1111-2222-3333-444444444444" + }, + { + "name": "tenantDomain", + "value": "contoso.example" + }, + { + "name": "deviceName", + "value": "SYNTH-DEV-01" + } + ], + "message": "Autopilot device registration record read from the diagnostics export." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "rp-s2", + "sourceArtifactId": "mdm-diagnostics-report" + }, + "provenance": { + "sourceKind": "diagnosticReport", + "sourceArtifactId": "mdm-diagnostics-report", + "filePath": null, + "lineNumber": null, + "recordNumber": 2, + "registry": null, + "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": "parsed", + "accessState": "available" + }, + "sectionId": "enrollmentHandoff", + "kind": "enrollmentHandoff", + "outcome": "observed", + "error": null, + "values": [ + { + "name": "enrollmentId", + "value": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + } + ], + "message": "MDM enrollment began for the Autopilot-assigned profile." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "rp-s3", + "sourceArtifactId": "mdm-diagnostics-report" + }, + "provenance": { + "sourceKind": "diagnosticReport", + "sourceArtifactId": "mdm-diagnostics-report", + "filePath": null, + "lineNumber": null, + "recordNumber": 3, + "registry": null, + "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": "parsed", + "accessState": "available" + }, + "sectionId": "espHandoff", + "kind": "espHandoff", + "outcome": "observed", + "error": null, + "values": [ + { + "name": "correlationId", + "value": "12121212-3434-5656-7878-909090909090" + }, + { + "name": "entraDeviceId", + "value": "abcdabcd-1234-5678-9012-abcdabcdabcd" + } + ], + "message": "Control passed to the Enrollment Status Page." + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/expected.json new file mode 100644 index 000000000..a8927507f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/expected.json @@ -0,0 +1,84 @@ +{ + "assertions": [ + "reaching the handoff is not the same as the deployment succeeding", + "the missing ESP bundle stays an explicit coverage gap rather than an assumption" + ], + "confidence": "medium", + "conflictIds": [], + "coverage": [ + { + "artifactId": "autopilot-channel", + "status": "available" + }, + { + "artifactId": "mdm-diagnostics-report", + "status": "available" + }, + { + "artifactId": "esp-session-facts", + "status": "missing" + } + ], + "documentParseStates": { + "autopilot-channel": "parsed", + "mdm-diagnostics-report": "parsed" + }, + "espLinkConfidence": "high", + "espLinkState": "evidenceMissing", + "espSessionIds": [], + "findingIds": [ + "autopilot-esp-evidence-missing", + "autopilot-evidence-coverage-gap" + ], + "findings": [ + { + "confidence": "high", + "coverageGapIds": [], + "evidence": [ + { + "evidenceId": "rp-s2", + "sourceArtifactId": "mdm-diagnostics-report" + }, + { + "evidenceId": "rp-s3", + "sourceArtifactId": "mdm-diagnostics-report" + } + ], + "findingId": "autopilot-esp-evidence-missing", + "recommendedChecks": [ + "Collect the ESP diagnostics bundle for the same enrollment and analyze it with the ESP workspace.", + "Carry the enrollment or correlation identifier across so the two analyses bind explicitly." + ], + "severity": "warning", + "summary": "The local Autopilot phase reached the Enrollment Status Page handoff. What happened after that point is owned by the ESP reducer, and this bundle contains none of its evidence, so the deployment cannot be called complete or failed.", + "title": "Autopilot handed off to ESP but no ESP evidence was supplied" + }, + { + "confidence": "high", + "coverageGapIds": [ + "esp-session-facts" + ], + "evidence": [], + "findingId": "autopilot-evidence-coverage-gap", + "recommendedChecks": [ + "Re-collect the cited artifacts, elevated, before ruling anything out.", + "microsoft-windows-moderndeployment-diagnostics-provider-autopilot.evtx carries Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot in full." + ], + "severity": "warning", + "summary": "1 expected artifact(s) were missing, truncated, skipped, or unreadable. The absence of a signal in them proves nothing. esp-sessions.json was not present on the device.", + "title": "Expected Autopilot evidence was incomplete" + } + ], + "matchedKeyKinds": [], + "outcome": "handoffReachedEspEvidenceMissing", + "phase": "espHandoff", + "profileApplied": true, + "profileCandidateState": "available", + "profileRetrieved": true, + "registrationState": "registered", + "scenario": "completed-without-esp-bundle", + "timeBasis": "utc", + "timezoneState": "declared", + "unclassifiedObservationCount": 0, + "workload": "enrollment/windows/autopilot" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/manifest.json new file mode 100644 index 000000000..0f27bbfdb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/manifest.json @@ -0,0 +1,64 @@ +{ + "intuneManifestVersion": 1, + "syntheticFixture": true, + "scenario": "completed-without-esp-bundle", + "workload": "enrollment/windows/autopilot", + "description": "The local Autopilot phase completed and handed off, but the ESP evidence that owns everything after the handoff was never collected.", + "generatedAtUtc": "2026-07-31T09:30:00Z", + "capture": { + "collectedAtUtc": "2026-07-31T09:30:00Z", + "windowsBuild": "10.0.26100.2314", + "autopilotSchemaVersion": "1", + "timezone": "UTC" + }, + "artifacts": [ + { + "artifactId": "autopilot-channel", + "family": "autopilotEvents", + "sourceKind": "eventLog", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "autopilot-events.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-events.json", + "relativePath": "evidence/autopilot-channel/current/autopilot-events.json", + "bytesCopied": 10487, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + }, + { + "artifactId": "mdm-diagnostics-report", + "family": "mdmReport", + "sourceKind": "diagnosticReport", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "autopilot-report.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-report.json", + "relativePath": "evidence/mdm-diagnostics-report/current/autopilot-report.json", + "bytesCopied": 3681, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + }, + { + "artifactId": "esp-session-facts", + "family": "espSession", + "sourceKind": "suppliedFact", + "captureState": "absent", + "encoding": null, + "originalBasename": "esp-sessions.json", + "sanitizedSourcePath": null, + "relativePath": null, + "bytesCopied": 0, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/autopilot-channel/current/autopilot-events.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/autopilot-channel/current/autopilot-events.json new file mode 100644 index 000000000..17a63bd62 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/autopilot-channel/current/autopilot-events.json @@ -0,0 +1,57 @@ +{"_comment": "SYNTHETIC FIXTURE: channel naming one profile", + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 161, + "recordId": 1 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:08Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:08Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 161, + "level": "information", + "task": null, + "keywords": null, + "recordId": 1, + "activityId": null, + "namedData": [ + { + "name": "profileId", + "value": "11111111-2222-3333-4444-555555555555" + }, + { + "name": "enrollmentId", + "value": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + } + ], + "message": "AutopilotManager retrieve settings succeeded." + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/esp-session-facts/current/esp-sessions.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/esp-session-facts/current/esp-sessions.json new file mode 100644 index 000000000..323bc55ca --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/esp-session-facts/current/esp-sessions.json @@ -0,0 +1,34 @@ +{"_comment": "SYNTHETIC FIXTURE: two ESP sessions sharing one enrollment identifier", + "autopilotDocument": "autopilot.espSession", + "documentVersion": 1, + "sessions": [ + { + "sessionId": "esp-session-0003", + "enrollmentId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "correlationId": null, + "activityId": null, + "entraDeviceId": null, + "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:20Z", + "phase": "deviceSetup", + "evidence": { + "evidenceId": "esp-s1", + "sourceArtifactId": "esp-session-facts" + } + }, + { + "sessionId": "esp-session-0004", + "enrollmentId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "correlationId": null, + "activityId": null, + "entraDeviceId": null, + "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:10:20Z", + "phase": "accountSetup", + "evidence": { + "evidenceId": "esp-s2", + "sourceArtifactId": "esp-session-facts" + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/mdm-diagnostics-report/current/autopilot-report.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/mdm-diagnostics-report/current/autopilot-report.json new file mode 100644 index 000000000..1fcd17193 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/mdm-diagnostics-report/current/autopilot-report.json @@ -0,0 +1,39 @@ +{"_comment": "SYNTHETIC FIXTURE: report naming a different profile", + "autopilotDocument": "autopilot.mdmDiagnosticsReport", + "documentVersion": 1, + "sections": [ + { + "context": { + "evidenceRef": { + "evidenceId": "rp-s1", + "sourceArtifactId": "mdm-diagnostics-report" + }, + "provenance": { + "sourceKind": "diagnosticReport", + "sourceArtifactId": "mdm-diagnostics-report", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": "parsed", + "accessState": "available" + }, + "sectionId": "autopilotProfileRetrieval", + "kind": "profileRetrieval", + "outcome": "succeeded", + "error": null, + "values": [ + { + "name": "profileId", + "value": "99999999-8888-7777-6666-555555555555" + } + ], + "message": "A deployment profile was retrieved." + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/expected.json new file mode 100644 index 000000000..2c978502e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/expected.json @@ -0,0 +1,83 @@ +{ + "assertions": [ + "an ambiguous correlation key produces no linkage at all rather than a guess", + "contradictory identifiers suppress every downstream terminal claim" + ], + "confidence": "high", + "conflictIds": [ + "conflicting-esp-session-id", + "conflicting-profile-id" + ], + "coverage": [ + { + "artifactId": "autopilot-channel", + "status": "available" + }, + { + "artifactId": "mdm-diagnostics-report", + "status": "available" + }, + { + "artifactId": "esp-session-facts", + "status": "available" + } + ], + "documentParseStates": { + "autopilot-channel": "parsed", + "esp-session-facts": "parsed", + "mdm-diagnostics-report": "parsed" + }, + "espLinkConfidence": "high", + "espLinkState": "conflicting", + "espSessionIds": [ + "esp-session-0003", + "esp-session-0004" + ], + "findingIds": [ + "autopilot-contradictory-sources" + ], + "findings": [ + { + "confidence": "high", + "coverageGapIds": [], + "evidence": [ + { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "esp-s1", + "sourceArtifactId": "esp-session-facts" + }, + { + "evidenceId": "esp-s2", + "sourceArtifactId": "esp-session-facts" + }, + { + "evidenceId": "rp-s1", + "sourceArtifactId": "mdm-diagnostics-report" + } + ], + "findingId": "autopilot-contradictory-sources", + "recommendedChecks": [ + "Re-collect the Autopilot evidence in one pass so every source describes the same point in time.", + "Confirm the bundle was not assembled from two devices or two provisioning attempts." + ], + "severity": "error", + "summary": "A single correlation key resolves to more than one ESP session. 2 distinct profileId values were observed across the supplied sources. No single Autopilot outcome can be asserted while the sources disagree.", + "title": "Supplied Autopilot sources contradict each other" + } + ], + "matchedKeyKinds": [], + "outcome": "contradictoryEvidence", + "phase": "profileRetrieved", + "profileApplied": false, + "profileCandidateState": "available", + "profileRetrieved": true, + "registrationState": "unknown", + "scenario": "conflicting-profile-session-identifiers", + "timeBasis": "utc", + "timezoneState": "declared", + "unclassifiedObservationCount": 0, + "workload": "enrollment/windows/autopilot" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/manifest.json new file mode 100644 index 000000000..6ef9a3aca --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/manifest.json @@ -0,0 +1,64 @@ +{ + "intuneManifestVersion": 1, + "syntheticFixture": true, + "scenario": "conflicting-profile-session-identifiers", + "workload": "enrollment/windows/autopilot", + "description": "Two sources name different profiles and one correlation key resolves to two ESP sessions, so no single Autopilot outcome can be asserted.", + "generatedAtUtc": "2026-07-31T09:30:00Z", + "capture": { + "collectedAtUtc": "2026-07-31T09:30:00Z", + "windowsBuild": "10.0.26100.2314", + "autopilotSchemaVersion": "1", + "timezone": "UTC" + }, + "artifacts": [ + { + "artifactId": "autopilot-channel", + "family": "autopilotEvents", + "sourceKind": "eventLog", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "autopilot-events.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-events.json", + "relativePath": "evidence/autopilot-channel/current/autopilot-events.json", + "bytesCopied": 1777, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + }, + { + "artifactId": "mdm-diagnostics-report", + "family": "mdmReport", + "sourceKind": "diagnosticReport", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "autopilot-report.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-report.json", + "relativePath": "evidence/mdm-diagnostics-report/current/autopilot-report.json", + "bytesCopied": 1119, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + }, + { + "artifactId": "esp-session-facts", + "family": "espSession", + "sourceKind": "suppliedFact", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "esp-sessions.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/esp-sessions.json", + "relativePath": "evidence/esp-session-facts/current/esp-sessions.json", + "bytesCopied": 1005, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/autopilot-channel/current/autopilot-events.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/autopilot-channel/current/autopilot-events.json new file mode 100644 index 000000000..6550ad828 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/autopilot-channel/current/autopilot-events.json @@ -0,0 +1,149 @@ +{"_comment": "SYNTHETIC FIXTURE: channel quoting a user principal name and a device path", + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 161, + "recordId": 1 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:08Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:08Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 161, + "level": "information", + "task": null, + "keywords": null, + "recordId": 1, + "activityId": null, + "namedData": [ + { + "name": "profileId", + "value": "11111111-2222-3333-4444-555555555555" + }, + { + "name": "profileName", + "value": "Synthetic User-Driven" + }, + { + "name": "entraDeviceId", + "value": "abcdabcd-1234-5678-9012-abcdabcdabcd" + }, + { + "name": "serialNumber", + "value": "SYNTH-SERIAL-0001" + } + ], + "message": "AutopilotManager retrieve settings succeeded." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 2, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 153, + "recordId": 2 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:09Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:09Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 153, + "level": "information", + "task": null, + "keywords": null, + "recordId": 2, + "activityId": null, + "namedData": [], + "message": "AutopilotManager reported the state changed from ProfileState_Unknown to ProfileState_Available." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e3", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 3, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 103, + "recordId": 3 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:10Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:10Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 103, + "level": "information", + "task": null, + "keywords": null, + "recordId": 3, + "activityId": null, + "namedData": [], + "message": "AutopilotGetPolicyStringByName succeeded: policy name = CloudAssignedTenantUpn; policy value = synthetic.user@example.invalid." + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/esp-session-facts/current/esp-sessions.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/esp-session-facts/current/esp-sessions.json new file mode 100644 index 000000000..8b9bf5b69 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/esp-session-facts/current/esp-sessions.json @@ -0,0 +1,20 @@ +{"_comment": "SYNTHETIC FIXTURE: ESP session bound by the Entra device id", + "autopilotDocument": "autopilot.espSession", + "documentVersion": 1, + "sessions": [ + { + "sessionId": "esp-session-0006", + "enrollmentId": null, + "correlationId": null, + "activityId": null, + "entraDeviceId": "abcdabcd-1234-5678-9012-abcdabcdabcd", + "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:20Z", + "phase": "deviceSetup", + "evidence": { + "evidenceId": "esp-s1", + "sourceArtifactId": "esp-session-facts" + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/mdm-diagnostics-report/current/autopilot-report.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/mdm-diagnostics-report/current/autopilot-report.json new file mode 100644 index 000000000..339326f2b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/mdm-diagnostics-report/current/autopilot-report.json @@ -0,0 +1,125 @@ +{"_comment": "SYNTHETIC FIXTURE: identity facts with a hardware hash and a profile path", + "autopilotDocument": "autopilot.identityFacts", + "documentVersion": 1, + "sections": [ + { + "context": { + "evidenceRef": { + "evidenceId": "rp-s1", + "sourceArtifactId": "mdm-diagnostics-report" + }, + "provenance": { + "sourceKind": "diagnosticReport", + "sourceArtifactId": "mdm-diagnostics-report", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": "parsed", + "accessState": "available" + }, + "sectionId": "deviceIdentity", + "kind": "deviceIdentity", + "outcome": "observed", + "error": null, + "values": [ + { + "name": "serialNumber", + "value": "SYNTH-SERIAL-0001" + }, + { + "name": "hardwareHash", + "value": "U1lOVEhFVElDSEFSRFdBUkVIQVNIRk9SRklYVFVSRVVTRU9OTFlaWloK" + }, + { + "name": "entraDeviceId", + "value": "abcdabcd-1234-5678-9012-abcdabcdabcd" + }, + { + "name": "tenantDomain", + "value": "contoso.example" + }, + { + "name": "deviceName", + "value": "SYNTH-DEV-01" + }, + { + "name": "userPrincipalName", + "value": "synthetic.user@example.invalid" + } + ], + "message": "Diagnostics were exported to D:\\Users\\Synthetic Person\\autopilot-diagnostics.log by synthetic.user@example.invalid." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "rp-s2", + "sourceArtifactId": "mdm-diagnostics-report" + }, + "provenance": { + "sourceKind": "diagnosticReport", + "sourceArtifactId": "mdm-diagnostics-report", + "filePath": null, + "lineNumber": null, + "recordNumber": 2, + "registry": null, + "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": "parsed", + "accessState": "available" + }, + "sectionId": "enrollmentHandoff", + "kind": "enrollmentHandoff", + "outcome": "observed", + "error": null, + "values": [ + { + "name": "enrollmentId", + "value": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + } + ], + "message": "MDM enrollment began for the Autopilot-assigned profile." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "rp-s3", + "sourceArtifactId": "mdm-diagnostics-report" + }, + "provenance": { + "sourceKind": "diagnosticReport", + "sourceArtifactId": "mdm-diagnostics-report", + "filePath": null, + "lineNumber": null, + "recordNumber": 3, + "registry": null, + "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": "parsed", + "accessState": "available" + }, + "sectionId": "espHandoff", + "kind": "espHandoff", + "outcome": "observed", + "error": null, + "values": [ + { + "name": "entraDeviceId", + "value": "abcdabcd-1234-5678-9012-abcdabcdabcd" + } + ], + "message": "Control passed to the Enrollment Status Page." + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/expected.json new file mode 100644 index 000000000..a24c655ae --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/expected.json @@ -0,0 +1,122 @@ +{ + "assertions": [ + "masking is a pure function of the value, so a correlation key still matches", + "the profile object id survives because it is the only handle back to Intune" + ], + "confidence": "high", + "conflictIds": [], + "coverage": [ + { + "artifactId": "autopilot-channel", + "status": "available" + }, + { + "artifactId": "mdm-diagnostics-report", + "status": "available" + }, + { + "artifactId": "esp-session-facts", + "status": "available" + } + ], + "documentParseStates": { + "autopilot-channel": "parsed", + "esp-session-facts": "parsed", + "mdm-diagnostics-report": "parsed" + }, + "espLinkConfidence": "high", + "espLinkState": "linked", + "espSessionIds": [ + "esp-session-0006" + ], + "findingIds": [ + "autopilot-esp-session-linked", + "autopilot-phase-completed" + ], + "findings": [ + { + "confidence": "high", + "coverageGapIds": [], + "evidence": [ + { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "esp-s1", + "sourceArtifactId": "esp-session-facts" + }, + { + "evidenceId": "rp-s1", + "sourceArtifactId": "mdm-diagnostics-report" + }, + { + "evidenceId": "rp-s3", + "sourceArtifactId": "mdm-diagnostics-report" + } + ], + "findingId": "autopilot-esp-session-linked", + "recommendedChecks": [ + "Continue the investigation in the ESP workspace for the cited session." + ], + "severity": "info", + "summary": "1 explicit correlation key(s) bind this Autopilot evidence to ESP session(s) esp-session-0006. ESP remains the owner of everything after the handoff.", + "title": "Autopilot evidence is bound to an ESP session by an explicit key" + }, + { + "confidence": "high", + "coverageGapIds": [], + "evidence": [ + { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "rp-s2", + "sourceArtifactId": "mdm-diagnostics-report" + }, + { + "evidenceId": "rp-s3", + "sourceArtifactId": "mdm-diagnostics-report" + } + ], + "findingId": "autopilot-phase-completed", + "recommendedChecks": [ + "Review the correlated ESP session for what happened after the handoff." + ], + "severity": "info", + "summary": "A profile was retrieved and applied, and the handoff into enrollment and the Enrollment Status Page was observed. Everything after this point belongs to the ESP analysis, not to Autopilot.", + "title": "The local Autopilot phase completed and handed off" + } + ], + "matchedKeyKinds": [ + "entraDeviceId" + ], + "outcome": "completed", + "phase": "espHandoff", + "profileApplied": true, + "profileCandidateState": "available", + "profileRetrieved": true, + "redactionMustContain": [ + "11111111-2222-3333-4444-555555555555" + ], + "redactionMustNotContain": [ + "SYNTH-SERIAL-0001", + "U1lOVEhFVElDSEFSRFdBUkVIQVNIRk9SRklYVFVSRVVTRU9OTFlaWloK", + "SYNTH-DEV-01", + "contoso.example", + "synthetic.user@example.invalid", + "Synthetic Person", + "Synthetic User-Driven" + ], + "registrationState": "registered", + "scenario": "deterministic-identity-redaction", + "timeBasis": "utc", + "timezoneState": "declared", + "unclassifiedObservationCount": 0, + "workload": "enrollment/windows/autopilot" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/manifest.json new file mode 100644 index 000000000..b3df49946 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/manifest.json @@ -0,0 +1,64 @@ +{ + "intuneManifestVersion": 1, + "syntheticFixture": true, + "scenario": "deterministic-identity-redaction", + "workload": "enrollment/windows/autopilot", + "description": "An identity-dense bundle. The redacted export removes every serial, hash, device name, tenant domain, user principal name, and profile path, while keeping equal values equal so an explicit correlation still holds after masking.", + "generatedAtUtc": "2026-07-31T09:30:00Z", + "capture": { + "collectedAtUtc": "2026-07-31T09:30:00Z", + "windowsBuild": "10.0.26100.2314", + "autopilotSchemaVersion": "1", + "timezone": "UTC" + }, + "artifacts": [ + { + "artifactId": "autopilot-channel", + "family": "autopilotEvents", + "sourceKind": "eventLog", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "autopilot-events.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-events.json", + "relativePath": "evidence/autopilot-channel/current/autopilot-events.json", + "bytesCopied": 4930, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + }, + { + "artifactId": "mdm-diagnostics-report", + "family": "mdmReport", + "sourceKind": "diagnosticReport", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "autopilot-report.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-report.json", + "relativePath": "evidence/mdm-diagnostics-report/current/autopilot-report.json", + "bytesCopied": 3628, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + }, + { + "artifactId": "esp-session-facts", + "family": "espSession", + "sourceKind": "suppliedFact", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "esp-sessions.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/esp-sessions.json", + "relativePath": "evidence/esp-session-facts/current/esp-sessions.json", + "bytesCopied": 581, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/evidence/autopilot-channel/current/autopilot-events.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/evidence/autopilot-channel/current/autopilot-events.json new file mode 100644 index 000000000..73f7dd206 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/evidence/autopilot-channel/current/autopilot-events.json @@ -0,0 +1,132 @@ +{"_comment": "SYNTHETIC FIXTURE: channel showing a serial mismatch and a TPM failure", + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 160, + "recordId": 1 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:01Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:01Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 160, + "level": "information", + "task": null, + "keywords": null, + "recordId": 1, + "activityId": null, + "namedData": [], + "message": "AutopilotRetrieveSettings beginning acquisition." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 2, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 908, + "recordId": 2 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:04Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:04Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 908, + "level": "error", + "task": null, + "keywords": null, + "recordId": 2, + "activityId": null, + "namedData": [], + "message": "SerialNumberMismatch: the recorded serial does not match the physical hardware." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e3", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 3, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 171, + "recordId": 3 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:05Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:05Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 171, + "level": "error", + "task": null, + "keywords": null, + "recordId": 3, + "activityId": null, + "namedData": [], + "message": "AutopilotManager failed to set TPM identity confirmed. HRESULT=0x801C03EA" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/evidence/mdm-diagnostics-report/current/autopilot-report.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/evidence/mdm-diagnostics-report/current/autopilot-report.json new file mode 100644 index 000000000..60655e361 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/evidence/mdm-diagnostics-report/current/autopilot-report.json @@ -0,0 +1,67 @@ +{"_comment": "SYNTHETIC FIXTURE: report carrying both the registered and observed serials", + "autopilotDocument": "autopilot.mdmDiagnosticsReport", + "documentVersion": 1, + "sections": [ + { + "context": { + "evidenceRef": { + "evidenceId": "rp-s1", + "sourceArtifactId": "mdm-diagnostics-report" + }, + "provenance": { + "sourceKind": "diagnosticReport", + "sourceArtifactId": "mdm-diagnostics-report", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": "parsed", + "accessState": "available" + }, + "sectionId": "deviceIdentity", + "kind": "deviceIdentity", + "outcome": "observed", + "error": null, + "values": [ + { + "name": "serialNumber", + "value": "SYNTH-SERIAL-0001" + }, + { + "name": "entraDeviceId", + "value": "abcdabcd-1234-5678-9012-abcdabcdabcd" + }, + { + "name": "managedDeviceId", + "value": "fedcfedc-4321-8765-2109-fedcfedcfedc" + }, + { + "name": "tenantId", + "value": "00000000-1111-2222-3333-444444444444" + }, + { + "name": "tenantDomain", + "value": "contoso.example" + }, + { + "name": "deviceName", + "value": "SYNTH-DEV-01" + }, + { + "name": "registeredSerialNumber", + "value": "SYNTH-SERIAL-0001" + }, + { + "name": "observedSerialNumber", + "value": "SYNTH-SERIAL-9999" + } + ], + "message": "Autopilot device registration record read from the diagnostics export." + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/expected.json new file mode 100644 index 000000000..929767360 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/expected.json @@ -0,0 +1,68 @@ +{ + "assertions": [ + "an identity mismatch outranks every downstream profile symptom" + ], + "confidence": "high", + "conflictIds": [], + "coverage": [ + { + "artifactId": "autopilot-channel", + "status": "available" + }, + { + "artifactId": "mdm-diagnostics-report", + "status": "available" + } + ], + "documentParseStates": { + "autopilot-channel": "parsed", + "mdm-diagnostics-report": "parsed" + }, + "espLinkConfidence": "low", + "espLinkState": "notObserved", + "espSessionIds": [], + "findingIds": [ + "autopilot-identity-registration-mismatch" + ], + "findings": [ + { + "confidence": "high", + "coverageGapIds": [], + "evidence": [ + { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "ap-e3", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "rp-s1", + "sourceArtifactId": "mdm-diagnostics-report" + } + ], + "findingId": "autopilot-identity-registration-mismatch", + "recommendedChecks": [ + "Compare the registered serial number and hardware hash in Intune with the device's own values.", + "Read Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot events 908 and 171 for the exact token.", + "Deregister and re-register the device if the hardware was replaced." + ], + "severity": "blocker", + "summary": "A cited record reports a serial number, product key, or TPM identity that disagrees with the identity registered for this device. Provisioning cannot proceed until the registration matches the hardware.", + "title": "The device's hardware identity does not match its Autopilot registration" + } + ], + "matchedKeyKinds": [], + "outcome": "identityRegistrationMismatch", + "phase": "profileDiscovery", + "profileApplied": false, + "profileCandidateState": "pending", + "profileRetrieved": false, + "registrationState": "mismatch", + "scenario": "identity-registration-mismatch", + "timeBasis": "utc", + "timezoneState": "declared", + "unclassifiedObservationCount": 0, + "workload": "enrollment/windows/autopilot" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/manifest.json new file mode 100644 index 000000000..2cedf0e55 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/manifest.json @@ -0,0 +1,48 @@ +{ + "intuneManifestVersion": 1, + "syntheticFixture": true, + "scenario": "identity-registration-mismatch", + "workload": "enrollment/windows/autopilot", + "description": "The hardware identity the device reports disagrees with the identity registered for it, and TPM identity confirmation failed as a consequence.", + "generatedAtUtc": "2026-07-31T09:30:00Z", + "capture": { + "collectedAtUtc": "2026-07-31T09:30:00Z", + "windowsBuild": "10.0.26100.2314", + "autopilotSchemaVersion": "1", + "timezone": "UTC" + }, + "artifacts": [ + { + "artifactId": "autopilot-channel", + "family": "autopilotEvents", + "sourceKind": "eventLog", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "autopilot-events.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-events.json", + "relativePath": "evidence/autopilot-channel/current/autopilot-events.json", + "bytesCopied": 4426, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + }, + { + "artifactId": "mdm-diagnostics-report", + "family": "mdmReport", + "sourceKind": "diagnosticReport", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "autopilot-report.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-report.json", + "relativePath": "evidence/mdm-diagnostics-report/current/autopilot-report.json", + "bytesCopied": 1871, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/evidence/autopilot-channel/current/autopilot-events.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/evidence/autopilot-channel/current/autopilot-events.json new file mode 100644 index 000000000..dc62ca579 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/evidence/autopilot-channel/current/autopilot-events.json @@ -0,0 +1,133 @@ +{"_comment": "SYNTHETIC FIXTURE: truncated channel that declares its own incompleteness", + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 160, + "recordId": 1 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:01Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:01Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 160, + "level": "information", + "task": null, + "keywords": null, + "recordId": 1, + "activityId": null, + "namedData": [], + "message": "AutopilotRetrieveSettings beginning acquisition." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 2, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 100, + "recordId": 2 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:03Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:03Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 100, + "level": "warning", + "task": null, + "keywords": null, + "recordId": 2, + "activityId": null, + "namedData": [], + "message": "Autopilot policy SyntheticProfile not found." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e3", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 3, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 4242, + "recordId": 3 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:04Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:04Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 4242, + "level": "information", + "task": null, + "keywords": null, + "recordId": 3, + "activityId": null, + "namedData": [], + "message": "An Autopilot record this build has no documented meaning for." + } + ], + "channelComplete": false +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/expected.json new file mode 100644 index 000000000..9c6cbe041 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/expected.json @@ -0,0 +1,105 @@ +{ + "assertions": [ + "a capped channel cannot support a negative conclusion", + "an undocumented event id is retained and never given terminal semantics" + ], + "confidence": "medium", + "conflictIds": [], + "coverage": [ + { + "artifactId": "autopilot-channel", + "status": "capped" + } + ], + "documentParseStates": { + "autopilot-channel": "parsed" + }, + "espLinkConfidence": "low", + "espLinkState": "notObserved", + "espSessionIds": [], + "findingIds": [ + "autopilot-acquisition-deferred", + "autopilot-evidence-coverage-gap", + "autopilot-undocumented-events-retained" + ], + "findings": [ + { + "confidence": "medium", + "coverageGapIds": [], + "evidence": [ + { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + } + ], + "findingId": "autopilot-acquisition-deferred", + "recommendedChecks": [ + "Re-collect Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot once the device settles, and compare event 100 against a later event 161." + ], + "severity": "info", + "summary": "Acquisition began and the profile was reported as not yet found. Microsoft documents this state as usually transient, so it is not treated as a failure here.", + "title": "Autopilot profile acquisition is still retrying" + }, + { + "confidence": "high", + "coverageGapIds": [ + "autopilot-channel" + ], + "evidence": [ + { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "ap-e3", + "sourceArtifactId": "autopilot-channel" + } + ], + "findingId": "autopilot-evidence-coverage-gap", + "recommendedChecks": [ + "Re-collect the cited artifacts, elevated, before ruling anything out.", + "microsoft-windows-moderndeployment-diagnostics-provider-autopilot.evtx carries Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot in full." + ], + "severity": "warning", + "summary": "1 expected artifact(s) were missing, truncated, skipped, or unreadable. The absence of a signal in them proves nothing. The artifact was truncated, so the absence of a signal in it proves nothing.", + "title": "Expected Autopilot evidence was incomplete" + }, + { + "confidence": "high", + "coverageGapIds": [], + "evidence": [ + { + "evidenceId": "ap-e3", + "sourceArtifactId": "autopilot-channel" + } + ], + "findingId": "autopilot-undocumented-events-retained", + "recommendedChecks": [ + "Read the cited records directly; their message text may still be informative." + ], + "severity": "info", + "summary": "1 record(s) came from the Autopilot channel with an event ID this build has no documented meaning for. They are kept as raw evidence and deliberately given no terminal semantics.", + "title": "Autopilot records with no documented meaning were retained" + } + ], + "matchedKeyKinds": [], + "outcome": "retryDeferred", + "phase": "profileDiscovery", + "profileApplied": false, + "profileCandidateState": "pending", + "profileRetrieved": false, + "registrationState": "unknown", + "scenario": "incomplete-event-channel", + "timeBasis": "utc", + "timezoneState": "declared", + "unclassifiedObservationCount": 1, + "workload": "enrollment/windows/autopilot" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/manifest.json new file mode 100644 index 000000000..bf00f1ae5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/manifest.json @@ -0,0 +1,32 @@ +{ + "intuneManifestVersion": 1, + "syntheticFixture": true, + "scenario": "incomplete-event-channel", + "workload": "enrollment/windows/autopilot", + "description": "A truncated Autopilot channel. Acquisition is visible and still retrying; the absence of a later success proves nothing because the channel is capped.", + "generatedAtUtc": "2026-07-31T09:30:00Z", + "capture": { + "collectedAtUtc": "2026-07-31T09:30:00Z", + "windowsBuild": "10.0.26100.2314", + "autopilotSchemaVersion": "1", + "timezone": "UTC" + }, + "artifacts": [ + { + "artifactId": "autopilot-channel", + "family": "autopilotEvents", + "sourceKind": "eventLog", + "captureState": "capped", + "encoding": "utf-8", + "originalBasename": "autopilot-events.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-events.json", + "relativePath": "evidence/autopilot-channel/current/autopilot-events.json", + "bytesCopied": 4420, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/autopilot-channel/current/autopilot-events.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/autopilot-channel/current/autopilot-events.json new file mode 100644 index 000000000..cb060b7a0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/autopilot-channel/current/autopilot-events.json @@ -0,0 +1,137 @@ +{"_comment": "SYNTHETIC FIXTURE: channel whose records carry bare wall-clock times", + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 160, + "recordId": 1 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31 02:00:01", + "originalOffset": null, + "normalizedUtc": null, + "kind": "local" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 160, + "level": "information", + "task": null, + "keywords": null, + "recordId": 1, + "activityId": null, + "namedData": [], + "message": "AutopilotRetrieveSettings beginning acquisition." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 2, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 161, + "recordId": 2 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31 02:00:08", + "originalOffset": null, + "normalizedUtc": null, + "kind": "local" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 161, + "level": "information", + "task": null, + "keywords": null, + "recordId": 2, + "activityId": null, + "namedData": [ + { + "name": "profileId", + "value": "11111111-2222-3333-4444-555555555555" + } + ], + "message": "AutopilotManager retrieve settings succeeded." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e3", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 3, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 153, + "recordId": 3 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31 02:00:09", + "originalOffset": null, + "normalizedUtc": null, + "kind": "local" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 153, + "level": "information", + "task": null, + "keywords": null, + "recordId": 3, + "activityId": null, + "namedData": [], + "message": "AutopilotManager reported the state changed from ProfileState_Unknown to ProfileState_Available." + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/esp-session-facts/current/esp-sessions.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/esp-session-facts/current/esp-sessions.json new file mode 100644 index 000000000..3bf022fa4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/esp-session-facts/current/esp-sessions.json @@ -0,0 +1,20 @@ +{"_comment": "SYNTHETIC FIXTURE: an ESP session sharing no identifier with the Autopilot evidence", + "autopilotDocument": "autopilot.espSession", + "documentVersion": 1, + "sessions": [ + { + "sessionId": "esp-session-0005", + "enrollmentId": "ffffffff-0000-1111-2222-333333333333", + "correlationId": null, + "activityId": null, + "entraDeviceId": null, + "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:20Z", + "phase": "deviceSetup", + "evidence": { + "evidenceId": "esp-s1", + "sourceArtifactId": "esp-session-facts" + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/mdm-diagnostics-report/current/autopilot-report.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/mdm-diagnostics-report/current/autopilot-report.json new file mode 100644 index 000000000..284e3427e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/mdm-diagnostics-report/current/autopilot-report.json @@ -0,0 +1,76 @@ +{"_comment": "SYNTHETIC FIXTURE: report recording the ESP handoff", + "autopilotDocument": "autopilot.mdmDiagnosticsReport", + "documentVersion": 1, + "sections": [ + { + "context": { + "evidenceRef": { + "evidenceId": "rp-s2", + "sourceArtifactId": "mdm-diagnostics-report" + }, + "provenance": { + "sourceKind": "diagnosticReport", + "sourceArtifactId": "mdm-diagnostics-report", + "filePath": null, + "lineNumber": null, + "recordNumber": 2, + "registry": null, + "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": "parsed", + "accessState": "available" + }, + "sectionId": "enrollmentHandoff", + "kind": "enrollmentHandoff", + "outcome": "observed", + "error": null, + "values": [ + { + "name": "enrollmentId", + "value": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + } + ], + "message": "MDM enrollment began for the Autopilot-assigned profile." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "rp-s3", + "sourceArtifactId": "mdm-diagnostics-report" + }, + "provenance": { + "sourceKind": "diagnosticReport", + "sourceArtifactId": "mdm-diagnostics-report", + "filePath": null, + "lineNumber": null, + "recordNumber": 3, + "registry": null, + "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": "parsed", + "accessState": "available" + }, + "sectionId": "espHandoff", + "kind": "espHandoff", + "outcome": "observed", + "error": null, + "values": [ + { + "name": "correlationId", + "value": "12121212-3434-5656-7878-909090909090" + }, + { + "name": "entraDeviceId", + "value": "abcdabcd-1234-5678-9012-abcdabcdabcd" + } + ], + "message": "Control passed to the Enrollment Status Page." + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/expected.json new file mode 100644 index 000000000..0543eba4b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/expected.json @@ -0,0 +1,98 @@ +{ + "assertions": [ + "an unrecognizable timezone downgrades the time basis instead of being ignored", + "ESP facts with no shared key and an unreliable time basis are not even a low-confidence candidate" + ], + "confidence": "medium", + "conflictIds": [], + "coverage": [ + { + "artifactId": "autopilot-channel", + "status": "available" + }, + { + "artifactId": "mdm-diagnostics-report", + "status": "available" + }, + { + "artifactId": "esp-session-facts", + "status": "available" + } + ], + "documentParseStates": { + "autopilot-channel": "parsed", + "esp-session-facts": "parsed", + "mdm-diagnostics-report": "parsed" + }, + "espLinkConfidence": "low", + "espLinkState": "notObserved", + "espSessionIds": [], + "findingIds": [ + "autopilot-timestamps-unreliable", + "autopilot-phase-completed" + ], + "findings": [ + { + "confidence": "high", + "coverageGapIds": [ + "autopilot-channel", + "esp-session-facts", + "mdm-diagnostics-report" + ], + "evidence": [], + "findingId": "autopilot-timestamps-unreliable", + "recommendedChecks": [ + "Re-collect with the device's timezone recorded alongside the evidence.", + "Prefer sources that emit an explicit UTC offset per record." + ], + "severity": "warning", + "summary": "Ordering fell back to record identity because the declared timezone is not a recognizable UTC offset or region identifier. No conclusion in this analysis relies on wall-clock time, and time-proximity correlation with an ESP session is refused outright.", + "title": "The capture declared an unrecognizable timezone" + }, + { + "confidence": "high", + "coverageGapIds": [], + "evidence": [ + { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "ap-e3", + "sourceArtifactId": "autopilot-channel" + }, + { + "evidenceId": "rp-s2", + "sourceArtifactId": "mdm-diagnostics-report" + }, + { + "evidenceId": "rp-s3", + "sourceArtifactId": "mdm-diagnostics-report" + } + ], + "findingId": "autopilot-phase-completed", + "recommendedChecks": [ + "Review the correlated ESP session for what happened after the handoff." + ], + "severity": "info", + "summary": "A profile was retrieved and applied, and the handoff into enrollment and the Enrollment Status Page was observed. Everything after this point belongs to the ESP analysis, not to Autopilot.", + "title": "The local Autopilot phase completed and handed off" + } + ], + "matchedKeyKinds": [], + "outcome": "completed", + "phase": "espHandoff", + "profileApplied": true, + "profileCandidateState": "available", + "profileRetrieved": true, + "registrationState": "unknown", + "scenario": "invalid-timezone", + "timeBasis": "unreliable", + "timezoneState": "invalid", + "unclassifiedObservationCount": 0, + "workload": "enrollment/windows/autopilot" +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/manifest.json new file mode 100644 index 000000000..107bfab85 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/manifest.json @@ -0,0 +1,64 @@ +{ + "intuneManifestVersion": 1, + "syntheticFixture": true, + "scenario": "invalid-timezone", + "workload": "enrollment/windows/autopilot", + "description": "The collection declares a timezone that is not a recognizable offset or region identifier, and every record carries a bare wall-clock time. Ordering falls back to record identity and the unrelated ESP session stays an unbound candidate.", + "generatedAtUtc": "2026-07-31T09:30:00Z", + "capture": { + "collectedAtUtc": "2026-07-31T09:30:00Z", + "windowsBuild": "10.0.26100.2314", + "autopilotSchemaVersion": "1", + "timezone": "Pacific Standard Time (device local)" + }, + "artifacts": [ + { + "artifactId": "autopilot-channel", + "family": "autopilotEvents", + "sourceKind": "eventLog", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "autopilot-events.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-events.json", + "relativePath": "evidence/autopilot-channel/current/autopilot-events.json", + "bytesCopied": 4478, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + }, + { + "artifactId": "mdm-diagnostics-report", + "family": "mdmReport", + "sourceKind": "diagnosticReport", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "autopilot-report.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-report.json", + "relativePath": "evidence/mdm-diagnostics-report/current/autopilot-report.json", + "bytesCopied": 2188, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + }, + { + "artifactId": "esp-session-facts", + "family": "espSession", + "sourceKind": "suppliedFact", + "captureState": "captured", + "encoding": "utf-8", + "originalBasename": "esp-sessions.json", + "sanitizedSourcePath": "SYNTHETIC://autopilot/esp-sessions.json", + "relativePath": "evidence/esp-session-facts/current/esp-sessions.json", + "bytesCopied": 605, + "capturedUtc": "2026-07-31T09:30:00Z", + "rotation": { + "kind": "current", + "fragmentComplete": true + } + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/malformed-report-section/evidence/autopilot-channel/current/autopilot-events.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/malformed-report-section/evidence/autopilot-channel/current/autopilot-events.json new file mode 100644 index 000000000..49cdfe2b1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/malformed-report-section/evidence/autopilot-channel/current/autopilot-events.json @@ -0,0 +1,95 @@ +{"_comment": "SYNTHETIC FIXTURE: channel showing retrieval only", + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e1", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 1, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 160, + "recordId": 1 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:01Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:01Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 160, + "level": "information", + "task": null, + "keywords": null, + "recordId": 1, + "activityId": null, + "namedData": [], + "message": "AutopilotRetrieveSettings beginning acquisition." + }, + { + "context": { + "evidenceRef": { + "evidenceId": "ap-e2", + "sourceArtifactId": "autopilot-channel" + }, + "provenance": { + "sourceKind": "eventLog", + "sourceArtifactId": "autopilot-channel", + "filePath": null, + "lineNumber": null, + "recordNumber": 2, + "registry": null, + "event": { + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 161, + "recordId": 2 + } + }, + "sourceTimestamp": { + "rawText": "2026-07-31T09:00:08Z", + "originalOffset": "+00:00", + "normalizedUtc": "2026-07-31T09:00:08Z", + "kind": "utc" + }, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": "parsed", + "accessState": "available" + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": 161, + "level": "information", + "task": null, + "keywords": null, + "recordId": 2, + "activityId": null, + "namedData": [ + { + "name": "profileId", + "value": "11111111-2222-3333-4444-555555555555" + } + ], + "message": "AutopilotManager retrieve settings succeeded." + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/malformed-report-section/evidence/mdm-diagnostics-report/current/autopilot-report.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/malformed-report-section/evidence/mdm-diagnostics-report/current/autopilot-report.json new file mode 100644 index 000000000..9d186499c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/malformed-report-section/evidence/mdm-diagnostics-report/current/autopilot-report.json @@ -0,0 +1,4 @@ + + + + PathBuf { + corpus_root(CORPUS).join(scenario) +} + +fn capture_state(raw: &str) -> AutopilotCaptureState { + serde_json::from_value(Value::String(raw.to_owned())) + .unwrap_or_else(|error| panic!("captureState {raw:?} is a known state: {error}")) +} + +fn optional_string(value: &Value) -> Option { + value.as_str().map(str::to_owned) +} + +/// Build the reducer input from a scenario's manifest and evidence on disk. +/// +/// This is the only place the test touches the filesystem: the crate itself is +/// wasm32-clean and never reads a file. +fn bundle(scenario: &str) -> AutopilotBundleInput { + let root = scenario_root(scenario); + let manifest = load_json(&root.join("manifest.json")); + + let capture = &manifest["capture"]; + let sources = manifest["artifacts"] + .as_array() + .expect("manifest artifacts must be an array") + .iter() + .map(|artifact| AutopilotSourceInput { + artifact_id: artifact["artifactId"] + .as_str() + .expect("artifactId") + .to_owned(), + family: artifact["family"].as_str().unwrap_or_default().to_owned(), + capture_state: capture_state(artifact["captureState"].as_str().expect("captureState")), + original_basename: optional_string(&artifact["originalBasename"]), + sanitized_source_path: optional_string(&artifact["sanitizedSourcePath"]), + content: artifact["relativePath"].as_str().map(|relative| { + std::fs::read_to_string(root.join(relative)) + .unwrap_or_else(|error| panic!("{relative} is readable: {error}")) + }), + ..AutopilotSourceInput::default() + }) + .collect(); + + AutopilotBundleInput { + generated_at_utc: manifest["generatedAtUtc"] + .as_str() + .expect("generatedAtUtc") + .to_owned(), + capture: AutopilotCaptureMetadata { + collected_at_utc: optional_string(&capture["collectedAtUtc"]), + windows_build: optional_string(&capture["windowsBuild"]), + autopilot_schema_version: optional_string(&capture["autopilotSchemaVersion"]), + timezone: optional_string(&capture["timezone"]), + }, + sources, + events: Vec::new(), + } +} + +fn reduce(scenario: &str) -> (AutopilotSnapshot, Value) { + let expected = load_json(&scenario_root(scenario).join("expected.json")); + (reduce_autopilot_bundle(&bundle(scenario)), expected) +} + +fn wire(value: &impl serde::Serialize) -> Value { + serde_json::to_value(value).expect("snapshot must serialize") +} + +// ── The shared contract ───────────────────────────────────────────────────── + +#[test] +fn the_corpus_contains_exactly_the_required_fixture_matrix() { + assert_eq!( + scenario_names(&corpus_root(CORPUS)), + SCENARIOS.map(str::to_owned).to_vec(), + "issue #362 pins this matrix; adding or dropping a scenario is a contract change" + ); +} + +#[test] +fn every_scenario_satisfies_the_shared_fixture_contract() { + let mut failures = Failures::new(); + for scenario in SCENARIOS { + let root = scenario_root(scenario); + failures.absorb(validate_scenario( + scenario, + &root, + &load_json(&root.join("manifest.json")), + &load_json(&root.join("expected.json")), + )); + } + failures.assert_empty("autopilot corpus"); +} + +/// The harness must actually reject a corrupted copy of this corpus, not merely +/// pass over the clean one. +#[test] +fn a_corrupted_byte_count_in_this_corpus_is_rejected() { + let scenario = "no-profile-candidate"; + let root = scenario_root(scenario); + let manifest = load_json(&root.join("manifest.json")); + let expected = load_json(&root.join("expected.json")); + + let failures = validate_scenario( + scenario, + &root, + &mutated(&manifest, "/artifacts/0/bytesCopied", json!(1)), + &expected, + ); + assert!( + failures + .entries() + .iter() + .any(|entry| entry.contains("bytesCopied")), + "expected a bytesCopied failure, got {:?}", + failures.entries() + ); +} + +// ── Autopilot semantics ───────────────────────────────────────────────────── + +/// Assert one scenario's reduction against its stated contract. +fn assert_scenario(scenario: &str) -> AutopilotSnapshot { + let (snapshot, expected) = reduce(scenario); + let value = wire(&snapshot); + let at = scenario; + + for (pointer, key) in [ + ("/outcome", "outcome"), + ("/phase", "phase"), + ("/confidence", "confidence"), + ("/timezoneState", "timezoneState"), + ("/timeBasis", "timeBasis"), + ] { + assert_eq!( + value.pointer(pointer).unwrap_or(&Value::Null), + &expected[key], + "{at}: {key}" + ); + } + assert_eq!( + value["identity"]["registrationState"], expected["registrationState"], + "{at}: registrationState" + ); + assert_eq!( + value["profile"]["candidateState"], expected["profileCandidateState"], + "{at}: profileCandidateState" + ); + assert_eq!( + value["profile"]["retrieved"], expected["profileRetrieved"], + "{at}: profileRetrieved" + ); + assert_eq!( + value["profile"]["applied"], expected["profileApplied"], + "{at}: profileApplied" + ); + + // ESP stays a sibling: the snapshot records how the two bind and nothing + // about what ESP itself concluded. + assert_eq!( + value["espLinkage"]["state"], expected["espLinkState"], + "{at}: espLinkState" + ); + assert_eq!( + value["espLinkage"]["confidence"], expected["espLinkConfidence"], + "{at}: espLinkConfidence" + ); + assert_eq!( + value["espLinkage"]["espSessionIds"], expected["espSessionIds"], + "{at}: espSessionIds" + ); + assert_eq!( + snapshot + .esp_linkage + .matched_keys + .iter() + .map(|key| wire(&key.kind)) + .collect::(), + expected["matchedKeyKinds"], + "{at}: matched correlation key kinds" + ); + + assert_eq!( + snapshot + .conflicts + .iter() + .map(|conflict| Value::String(conflict.conflict_id.clone())) + .collect::(), + expected["conflictIds"], + "{at}: conflictIds" + ); + + for document in &snapshot.documents { + let want = &expected["documentParseStates"][&document.artifact_id]; + assert!( + !want.is_null(), + "{at}: document {} has no expected parse state", + document.artifact_id + ); + assert_eq!( + wire(&document.parse_state), + *want, + "{at}: parse state for {}", + document.artifact_id + ); + } + assert_eq!( + snapshot.documents.len(), + expected["documentParseStates"] + .as_object() + .expect("documentParseStates") + .len(), + "{at}: every expected document must be reported" + ); + + assert_eq!( + snapshot.unclassified_observation_ids.len() as u64, + expected["unclassifiedObservationCount"] + .as_u64() + .expect("unclassifiedObservationCount"), + "{at}: unclassified observation count" + ); + + assert_coverage_matches_manifest(at, &snapshot, &expected); + assert_findings(at, &snapshot, &expected); + + // The invariant every leaf of epic #356 owes: no uncited conclusions. + assert!( + snapshot.findings_are_evidence_backed(), + "{at}: a finding cited neither evidence nor a coverage gap" + ); + + snapshot +} + +fn assert_coverage_matches_manifest(at: &str, snapshot: &AutopilotSnapshot, expected: &Value) { + let actual = snapshot + .coverage + .iter() + .map(|entry| json!({ "artifactId": entry.artifact_id, "status": wire(&entry.status) })) + .collect::(); + assert_eq!(actual, expected["coverage"], "{at}: coverage"); +} + +fn assert_findings(at: &str, snapshot: &AutopilotSnapshot, expected: &Value) { + let actual_ids = snapshot + .findings + .iter() + .map(|finding| Value::String(finding.finding_id.clone())) + .collect::(); + assert_eq!(actual_ids, expected["findingIds"], "{at}: findingIds"); + + let golden = expected["findings"] + .as_array() + .unwrap_or_else(|| panic!("{at}: expected.json must carry a findings array")); + assert_eq!( + golden.len(), + snapshot.findings.len(), + "{at}: findings golden is stale; regenerate with UPDATE_AUTOPILOT_FINDINGS=1" + ); + for (actual, want) in snapshot.findings.iter().zip(golden) { + assert_eq!( + wire(actual), + *want, + "{at}: finding {} drifted from its golden", + actual.finding_id + ); + } +} + +// ── The required fixture matrix ───────────────────────────────────────────── + +#[test] +fn user_driven_success_reaches_the_esp_handoff_and_links_by_an_explicit_key() { + let snapshot = assert_scenario("user-driven-success-through-esp-handoff"); + assert_eq!( + snapshot.oobe.settings.len(), + 1, + "the OOBE settings override must be retained as a typed fact" + ); + assert_eq!( + snapshot.profile.profile_id.as_deref(), + Some("11111111-2222-3333-4444-555555555555") + ); +} + +/// Issue #362 explicitly defers self-deploying and pre-provisioning "only after +/// its actual source contract is captured". Until then the honest reduction is +/// a refusal, not a guess -- so this fixture asserts the refusal. +#[test] +fn a_self_deploying_sample_without_a_captured_source_contract_asserts_nothing_terminal() { + let snapshot = assert_scenario("self-deploying-source-contract-not-captured"); + assert!( + snapshot + .documents + .iter() + .any(|document| document.declared_kind.as_deref() + == Some("autopilot.selfDeployingContract")), + "the unvalidated document's declared kind must survive verbatim" + ); +} + +#[test] +fn no_profile_candidate_outranks_the_transient_lookup_that_preceded_it() { + assert_scenario("no-profile-candidate"); +} + +#[test] +fn profile_retrieval_failure_is_distinct_from_having_no_candidate() { + let snapshot = assert_scenario("profile-retrieval-failure"); + assert_eq!( + snapshot + .profile + .error + .as_ref() + .map(|error| error.raw.as_str()), + Some("0x80072EE2"), + "the reported code must survive in the form the source wrote it" + ); +} + +#[test] +fn profile_application_failure_keeps_retrieval_marked_as_succeeded() { + assert_scenario("profile-application-failure"); +} + +#[test] +fn an_identity_mismatch_outranks_every_downstream_profile_symptom() { + assert_scenario("identity-registration-mismatch"); +} + +#[test] +fn a_network_symptom_without_a_proven_cause_stays_low_confidence() { + let snapshot = assert_scenario("network-retry-without-terminal-proof"); + let finding = snapshot + .findings + .iter() + .find(|finding| finding.finding_id == "autopilot-network-symptom-without-cause") + .expect("the symptom finding must be present"); + assert_eq!( + wire(&finding.confidence), + json!("low"), + "a symptom with no proven cause may never be presented confidently" + ); +} + +#[test] +fn reaching_the_handoff_without_esp_evidence_is_not_a_completed_deployment() { + assert_scenario("completed-without-esp-bundle"); +} + +#[test] +fn one_explicit_shared_identifier_links_autopilot_to_an_esp_session() { + let snapshot = assert_scenario("matching-autopilot-and-esp-session"); + assert_eq!(snapshot.esp_linkage.matched_keys.len(), 1); +} + +#[test] +fn contradictory_identifiers_suppress_every_terminal_claim() { + assert_scenario("conflicting-profile-session-identifiers"); +} + +#[test] +fn a_capped_channel_cannot_support_a_negative_conclusion() { + assert_scenario("incomplete-event-channel"); +} + +#[test] +fn a_malformed_report_section_is_a_coverage_gap_not_an_unknown_schema() { + assert_scenario("malformed-report-section"); +} + +#[test] +fn an_unvalidated_windows_build_withholds_terminal_semantics_but_keeps_the_phase() { + assert_scenario("unknown-windows-schema-version"); +} + +#[test] +fn an_unrecognizable_timezone_downgrades_the_time_basis() { + assert_scenario("invalid-timezone"); +} + +#[test] +fn the_redacted_export_removes_identity_while_preserving_correlation() { + let scenario = "deterministic-identity-redaction"; + let snapshot = assert_scenario(scenario); + let expected = load_json(&scenario_root(scenario).join("expected.json")); + + let redacted = redacted_export_projection(&snapshot); + let text = serde_json::to_string(&wire(&redacted)).expect("redacted export must serialize"); + + for needle in expected["redactionMustNotContain"] + .as_array() + .expect("redactionMustNotContain") + { + let needle = needle.as_str().expect("needle"); + assert!( + !text.contains(needle), + "{scenario}: redacted export still contains {needle:?}" + ); + } + for needle in expected["redactionMustContain"] + .as_array() + .expect("redactionMustContain") + { + let needle = needle.as_str().expect("needle"); + assert!( + text.contains(needle), + "{scenario}: redacted export dropped {needle:?}, which is the only handle back to Intune" + ); + } + + // The masked correlation key must still equal the masked value it came + // from; otherwise redaction would destroy the very link it is exported to + // show. + let masked_key = &redacted.esp_linkage.matched_keys[0].value; + assert!( + masked_key.starts_with('['), + "the correlation key must be masked, got {masked_key}" + ); + assert_eq!( + redacted.esp_linkage.matched_keys, + redacted_export_projection(&snapshot) + .esp_linkage + .matched_keys, + "masking must be a pure function of the value" + ); +} + +// ── Cross-cutting contract ────────────────────────────────────────────────── + +#[test] +fn reduction_is_deterministic_across_runs() { + for scenario in SCENARIOS { + let first = wire(&reduce_autopilot_bundle(&bundle(scenario))); + let second = wire(&reduce_autopilot_bundle(&bundle(scenario))); + assert_eq!(first, second, "{scenario}: reduction must be deterministic"); + } +} + +#[test] +fn the_redacted_export_projection_is_idempotent() { + for scenario in SCENARIOS { + let snapshot = reduce_autopilot_bundle(&bundle(scenario)); + let once = redacted_export_projection(&snapshot); + let twice = redacted_export_projection(&once); + assert_eq!( + wire(&once), + wire(&twice), + "{scenario}: redaction must be idempotent" + ); + } +} + +#[test] +fn the_snapshot_serializes_as_stable_camel_case() { + let snapshot = reduce_autopilot_bundle(&bundle("user-driven-success-through-esp-handoff")); + let value = wire(&snapshot); + for key in [ + "schemaVersion", + "generatedAtUtc", + "capture", + "timezoneState", + "timeBasis", + "identity", + "profile", + "oobe", + "handoff", + "espLinkage", + "phase", + "outcome", + "confidence", + "nextEvidenceRequests", + "observations", + "unclassifiedObservationIds", + "documents", + "conflicts", + "coverage", + "findings", + ] { + assert!(value.get(key).is_some(), "missing top-level key {key}"); + } +} + +/// Every finding must name a concrete next artifact, and every scenario that is +/// not already complete must ask for something. A diagnosis that cannot say +/// what to collect next is not actionable. +#[test] +fn every_finding_recommends_at_least_one_concrete_check() { + for scenario in SCENARIOS { + let snapshot = reduce_autopilot_bundle(&bundle(scenario)); + for finding in &snapshot.findings { + assert!( + !finding.recommended_checks.is_empty(), + "{scenario}: finding {} recommends nothing", + finding.finding_id + ); + } + if snapshot.outcome != cmtraceopen_parser::intune::enrollment::windows::autopilot::AutopilotOutcome::Completed { + assert!( + !snapshot.next_evidence_requests.is_empty(), + "{scenario}: an incomplete diagnosis must name the next artifact" + ); + } + } +} + +/// Observation ids must be unique across a bundle, or a finding's citation +/// becomes ambiguous. +#[test] +fn observation_ids_are_unique_within_a_bundle() { + for scenario in SCENARIOS { + let snapshot = reduce_autopilot_bundle(&bundle(scenario)); + let mut seen = BTreeSet::new(); + for observation in &snapshot.observations { + assert!( + seen.insert(observation.observation_id.as_str()), + "{scenario}: observation id {} was reused", + observation.observation_id + ); + } + } +} + +/// Records from a channel this module does not own must not become Autopilot +/// evidence, however plausible they look. A busy device would otherwise report +/// a worse diagnosis than a quiet one. +#[test] +fn records_from_a_sibling_channel_are_ignored_entirely() { + let mut input = bundle("no-profile-candidate"); + let intruder = std::fs::read_to_string( + scenario_root("no-profile-candidate") + .join("evidence/autopilot-channel/current/autopilot-events.json"), + ) + .expect("evidence is readable") + .replace( + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService", + ); + input.sources.push(AutopilotSourceInput { + artifact_id: "sibling-channel".to_owned(), + family: "managementService".to_owned(), + capture_state: AutopilotCaptureState::Captured, + original_basename: Some("management-service.json".to_owned()), + sanitized_source_path: None, + content: Some(intruder), + ..AutopilotSourceInput::default() + }); + + let snapshot = reduce_autopilot_bundle(&input); + let baseline = reduce_autopilot_bundle(&bundle("no-profile-candidate")); + assert_eq!( + snapshot.observations.len(), + baseline.observations.len(), + "a sibling channel's records must not become Autopilot observations" + ); + assert_eq!(snapshot.outcome, baseline.outcome); + assert!( + snapshot.unclassified_observation_ids.is_empty(), + "an unrelated channel must not even register as unclassified Autopilot evidence" + ); +} + +// ── Golden maintenance ────────────────────────────────────────────────────── + +/// Rewrite every scenario's `findings` golden from the current reducer output. +/// +/// Runs only under `UPDATE_AUTOPILOT_FINDINGS=1`, and is a no-op otherwise so +/// the suite stays read-only in CI. The rewrite touches the `findings` key and +/// nothing else, so the hand-written semantic expectations survive and keep +/// cross-checking the regenerated goldens. +#[test] +fn update_findings_golden() { + if std::env::var("UPDATE_AUTOPILOT_FINDINGS").is_err() { + return; + } + for scenario in SCENARIOS { + let snapshot = reduce_autopilot_bundle(&bundle(scenario)); + let path = scenario_root(scenario).join("expected.json"); + let mut expected = load_json(&path); + // Only `findings` is regenerated. `findingIds` stays hand written on + // purpose: it is the cross-check that catches a regeneration which + // quietly changed which rules fire. + expected["findings"] = wire(&snapshot.findings); + write_json(&path, &expected); + } +} + +fn write_json(path: &Path, value: &Value) { + let text = serde_json::to_string_pretty(value).expect("golden must serialize") + "\n"; + std::fs::write(path, text) + .unwrap_or_else(|error| panic!("{} is writable: {error}", path.display())); +}