From f3a890fb0a24467b0e354984fef6e0ef085045e2 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 09:49:04 -0400 Subject: [PATCH 01/14] fix(intune): gate every Autopilot reduction path on assessability ADR-001: non-assessable evidence cannot produce a terminal conclusion. reduce_profile iterated observations raw, so a capped or unparsed success record could set retrieved/applied and, with an observed ESP handoff, prove Completed while the matching failure branch was filtered. The identity evidence loop had the same bypass and inflated the phase to IdentityObserved from an unreadable record. Closing the class, not the instance: sections_of now gates report sections on their own declared context (covering identity, profile, handoff, and outcome section paths), and the conflict-mismatch loop, correlation-key extraction, linkage evidence attribution, and the time-overlap probe are gated the same way. time_basis stays deliberately unfiltered because there the unfiltered scan is the conservative direction; documented at is_assessable. Also per ADR-003: reduce_outcome now matches a Conflicting ESP linkage explicitly and returns ContradictoryEvidence. The multi-session match path records no AutopilotConflict, so the empty-conflicts gate above it let an ambiguous session identity fall through to Completed. Refs #362, PR #450. Co-Authored-By: Claude Fable 5 --- .../enrollment/windows/autopilot/reducer.rs | 81 ++++++++++++++----- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs index c45345c86..aabf1ea64 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs @@ -257,7 +257,11 @@ impl Ingest { /// 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. + /// record a malformed document instead of an empty supported one. The + /// detail is a stable reducer-authored sentence and deliberately excludes + /// `serde_json`'s error text: that text flows into golden-asserted finding + /// summaries, and serde_json does not guarantee its `Display` output + /// across releases. fn absorb_payload( &mut self, source: &AutopilotSourceInput, @@ -268,7 +272,7 @@ impl Ingest { match kind { AutopilotDocumentKind::Events => { let document = serde_json::from_str::(content) - .map_err(|error| format!("events payload could not be read: {error}"))?; + .map_err(|_| "events payload could not be read".to_owned())?; if document.channel_complete == Some(false) { self.incomplete_artifacts.insert(source.artifact_id.clone()); } @@ -283,7 +287,7 @@ impl Ingest { } AutopilotDocumentKind::DiagnosticsReport | AutopilotDocumentKind::IdentityFacts => { let document = serde_json::from_str::(content) - .map_err(|error| format!("report payload could not be read: {error}"))?; + .map_err(|_| "report payload could not be read".to_owned())?; for section in &document.sections { let observation = observation_from_section(section); ids.push(observation.observation_id.clone()); @@ -293,7 +297,7 @@ impl Ingest { } AutopilotDocumentKind::EspSession => { let document = serde_json::from_str::(content) - .map_err(|error| format!("ESP session payload could not be read: {error}"))?; + .map_err(|_| "ESP session payload could not be read".to_owned())?; for session in &document.sessions { let observation = observation_from_esp_session(session); ids.push(observation.observation_id.clone()); @@ -585,11 +589,27 @@ fn sort_time(observation: &AutopilotObservation, basis: AutopilotTimeBasis) -> O /// An observation is assessable only when its record was fully available and /// parsed. Malformed or access-denied records carry no semantic signal. +/// +/// Every reduction path that turns a record into state, evidence, a phase, a +/// correlation key, or a time bound must pass through this gate (ADR-001: +/// non-assessable evidence cannot produce a terminal conclusion). The one +/// deliberate exception is [`time_basis`], where an *unfiltered* scan is the +/// conservative direction: a non-assessable record with an unnormalized +/// timestamp downgrades the basis, and gating it would upgrade it. fn is_assessable(observation: &AutopilotObservation) -> bool { observation.context.access_state == IntuneAccessState::Available && observation.context.parse_state == IntuneParseState::Parsed } +/// A report section is assessable under the same rule as an observation: its +/// own declared context must be fully available and parsed. Sections arrive +/// inside a parsed document, but each one still carries a caller-declared +/// context, and a section declared unreadable proves nothing. +fn is_assessable_section(section: &AutopilotReportSection) -> bool { + section.context.access_state == IntuneAccessState::Available + && section.context.parse_state == IntuneParseState::Parsed +} + fn signal_observations( observations: &[AutopilotObservation], signal: AutopilotSignal, @@ -603,12 +623,17 @@ fn has_signal(observations: &[AutopilotObservation], signal: AutopilotSignal) -> signal_observations(observations, signal).next().is_some() } +/// Iterate the assessable sections of one kind. The gate lives here so no +/// individual caller can forget it. fn sections_of<'a>( sections: &'a [AutopilotReportSection], kind: &AutopilotSectionKind, ) -> impl Iterator { let kind = kind.clone(); - sections.iter().filter(move |section| section.kind == kind) + sections + .iter() + .filter(|section| is_assessable_section(section)) + .filter(move |section| section.kind == kind) } /// Collect the distinct values a named key takes across every source. @@ -640,8 +665,11 @@ fn single_value(observations: &[AutopilotObservation], key: &str) -> Option = None; let mut last_state_token = None; - for observation in observations { + for observation in observations.iter().filter(|obs| is_assessable(obs)) { match observation.signal { AutopilotSignal::ProfileAcquisitionStarted | AutopilotSignal::ProfilePolicyNotFound @@ -757,6 +786,12 @@ fn reduce_profile( AutopilotSignal::ProfileStateChanged => { evidence.push(observation.evidence_ref()); let token = extract_profile_state(observation.message.as_deref()); + // `ProfileState_Available` deliberately counts as application + // evidence, not merely retrieval. Event 172 -- the documented + // failure sibling of this transition -- reads "failed to set + // Autopilot profile as available": setting the profile + // available IS the application step, so the transition into + // `Available` is its explicit success record. if matches!( token, Some(AutopilotProfileStateToken::Available) @@ -999,8 +1034,11 @@ fn detect_conflicts( // A report section that explicitly reports a mismatch is a conflict the // collector already detected; carrying it here keeps the two paths uniform. + // Gated like every other path: an unreadable section cannot assert a + // mismatch any more than it can assert a success. for section in sections .iter() + .filter(|section| is_assessable_section(section)) .filter(|section| section.outcome == AutopilotSectionOutcome::Mismatch) { conflicts.push(AutopilotConflict { @@ -1074,10 +1112,9 @@ fn autopilot_keys(observations: &[AutopilotObservation]) -> BTreeSet bool { let ap_times: Vec<&str> = observations .iter() + .filter(|obs| is_assessable(obs)) .filter_map(|obs| { obs.context .source_timestamp @@ -1361,6 +1398,12 @@ fn reduce_outcome( AutopilotEspLinkState::EvidenceMissing => { AutopilotOutcome::HandoffReachedEspEvidenceMissing } + // Matched explicitly rather than through `conflicts`: the + // multiple-matched-sessions path records no `AutopilotConflict`, + // so relying on the conflicts gate above would let an ambiguous + // session identity fall through to `Completed` (ADR-003: + // unresolved authoritative contradictions stay conservative). + AutopilotEspLinkState::Conflicting => AutopilotOutcome::ContradictoryEvidence, _ => AutopilotOutcome::Completed, }; } From 7b7feccdf3c7254b33cb6ac802431039ada7d3d7 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 09:49:15 -0400 Subject: [PATCH 02/14] fix(intune): explain withheld and conflicting Autopilot outcomes Two silent-outcome gaps in the findings rules: - push_unknown_schema gated its capture branch on windows_build alone, so a capture declaring only an unvalidated autopilotSchemaVersion reduced to UnknownSchema with every terminal rule suppressed and no finding saying why. The gate now covers both declared values and the summary names whichever failed validation. - A Conflicting ESP linkage reached through distinct keys matching distinct sessions records no AutopilotConflict, so no rule explained it. push_esp_link_conflicting covers exactly that path; the single-key-many-sessions path stays with push_contradictory_evidence. ADR-001 (withheld semantics must still be explained) and ADR-003 (conservative representation of unresolved contradictions). Refs #362, PR #450. Co-Authored-By: Claude Fable 5 --- .../enrollment/windows/autopilot/rules.rs | 94 ++++++++++++++++--- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs index 52e06d050..c1d1f2c09 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs @@ -16,7 +16,9 @@ use crate::intune::evidence::{ use super::models::*; use super::reducer::normalized_evidence; -use super::sources::AUTOPILOT_EVENT_CHANNEL; +use super::sources::{ + is_validated_schema_version, is_validated_windows_build, AUTOPILOT_EVENT_CHANNEL, +}; /// Derive stable, read-only findings from a snapshot. /// @@ -37,6 +39,7 @@ pub fn derive_findings(snapshot: &AutopilotSnapshot) -> Vec { push_retry_deferred(snapshot, &mut findings); push_esp_evidence_missing(snapshot, &mut findings); push_esp_time_only_candidate(snapshot, &mut findings); + push_esp_link_conflicting(snapshot, &mut findings); push_esp_linked(snapshot, &mut findings); push_unreliable_timestamps(snapshot, &mut findings); push_coverage_gaps(snapshot, &mut findings); @@ -54,22 +57,44 @@ fn push_unknown_schema(snapshot: &AutopilotSnapshot, findings: &mut Vec>(); - let build_unvalidated = snapshot.outcome == AutopilotOutcome::UnknownSchema + // Either declared capture value can be the one that failed validation. + // Gating on `windows_build` alone left a capture that declared only an + // unvalidated `autopilotSchemaVersion` with an unexplained `UnknownSchema` + // outcome: every terminal rule was suppressed and no finding said why. + let capture_unvalidated = snapshot.outcome == AutopilotOutcome::UnknownSchema && unsupported.is_empty() - && snapshot.capture.windows_build.is_some(); - if unsupported.is_empty() && !build_unvalidated { + && (snapshot.capture.windows_build.is_some() + || snapshot.capture.autopilot_schema_version.is_some()); + if unsupported.is_empty() && !capture_unvalidated { return; } let mut summary = if unsupported.is_empty() { + let mut offenders = Vec::new(); + if !is_validated_windows_build(snapshot.capture.windows_build.as_deref()) { + offenders.push(format!( + "Windows build {}", + snapshot + .capture + .windows_build + .as_deref() + .unwrap_or("unknown") + )); + } + if !is_validated_schema_version(snapshot.capture.autopilot_schema_version.as_deref()) { + offenders.push(format!( + "Autopilot schema version {}", + snapshot + .capture + .autopilot_schema_version + .as_deref() + .unwrap_or("unknown") + )); + } 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") + "This build has no validated Autopilot rules for {}, so terminal semantics are \ + withheld and the evidence is retained raw.", + offenders.join(" or ") ) } else { format!( @@ -109,7 +134,7 @@ fn push_unknown_schema(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.esp_linkage.state != AutopilotEspLinkState::Conflicting + || snapshot + .conflicts + .iter() + .any(|conflict| conflict.kind == AutopilotConflictKind::EspSessionIdentifier) + { + return; + } + push( + findings, + finding( + "autopilot-esp-link-conflicting", + IntuneFindingSeverity::Error, + IntuneFindingConfidence::High, + "The Autopilot evidence matches more than one ESP session", + &format!( + "Explicit correlation keys bind this Autopilot evidence to {} different ESP \ + sessions ({}). A single Autopilot phase produces one session, so the session \ + identity is ambiguous and no completed handoff can be asserted.", + snapshot.esp_linkage.esp_session_ids.len(), + snapshot.esp_linkage.esp_session_ids.join(", ") + ), + &[ + "Re-collect both sides in one pass so the bundle describes a single provisioning \ + attempt." + .to_owned(), + "Confirm the ESP session facts were not assembled from two enrollments of the \ + same device." + .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; From dca21ee9d87401830e820e98b676a34f8847962e Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 09:49:15 -0400 Subject: [PATCH 03/14] fix(intune): stop collector placeholders classifying as a declared timezone windows_zone_re accepted any alphabetic run of three or more characters, so placeholders like 'unknown', 'not recorded', and 'Unavailable' classified as Declared. That upgraded time_basis to Utc, raised reduce_confidence to High, and allowed the TimeOnlyCandidate ESP join the module contract refuses when the timezone is unrecognizable (ADR-002: timestamp proximity alone never creates strong correlation). Every Windows time zone identifier ends in 'Time', so the shape check anchors on that suffix; UTC and offset forms were already covered by utc_offset_re. Also makes detect_document's malformed detail a stable reducer-authored sentence: serde_json does not guarantee its error Display output across releases, and the detail flows into golden-asserted finding summaries. Refs #362, PR #450. Co-Authored-By: Claude Fable 5 --- .../enrollment/windows/autopilot/sources.rs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs index 9df1167ef..4ec45e495 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs @@ -188,11 +188,14 @@ struct DocumentEnvelope { /// document produces a precise coverage fact rather than a parse error that /// looks like corruption. pub fn detect_document(content: &str) -> AutopilotDocumentDetection { + // The detail stays a stable reducer-authored sentence: it flows into + // golden-asserted finding summaries, and serde_json does not guarantee its + // error `Display` output across releases. let envelope: DocumentEnvelope = match serde_json::from_str(content) { Ok(envelope) => envelope, - Err(error) => { + Err(_) => { return AutopilotDocumentDetection::Malformed { - detail: format!("content is not a JSON object: {error}"), + detail: "content is not a JSON object".to_owned(), } } }; @@ -349,13 +352,18 @@ fn iana_zone_re() -> &'static Regex { /// /// 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)`. +/// downgraded the time basis. Every Windows time zone identifier ends in +/// `Time` (`W. Europe Standard Time`, `Coordinated Universal Time`); anchoring +/// on that suffix is what rejects collector placeholders such as `unknown` or +/// `not recorded`, which would otherwise pass as a declared timezone and let +/// `reduce_esp_linkage` offer a time-only candidate the module contract +/// forbids. `UTC` and offset forms are covered by `utc_offset_re`. Punctuation +/// beyond spaces, hyphens, and periods stays excluded, which 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") + Regex::new(r"^[A-Za-z][A-Za-z .\-]* Time$").expect("windows zone regex must compile") }) } @@ -490,10 +498,20 @@ mod tests { classify_timezone(Some("Pacific Standard Time")), AutopilotTimezoneState::Declared ); + assert_eq!( + classify_timezone(Some("W. Europe Standard Time")), + AutopilotTimezoneState::Declared + ); for junk in [ "Pacific Standard Time (device local)", "Pacific Standard Time?", "??", + // Collector placeholders: a plausible-looking word is not a + // declared timezone, and treating it as one raised confidence and + // enabled the time-only ESP candidate the contract forbids. + "unknown", + "not recorded", + "Unavailable", ] { assert_eq!( classify_timezone(Some(junk)), From 41ef6e3a4470cfaf3524551f1746e54f912425ce Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 09:49:29 -0400 Subject: [PATCH 04/14] fix(intune): normalize every whole-value mask in the Autopilot export The module contract promises every whole-value mask is computed over the trimmed, lowercased value, but four sites called stable_token on the raw value: the ESP matched keys, sensitive named-data values, and both conflict-value shapes. A sensitive value arriving in a different casing or with surrounding space therefore masked to a different token than the identity field it should visibly equal, destroying the cross-field correlation the projection exists to preserve (ADR-004: same-scope redaction preserves intended equality). All four sites now go through mask_value, which also owns the is_token idempotency check. Also corrects the opaque-blob doc comment (the regex bound is 40, not 32) and pins the deliberate HRESULT canonicalization in normalize.rs: a 64-bit sign-extended token derives its canonical 32-bit hex while the raw token survives verbatim, and a genuinely 64-bit value gains no fabricated 32-bit form. Refs #362, PR #450. Co-Authored-By: Claude Fable 5 --- .../enrollment/windows/autopilot/normalize.rs | 22 +++++++ .../enrollment/windows/autopilot/redaction.rs | 61 +++++++++++++------ 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs index 7b63a1db3..12c4b4bc1 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs @@ -295,6 +295,28 @@ mod tests { assert_eq!(code.decimal, Some(-2_145_647_638)); } + /// A 64-bit sign-extended HRESULT canonicalizes to its 32-bit form on + /// purpose. `0xFFFFFFFF80070002` is `0x80070002` printed through a 64-bit + /// register; the upper 32 bits are sign extension, not information. The + /// raw token always survives verbatim, and a genuinely 64-bit value that + /// is not a sign extension keeps its decimal and gains no fabricated + /// 32-bit hex. + #[test] + fn a_sign_extended_hresult_canonicalizes_to_its_32_bit_form() { + let code = error_code_from_token("0xFFFFFFFF80070002"); + assert_eq!(code.raw, "0xFFFFFFFF80070002"); + assert_eq!(code.decimal, Some(-2_147_024_894)); + assert_eq!(code.hex.as_deref(), Some("0x80070002")); + + let wide = error_code_from_token("0x1234567880070002"); + assert_eq!(wide.raw, "0x1234567880070002"); + assert_eq!(wide.decimal, Some(0x1234_5678_8007_0002)); + assert_eq!( + wide.hex, None, + "a non-sign-extended 64-bit value must not gain an invented 32-bit hex form" + ); + } + #[test] fn a_message_without_an_hresult_yields_no_error_code() { assert!(extract_error_code(Some("AutopilotRetrieveSettings succeeded.")).is_none()); diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs index 7b6dbe659..7a6bf2482 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs @@ -111,7 +111,7 @@ fn user_path_re() -> &'static Regex { /// 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 +/// Bounded at 40 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 { @@ -201,10 +201,11 @@ pub fn redacted_export_projection(snapshot: &AutopilotSnapshot) -> AutopilotSnap .collect(); } + // `mask_value` everywhere a whole value is masked: it performs the + // trim/lowercase normalization the module contract promises, so the same + // identifier masks identically whatever field or casing it arrived in. for key in &mut projected.esp_linkage.matched_keys { - if !is_token(&key.value) { - key.value = stable_token(VALUE_KIND, &key.value); - } + key.value = mask_value(&key.value); } for entry in &mut projected.coverage { @@ -230,9 +231,7 @@ fn redact_named_values(values: &mut [IntuneNamedValue]) { .iter() .any(|key| key.eq_ignore_ascii_case(&value.name)) { - if !is_token(&value.value) { - value.value = stable_token(VALUE_KIND, &value.value); - } + value.value = mask_value(&value.value); } else { value.value = redact_text(&value.value); } @@ -249,19 +248,9 @@ fn redact_conflict_value(value: &str) -> String { .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) - } + format!("{name}={}", mask_value(raw)) } + _ => mask_value(value), } } @@ -326,6 +315,40 @@ mod tests { ); } + /// Every whole-value masking site must normalize case and surrounding + /// space before hashing, or the same identifier arriving in two casings + /// would mask to two different tokens and destroy the very correlation the + /// projection exists to preserve. + #[test] + fn whole_value_masking_normalizes_case_and_space_at_every_site() { + let canonical = mask_value("abcdabcd-1234-5678-9012-abcdabcdabcd"); + + // Named-data values with a sensitive key. + let mut values = vec![IntuneNamedValue { + name: "entraDeviceId".to_owned(), + value: " ABCDABCD-1234-5678-9012-ABCDABCDABCD ".to_owned(), + }]; + redact_named_values(&mut values); + assert_eq!(values[0].value, canonical); + + // Conflict values, both shapes. + assert_eq!( + redact_conflict_value("entraDeviceId= ABCDABCD-1234-5678-9012-ABCDABCDABCD "), + format!("entraDeviceId={canonical}") + ); + assert_eq!( + redact_conflict_value(" ABCDABCD-1234-5678-9012-ABCDABCDABCD "), + canonical + ); + + // The identity-field path already goes through mask_value; the token + // must line up with all of the above. + assert_eq!( + redact_opt(&Some("Abcdabcd-1234-5678-9012-abcdabcdABCD".to_owned())), + Some(canonical) + ); + } + #[test] fn a_non_sensitive_conflict_key_still_masks_its_value() { // Falling through to the bare-value branch is deliberate: a conflict From cf399d4d0587be5f1a7c17ea804eee90f6ebefec Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 09:49:29 -0400 Subject: [PATCH 05/14] test(intune): adversarial coverage and safe golden regeneration for Autopilot New inline scenarios pin the hardened invariants: non-assessable success and identity records cannot prove progress or raise the phase (ADR-001), a Conflicting ESP linkage reduces to ContradictoryEvidence with an explaining finding (ADR-003), a schema-version-only unknown capture is explained, and the native-event input path derives its artifact from the event's own provenance. Fixture corrections, each documented in its expected.json assertions: - malformed-report-section now carries a correctly tagged report document with a mangled sections payload, so it exercises the report-payload contract instead of generic non-JSON rejection, and its golden no longer pins serde_json's unstable error text. - unknown-windows-schema-version's summary now names the unvalidated schema version alongside the build. - completed-without-esp-bundle's evidence comment no longer claims a happy path, and the user-driven manifest describes local-phase completion only; post-handoff state belongs to ESP. update_findings_golden is now #[ignore]d so it never rewrites goldens beside the tests reading the same files, and write_json goes through a temp file plus rename so a reader can never observe a truncated golden. Refs #362, PR #450. Co-Authored-By: Claude Fable 5 --- .../current/autopilot-events.json | 2 +- .../manifest.json | 2 +- .../current/autopilot-report.json | 9 +- .../malformed-report-section/expected.json | 5 +- .../malformed-report-section/manifest.json | 2 +- .../expected.json | 5 +- .../manifest.json | 2 +- .../tests/intune_windows_autopilot.rs | 379 +++++++++++++++++- 8 files changed, 386 insertions(+), 20 deletions(-) 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 index 5a01c4b7d..cae932a87 100644 --- 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 @@ -1,4 +1,4 @@ -{"_comment": "SYNTHETIC FIXTURE: user-driven Autopilot channel, happy path", +{"_comment": "SYNTHETIC FIXTURE: user-driven Autopilot channel reaching the handoff, with no ESP evidence captured", "autopilotDocument": "autopilot.events", "documentVersion": 1, "events": [ 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 index 0f27bbfdb..456ca9a49 100644 --- 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 @@ -21,7 +21,7 @@ "originalBasename": "autopilot-events.json", "sanitizedSourcePath": "SYNTHETIC://autopilot/autopilot-events.json", "relativePath": "evidence/autopilot-channel/current/autopilot-events.json", - "bytesCopied": 10487, + "bytesCopied": 10527, "capturedUtc": "2026-07-31T09:30:00Z", "rotation": { "kind": "current", 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 index 9d186499c..42a631a68 100644 --- 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 @@ -1,4 +1,5 @@ - - - - Value { + json!({ + "context": { + "evidenceRef": { "evidenceId": evidence_id, "sourceArtifactId": artifact_id }, + "provenance": { + "sourceKind": "eventLog", "sourceArtifactId": artifact_id, + "filePath": null, "lineNumber": null, "recordNumber": record, + "registry": null, "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "public", + "parseState": parse_state, + "accessState": access_state + }, + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", + "eventId": event_id, + "level": "information", + "task": null, "keywords": null, "recordId": record, "activityId": null, + "namedData": named_data, + "message": message + }) +} + +fn synthetic_source(artifact_id: &str, family: &str, document: &Value) -> AutopilotSourceInput { + AutopilotSourceInput { + artifact_id: artifact_id.to_owned(), + family: family.to_owned(), + capture_state: AutopilotCaptureState::Captured, + original_basename: Some(format!("{artifact_id}.json")), + sanitized_source_path: None, + content: Some(document.to_string()), + ..AutopilotSourceInput::default() + } +} + +fn synthetic_bundle(sources: Vec) -> AutopilotBundleInput { + AutopilotBundleInput { + generated_at_utc: "2026-07-31T09:30:00Z".to_owned(), + capture: AutopilotCaptureMetadata { + collected_at_utc: Some("2026-07-31T09:30:00Z".to_owned()), + windows_build: Some("10.0.26100.2314".to_owned()), + autopilot_schema_version: Some("1".to_owned()), + timezone: Some("UTC".to_owned()), + }, + sources, + events: Vec::new(), + } +} + +/// The espHandoff report section every ADR-001 test below pairs with the +/// success events, so a Completed claim is one gate away if the reducer honors +/// a non-assessable record. +fn esp_handoff_report(artifact_id: &str) -> Value { + json!({ + "autopilotDocument": "autopilot.mdmDiagnosticsReport", + "documentVersion": 1, + "sections": [{ + "context": { + "evidenceRef": { "evidenceId": "hardening-esp-handoff", "sourceArtifactId": artifact_id }, + "provenance": { + "sourceKind": "diagnosticReport", "sourceArtifactId": artifact_id, + "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": "espHandoff", + "kind": "espHandoff", + "outcome": "observed", + "error": null, + "values": [], + "message": "Control passed to the Enrollment Status Page." + }] + }) +} + +/// ADR-001: non-assessable evidence cannot produce a terminal conclusion. +/// A capped success record must not set `retrieved`/`applied`, and must not +/// combine with an observed handoff into `Completed`. +#[test] +fn non_assessable_success_records_cannot_prove_profile_progress() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "gate-e1", "gated-channel", 1, 161, "capped", "parsed", + json!([]), "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "gate-e2", "gated-channel", 2, 153, "available", "raw", + json!([]), + "AutopilotManager reported the state changed from ProfileState_Available to ProfileState_Provisioned.", + ), + ] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![ + synthetic_source("gated-channel", "autopilotEvents", &events), + synthetic_source("gated-report", "mdmReport", &esp_handoff_report("gated-report")), + ])); + + assert!( + !snapshot.profile.retrieved, + "a capped record must not prove retrieval" + ); + assert!( + !snapshot.profile.applied, + "an unparsed record must not prove application" + ); + assert_ne!( + snapshot.outcome, + AutopilotOutcome::Completed, + "non-assessable success evidence must never reach a terminal success" + ); +} + +/// ADR-001, same class: a non-assessable record carrying identity keys must +/// not inflate the phase to `IdentityObserved` through the evidence list. +#[test] +fn non_assessable_identity_records_cannot_raise_the_phase() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [synthetic_event( + "gate-i1", "gated-channel", 1, 161, "capped", "parsed", + json!([{ "name": "serialNumber", "value": "SYNTH-5CD1234ABC" }]), + "AutopilotManager retrieve settings succeeded.", + )] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![synthetic_source( + "gated-channel", + "autopilotEvents", + &events, + )])); + + assert!( + snapshot.identity.evidence.is_empty(), + "a non-assessable record may not be cited as identity evidence" + ); + assert_eq!( + snapshot.phase, + AutopilotPhase::NoEvidence, + "an unreadable record alone must not raise the phase" + ); +} + +/// ADR-003: unresolved authoritative contradictions stay conservative. When +/// distinct explicit keys resolve to distinct ESP sessions, the linkage is +/// `Conflicting`; that must surface as `ContradictoryEvidence` with a finding, +/// never as `Completed`. +#[test] +fn a_conflicting_esp_linkage_cannot_report_completed() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "link-e1", "linked-channel", 1, 161, "available", "parsed", + json!([{ "name": "enrollmentId", "value": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }]), + "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "link-e2", "linked-channel", 2, 153, "available", "parsed", + json!([{ "name": "correlationId", "value": "99999999-8888-7777-6666-555555555555" }]), + "AutopilotManager reported the state changed from ProfileState_Unknown to ProfileState_Available.", + ), + ] + }); + let sessions = json!({ + "autopilotDocument": "autopilot.espSession", + "documentVersion": 1, + "sessions": [ + { + "sessionId": "esp-session-a", + "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-a", "sourceArtifactId": "esp-session-facts" } + }, + { + "sessionId": "esp-session-b", + "enrollmentId": null, + "correlationId": "99999999-8888-7777-6666-555555555555", + "activityId": null, "entraDeviceId": null, "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:25Z", "phase": "deviceSetup", + "evidence": { "evidenceId": "esp-b", "sourceArtifactId": "esp-session-facts" } + } + ] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![ + synthetic_source("linked-channel", "autopilotEvents", &events), + synthetic_source("linked-report", "mdmReport", &esp_handoff_report("linked-report")), + synthetic_source("esp-session-facts", "espSession", &sessions), + ])); + + assert_eq!(snapshot.esp_linkage.state, AutopilotEspLinkState::Conflicting); + assert_eq!( + snapshot.outcome, + AutopilotOutcome::ContradictoryEvidence, + "an ambiguous session identity must not be reported as a completed handoff" + ); + assert!( + snapshot + .findings + .iter() + .any(|finding| finding.finding_id == "autopilot-esp-link-conflicting"), + "the conflicting linkage must be explained by a finding, got {:?}", + snapshot + .findings + .iter() + .map(|finding| finding.finding_id.as_str()) + .collect::>() + ); + assert!( + snapshot.findings_are_evidence_backed(), + "the linkage-conflict finding must cite evidence" + ); +} + +/// A capture that declares only an unvalidated Autopilot schema version (no +/// Windows build at all) still refuses terminal semantics, and that refusal +/// must be explained by the unknown-schema finding rather than left silent. +#[test] +fn a_schema_version_only_unknown_schema_is_explained_by_a_finding() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [synthetic_event( + "schema-e1", "schema-channel", 1, 161, "available", "parsed", + json!([]), "AutopilotManager retrieve settings succeeded.", + )] + }); + let mut bundle = synthetic_bundle(vec![synthetic_source( + "schema-channel", + "autopilotEvents", + &events, + )]); + bundle.capture.windows_build = None; + bundle.capture.autopilot_schema_version = Some("3".to_owned()); + + let snapshot = reduce_autopilot_bundle(&bundle); + assert_eq!(snapshot.outcome, AutopilotOutcome::UnknownSchema); + assert!( + snapshot + .findings + .iter() + .any(|finding| finding.finding_id == "autopilot-unknown-schema"), + "withheld terminal semantics must be explained, got {:?}", + snapshot + .findings + .iter() + .map(|finding| finding.finding_id.as_str()) + .collect::>() + ); +} + +/// The native-event input path: events supplied directly on the bundle derive +/// their artifact from the event's own provenance and produce observations +/// without any document or source coverage entry. +#[test] +fn native_events_are_absorbed_with_their_provenance_artifact() { + use cmtraceopen_parser::intune::evidence::{ + IntuneAccessState, IntuneEvidenceRef, IntuneObservationContext, IntuneParseState, + IntuneProvenance, IntuneSensitivity, IntuneSourceKind, + }; + use cmtraceopen_parser::intune::normalized::{NormalizedEventLevel, NormalizedWindowsEvent}; + + let mut bundle = synthetic_bundle(Vec::new()); + bundle.events.push(NormalizedWindowsEvent { + context: IntuneObservationContext { + evidence_ref: IntuneEvidenceRef { + evidence_id: "native-e1".to_owned(), + source_artifact_id: "native-adapter".to_owned(), + }, + provenance: IntuneProvenance { + source_kind: IntuneSourceKind::EventLog, + source_artifact_id: "native-adapter".to_owned(), + file_path: None, + line_number: None, + record_number: Some(1), + registry: None, + event: None, + }, + source_timestamp: None, + observed_at_utc: "2026-07-31T09:30:00Z".to_owned(), + sensitivity: IntuneSensitivity::Public, + parse_state: IntuneParseState::Parsed, + access_state: IntuneAccessState::Available, + }, + channel: "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot".to_owned(), + provider: "Microsoft-Windows-ModernDeployment-Diagnostics-Provider".to_owned(), + event_id: 161, + level: NormalizedEventLevel::Information, + task: None, + keywords: None, + record_id: Some(1), + activity_id: None, + event_version: None, + named_data: Vec::new(), + message: Some("AutopilotManager retrieve settings succeeded.".to_owned()), + }); + + let snapshot = reduce_autopilot_bundle(&bundle); + let observation = snapshot + .observations + .iter() + .find(|observation| observation.observation_id == "native-e1") + .expect("the native event must become an observation under its own evidence id"); + assert_eq!( + observation.context.evidence_ref.source_artifact_id, + "native-adapter", + "the artifact must come from the event's own provenance" + ); + assert!( + snapshot.documents.is_empty(), + "a native event is not a document" + ); + assert!( + snapshot.coverage.is_empty(), + "coverage entries describe supplied sources; a native event has none" + ); + assert!(snapshot.profile.retrieved); +} + // ── 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. +/// Marked `#[ignore]` so it never runs beside the readers of the same files: +/// the harness runs tests in parallel threads, and rewriting an +/// `expected.json` while another test reads it would race. Regenerate with +/// `UPDATE_AUTOPILOT_FINDINGS=1 cargo test --test intune_windows_autopilot -- \ +/// --ignored update_findings_golden`, then review the diff. The rewrite +/// touches the `findings` key and nothing else, so the hand-written semantic +/// expectations survive and keep cross-checking the regenerated goldens. #[test] +#[ignore = "rewrites goldens; run alone via -- --ignored update_findings_golden"] fn update_findings_golden() { if std::env::var("UPDATE_AUTOPILOT_FINDINGS").is_err() { return; @@ -640,8 +998,13 @@ fn update_findings_golden() { } } +/// Write through a temporary file plus rename so no concurrent reader can ever +/// observe a truncated golden. `std::fs::write` truncates before it writes. 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())); + let temporary = path.with_extension("json.tmp"); + std::fs::write(&temporary, text) + .unwrap_or_else(|error| panic!("{} is writable: {error}", temporary.display())); + std::fs::rename(&temporary, path) + .unwrap_or_else(|error| panic!("{} is replaceable: {error}", path.display())); } From a4d0daeb3ab4958eec67913e6da6f82f5ff91aba Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 10:42:32 -0400 Subject: [PATCH 06/14] fix(intune): let a recorded failure on a non-assessable section block Autopilot success The assessability gate in sections_of hid every non-assessable report section from every consumer, including the Failed probes in reduce_outcome. A capped or unparsed profileApplication section whose outcome was failed became invisible, and sibling assessable evidence could then complete the enrollment at high confidence over a failure that was on record. The gate is now direction-aware (ADR-001 cuts both ways): sections_of still admits only assessable sections, so nothing non-assessable can prove progress or a terminal cause, and the new recorded_non_assessable_failure_sections iterator carries an explicitly recorded Failed/Mismatch outcome to the success branch of reduce_outcome, which then returns InsufficientEvidence instead of Completed/HandoffReachedEspEvidenceMissing. The recorded failure is never silent: the new autopilot-non-assessable-failure-recorded finding (low confidence, warning) cites the section and asks for a readable re-collection. NotFound and Retrying stay gated in both directions on purpose: those are absence or transient statements, and absence in a partial capture proves nothing. Co-Authored-By: Claude Fable 5 --- .../enrollment/windows/autopilot/reducer.rs | 53 ++++++ .../enrollment/windows/autopilot/rules.rs | 73 +++++++- .../tests/intune_windows_autopilot.rs | 160 ++++++++++++++++++ 3 files changed, 284 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs index aabf1ea64..562342fec 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs @@ -625,6 +625,13 @@ fn has_signal(observations: &[AutopilotObservation], signal: AutopilotSignal) -> /// Iterate the assessable sections of one kind. The gate lives here so no /// individual caller can forget it. +/// +/// This gate is deliberately direction-aware in combination with +/// [`recorded_non_assessable_failure_sections`]: everything reached through +/// *this* iterator may prove progress, success, or a terminal cause, so it +/// admits assessable sections only. A non-assessable section that explicitly +/// recorded a failure is not silently discarded with it -- see the companion +/// iterator below. fn sections_of<'a>( sections: &'a [AutopilotReportSection], kind: &AutopilotSectionKind, @@ -636,6 +643,37 @@ fn sections_of<'a>( .filter(move |section| section.kind == kind) } +/// Iterate the *non-assessable* sections that explicitly recorded a failure or +/// mismatch. +/// +/// ADR-001 cuts both ways. A capped or unparsed section cannot PROVE anything: +/// not progress, not success, and not a terminal failure either -- which is why +/// [`sections_of`] excludes it and why the reducer never turns one of these +/// into `ProfileRetrievalFailure`/`ProfileApplicationFailure`. But `Capped` +/// means "absence proves nothing", not "presence proves nothing": a failure +/// that IS on the record must still BLOCK a success conclusion, or sibling +/// assessable evidence would complete the enrollment at high confidence over a +/// recorded failure. Consumers of this iterator may only move the result in +/// the conservative direction. +/// +/// `Failed` and `Mismatch` are the explicit negative recordings. `NotFound` +/// and `Retrying` stay out on purpose: those are statements of absence or of a +/// transient state, and an absence statement from a partially captured view +/// proves nothing in either direction. +fn recorded_non_assessable_failure_sections( + sections: &[AutopilotReportSection], +) -> impl Iterator { + sections + .iter() + .filter(|section| !is_assessable_section(section)) + .filter(|section| { + matches!( + section.outcome, + AutopilotSectionOutcome::Failed | AutopilotSectionOutcome::Mismatch + ) + }) +} + /// Collect the distinct values a named key takes across every source. /// /// Returned sorted and deduplicated so that "more than one value" is a @@ -1394,6 +1432,21 @@ fn reduce_outcome( return AutopilotOutcome::ProfileApplicationFailure; } if profile.applied && handoff.esp_observed { + // Direction-aware assessability gate (ADR-001). A non-assessable + // section that explicitly recorded a failure or mismatch can prove + // nothing -- including the failure itself, so the terminal failure + // outcomes above stay reserved for assessable records -- but it must + // still block a success claim. `InsufficientEvidence` is the honest + // reduction: the bundle cannot support success while a failure is on + // record, and cannot prove the failure while its record is + // non-assessable. The recorded failure is surfaced by its own finding + // rather than silently swallowed. + if recorded_non_assessable_failure_sections(sections) + .next() + .is_some() + { + return AutopilotOutcome::InsufficientEvidence; + } return match esp_linkage.state { AutopilotEspLinkState::EvidenceMissing => { AutopilotOutcome::HandoffReachedEspEvidenceMissing diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs index c1d1f2c09..33b8f2ab0 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs @@ -10,8 +10,8 @@ //! the whole MDM bundle" is not an answer when one event ID would do. use crate::intune::evidence::{ - IntuneArtifactStatus, IntuneEvidenceRef, IntuneFinding, IntuneFindingConfidence, - IntuneFindingSeverity, IntuneParseState, + IntuneAccessState, IntuneArtifactStatus, IntuneEvidenceRef, IntuneFinding, + IntuneFindingConfidence, IntuneFindingSeverity, IntuneParseState, }; use super::models::*; @@ -43,6 +43,7 @@ pub fn derive_findings(snapshot: &AutopilotSnapshot) -> Vec { push_esp_linked(snapshot, &mut findings); push_unreliable_timestamps(snapshot, &mut findings); push_coverage_gaps(snapshot, &mut findings); + push_non_assessable_failure_recorded(snapshot, &mut findings); push_unclassified_records(snapshot, &mut findings); push_completed(snapshot, &mut findings); @@ -703,6 +704,74 @@ fn push_coverage_gaps(snapshot: &AutopilotSnapshot, findings: &mut Vec, +) { + let recorded = snapshot + .observations + .iter() + .filter(|observation| observation.signal == AutopilotSignal::ReportSection) + .filter(|observation| { + observation.context.access_state != IntuneAccessState::Available + || observation.context.parse_state != IntuneParseState::Parsed + }) + .filter(|observation| { + matches!( + observation.section_outcome, + Some(AutopilotSectionOutcome::Failed) | Some(AutopilotSectionOutcome::Mismatch) + ) + }) + .collect::>(); + if recorded.is_empty() { + return; + } + push( + findings, + finding( + "autopilot-non-assessable-failure-recorded", + IntuneFindingSeverity::Warning, + // Low on purpose: non-assessable evidence cannot support a + // confident conclusion in either direction (ADR-001). The finding + // exists so the recorded failure blocks success visibly instead of + // silently. + IntuneFindingConfidence::Low, + "A section that could not be fully read recorded a failure", + &format!( + "{} report section(s) whose own capture or parse state is not assessable \ + explicitly recorded a failed or mismatched outcome. That record cannot prove the \ + failure, but it blocks any success conclusion until the section is re-collected \ + in a readable form.", + recorded.len() + ), + &[ + "Re-collect the cited report section(s), complete and readable, before trusting \ + any success signal from this bundle." + .to_owned(), + "Compare the re-collected section's outcome against the sibling event evidence." + .to_owned(), + ], + normalized_evidence( + recorded + .iter() + .map(|observation| observation.evidence_ref()) + .collect(), + ), + Vec::new(), + ), + ); +} + fn push_unclassified_records(snapshot: &AutopilotSnapshot, findings: &mut Vec) { if snapshot.unclassified_observation_ids.is_empty() { return; diff --git a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs index d3bde8c89..997061324 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs @@ -760,6 +760,166 @@ fn non_assessable_success_records_cannot_prove_profile_progress() { ); } +/// One report section as a JSON fragment, with its own declared context. +#[allow(clippy::too_many_arguments)] +fn synthetic_section( + evidence_id: &str, + artifact_id: &str, + section_id: &str, + kind: &str, + outcome: &str, + access_state: &str, + parse_state: &str, + message: &str, +) -> Value { + json!({ + "context": { + "evidenceRef": { "evidenceId": evidence_id, "sourceArtifactId": artifact_id }, + "provenance": { + "sourceKind": "diagnosticReport", "sourceArtifactId": artifact_id, + "filePath": null, "lineNumber": null, "recordNumber": 1, + "registry": null, "event": null + }, + "sourceTimestamp": null, + "observedAtUtc": "2026-07-31T09:30:00Z", + "sensitivity": "sensitive", + "parseState": parse_state, + "accessState": access_state + }, + "sectionId": section_id, + "kind": kind, + "outcome": outcome, + "error": null, + "values": [], + "message": message + }) +} + +/// ADR-001, direction-aware: a non-assessable section can never PROVE +/// progress, but one that explicitly RECORDED a failure must still block +/// success. Hiding it entirely would let sibling assessable evidence complete +/// the enrollment at high confidence over a failure that is on record. +/// +/// This is the capped-failed-section fixture the corpus lacked: an assessable +/// success path (events 161 + 153, an observed espHandoff section) plus a +/// capped `profileApplication` section whose outcome is `failed`. +#[test] +fn a_recorded_failure_on_a_non_assessable_section_blocks_success() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "blocked-e1", "blocked-channel", 1, 161, "available", "parsed", + json!([]), "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "blocked-e2", "blocked-channel", 2, 153, "available", "parsed", + json!([]), + "AutopilotManager reported the state changed from ProfileState_Available to ProfileState_Provisioned.", + ), + ] + }); + let report = json!({ + "autopilotDocument": "autopilot.mdmDiagnosticsReport", + "documentVersion": 1, + "sections": [ + synthetic_section( + "blocked-handoff", "blocked-report", "espHandoff", "espHandoff", + "observed", "available", "parsed", + "Control passed to the Enrollment Status Page.", + ), + synthetic_section( + "blocked-failure", "blocked-report", "profileApplication", "profileApplication", + "failed", "capped", "parsed", + "Failed to set the Autopilot profile as available.", + ), + ] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![ + synthetic_source("blocked-channel", "autopilotEvents", &events), + synthetic_source("blocked-report", "mdmReport", &report), + ])); + + assert_ne!( + snapshot.outcome, + AutopilotOutcome::Completed, + "a recorded failure on a capped section must block a completed outcome" + ); + assert_eq!( + snapshot.outcome, + AutopilotOutcome::InsufficientEvidence, + "non-assessable evidence proves neither the failure nor the success (ADR-001)" + ); + assert_ne!( + wire(&snapshot.confidence), + json!("high"), + "a success-path bundle carrying a recorded failure may not be presented at high confidence" + ); + assert!( + !snapshot.profile.applied || snapshot.outcome != AutopilotOutcome::Completed, + "assessable progress may survive, but never as a completed enrollment" + ); + let finding = snapshot + .findings + .iter() + .find(|finding| finding.finding_id == "autopilot-non-assessable-failure-recorded") + .unwrap_or_else(|| { + panic!( + "the recorded-but-unassessable failure must never be silent, got {:?}", + snapshot + .findings + .iter() + .map(|finding| finding.finding_id.as_str()) + .collect::>() + ) + }); + assert_eq!(wire(&finding.confidence), json!("low")); + assert!( + finding + .evidence + .iter() + .any(|evidence| evidence.evidence_id == "blocked-failure"), + "the finding must cite the capped section that recorded the failure" + ); + assert!(snapshot.findings_are_evidence_backed()); +} + +/// The same gate must not fire when the failed section is assessable: an +/// assessable failure is the real terminal outcome, not a blocked success. +#[test] +fn an_assessable_failed_section_still_produces_the_terminal_failure() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [synthetic_event( + "term-e1", "term-channel", 1, 161, "available", "parsed", + json!([]), "AutopilotManager retrieve settings succeeded.", + )] + }); + let report = json!({ + "autopilotDocument": "autopilot.mdmDiagnosticsReport", + "documentVersion": 1, + "sections": [synthetic_section( + "term-failure", "term-report", "profileApplication", "profileApplication", + "failed", "available", "parsed", + "Failed to set the Autopilot profile as available.", + )] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![ + synthetic_source("term-channel", "autopilotEvents", &events), + synthetic_source("term-report", "mdmReport", &report), + ])); + assert_eq!(snapshot.outcome, AutopilotOutcome::ProfileApplicationFailure); + assert!( + !snapshot + .findings + .iter() + .any(|finding| finding.finding_id == "autopilot-non-assessable-failure-recorded"), + "an assessable failure is not a non-assessable one" + ); +} + /// ADR-001, same class: a non-assessable record carrying identity keys must /// not inflate the phase to `IdentityObserved` through the evidence list. #[test] From 6c031f9236f12ec40f34f115703cd1422232bd60 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 10:45:19 -0400 Subject: [PATCH 07/14] fix(intune): keep non-assessable correlation keys for ESP conflict detection autopilot_keys gated every key on assessability, so a key carried only by a capped observation vanished entirely. When that key was the one binding a second ESP session, matched_sessions shrank from two to one and Conflicting collapsed into Linked into Completed: a non-assessable record silently upgrading the conclusion. Keys are the one input where more evidence is more conservative: every additional key can only widen the entangled-session set. So the key set is now split by AutopilotKeyGate. Proving keys (assessable only) are still the only ones that can produce Linked and the Completed outcome behind it; Detecting keys (all observations) feed the multi-session conflict check, which runs before any positive linkage and returns Conflicting naming every detected session. A linkage whose only key rides a non-assessable observation stays NotObserved/TimeOnlyCandidate, pinned by its own test. Co-Authored-By: Claude Fable 5 --- .../enrollment/windows/autopilot/reducer.rs | 96 +++++++++++--- .../tests/intune_windows_autopilot.rs | 125 ++++++++++++++++++ 2 files changed, 201 insertions(+), 20 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs index 562342fec..291c49cf9 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs @@ -1133,11 +1133,32 @@ fn session_keys(session: &AutopilotEspSessionFact) -> Vec BTreeSet { +fn autopilot_keys( + observations: &[AutopilotObservation], + gate: AutopilotKeyGate, +) -> BTreeSet { const NAMED_KEYS: [(&str, AutopilotCorrelationKeyKind); 5] = [ ("enrollmentId", AutopilotCorrelationKeyKind::EnrollmentId), ("correlationId", AutopilotCorrelationKeyKind::CorrelationId), @@ -1151,7 +1172,8 @@ fn autopilot_keys(observations: &[AutopilotObservation]) -> BTreeSet 1 { + for observation in observations + .iter() + .filter(|observation| observation.signal != AutopilotSignal::EspSessionFact) + { + // Non-assessable carriers are cited too: a capped record is the + // very evidence of the entanglement this state reports. Citing is + // not proving; the state it supports is the conservative one. + if session_key_values(&detected_keys) + .any(|value| observation_mentions(observation, value)) + { + detection_evidence.push(observation.evidence_ref()); + } + } + return AutopilotEspLinkage { + state: AutopilotEspLinkState::Conflicting, + confidence: IntuneFindingConfidence::High, + matched_keys: detected_keys.into_iter().collect(), + esp_session_ids: detected_sessions.into_iter().collect(), + evidence: normalized_evidence(detection_evidence), + }; + } + 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 @@ -1280,19 +1346,9 @@ fn reduce_esp_linkage( } } - // 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), - }; - } - + // `matched_sessions` is a subset of `detected_sessions`, and the multi- + // session case already returned Conflicting above, so exactly one session + // remains here and it was matched by an assessable key. AutopilotEspLinkage { state: AutopilotEspLinkState::Linked, confidence: IntuneFindingConfidence::High, diff --git a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs index 997061324..10cf925e6 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs @@ -1024,6 +1024,131 @@ fn a_conflicting_esp_linkage_cannot_report_completed() { ); } +/// ADR-003 via ADR-001, the conservative direction for correlation keys: a key +/// carried only by a non-assessable observation must still be USED to DETECT a +/// session-identity conflict. Dropping it can shrink the matched-session set +/// from two to one and collapse Conflicting into Linked into Completed -- +/// exactly the silent upgrade the assessability gate exists to prevent. +/// +/// This is the capped-key-carrying-observation fixture the corpus lacked: the +/// first ESP session matches an assessable key, the second matches only a key +/// on a capped observation. +#[test] +fn a_key_on_a_capped_observation_still_detects_a_second_esp_session() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "cap-e1", "cap-channel", 1, 161, "available", "parsed", + json!([{ "name": "enrollmentId", "value": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }]), + "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "cap-e2", "cap-channel", 2, 153, "available", "parsed", + json!([]), + "AutopilotManager reported the state changed from ProfileState_Available to ProfileState_Provisioned.", + ), + synthetic_event( + "cap-e3", "cap-channel", 3, 161, "capped", "parsed", + json!([{ "name": "correlationId", "value": "99999999-8888-7777-6666-555555555555" }]), + "AutopilotManager retrieve settings succeeded.", + ), + ] + }); + let sessions = json!({ + "autopilotDocument": "autopilot.espSession", + "documentVersion": 1, + "sessions": [ + { + "sessionId": "esp-session-a", + "enrollmentId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "correlationId": null, "activityId": null, + "entraDeviceId": null, "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:20Z", "phase": "deviceSetup", + "evidence": { "evidenceId": "cap-esp-a", "sourceArtifactId": "esp-session-facts" } + }, + { + "sessionId": "esp-session-b", + "enrollmentId": null, + "correlationId": "99999999-8888-7777-6666-555555555555", + "activityId": null, "entraDeviceId": null, "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:25Z", "phase": "deviceSetup", + "evidence": { "evidenceId": "cap-esp-b", "sourceArtifactId": "esp-session-facts" } + } + ] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![ + synthetic_source("cap-channel", "autopilotEvents", &events), + synthetic_source("cap-report", "mdmReport", &esp_handoff_report("cap-report")), + synthetic_source("esp-session-facts", "espSession", &sessions), + ])); + + assert_eq!( + snapshot.esp_linkage.state, + AutopilotEspLinkState::Conflicting, + "the capped key's session must still count toward conflict detection" + ); + assert!( + snapshot + .esp_linkage + .esp_session_ids + .contains(&"esp-session-b".to_owned()), + "the session detected through the capped key must be named" + ); + assert_ne!( + snapshot.outcome, + AutopilotOutcome::Completed, + "dropping the capped key must not collapse Conflicting into Completed" + ); + assert_eq!(snapshot.outcome, AutopilotOutcome::ContradictoryEvidence); + assert!(snapshot.findings_are_evidence_backed()); +} + +/// The other half of the same rule: a linkage whose ONLY explicit key rides a +/// non-assessable observation may not upgrade to a confident Linked. Detection +/// may widen (conservative); proof may not. +#[test] +fn a_non_assessable_only_key_match_cannot_upgrade_to_linked() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "solo-e1", "solo-channel", 1, 161, "available", "parsed", + json!([]), "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "solo-e2", "solo-channel", 2, 161, "capped", "parsed", + json!([{ "name": "enrollmentId", "value": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }]), + "AutopilotManager retrieve settings succeeded.", + ), + ] + }); + let sessions = json!({ + "autopilotDocument": "autopilot.espSession", + "documentVersion": 1, + "sessions": [{ + "sessionId": "esp-session-a", + "enrollmentId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "correlationId": null, "activityId": null, + "entraDeviceId": null, "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:20Z", "phase": "deviceSetup", + "evidence": { "evidenceId": "solo-esp-a", "sourceArtifactId": "esp-session-facts" } + }] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![ + synthetic_source("solo-channel", "autopilotEvents", &events), + synthetic_source("esp-session-facts", "espSession", &sessions), + ])); + + assert_ne!( + snapshot.esp_linkage.state, + AutopilotEspLinkState::Linked, + "a key readable only from a capped record may not prove a link" + ); +} + /// A capture that declares only an unvalidated Autopilot schema version (no /// Windows build at all) still refuses terminal semantics, and that refusal /// must be explained by the unknown-schema finding rather than left silent. From cca27db8b2a35f206a5024452e9f784749eb5ee6 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 10:45:42 -0400 Subject: [PATCH 08/14] test(intune): pin the matched_keys masking site as case-normalizing on its own The masking loop at the matched_keys site of the redacted export was behaviorally a no-op because autopilot_keys lowercases every key value first, and nothing tested either half of that coincidence. Both halves are now a contract: the reducer hands the projection lowercase values, and the projection masks a mixed-case value of the same key to the same token even though the reducer never produces one. Co-Authored-By: Claude Fable 5 --- .../tests/intune_windows_autopilot.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs index 10cf925e6..50d0d3974 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs @@ -484,6 +484,45 @@ fn the_redacted_export_removes_identity_while_preserving_correlation() { ); } +/// The `matched_keys` masking site in `redacted_export_projection` must +/// normalize case on its own, not by riding on `autopilot_keys` happening to +/// lowercase first. Both halves of that contract are pinned here: the reducer +/// hands the projection lowercase key values, and the projection would still +/// mask a mixed-case value to the same token if it ever received one. +#[test] +fn matched_key_masking_normalizes_case_independently_of_the_reducer() { + let snapshot = reduce_autopilot_bundle(&bundle("matching-autopilot-and-esp-session")); + let raw = snapshot.esp_linkage.matched_keys[0].value.clone(); + + // Contract half 1: reducer-produced key values are already lowercased. + assert_eq!( + raw, + raw.to_ascii_lowercase(), + "autopilot_keys must lowercase key values before they reach the snapshot" + ); + + let lower_token = redacted_export_projection(&snapshot).esp_linkage.matched_keys[0] + .value + .clone(); + assert!( + lower_token.starts_with("[redacted:"), + "the matched key must be masked, got {lower_token}" + ); + + // Contract half 2: the masking loop normalizes on its own. Feed it the + // same key in a casing the reducer never produces and the token must not + // change -- otherwise the loop is only correct by coincidence. + let mut mixed = snapshot.clone(); + mixed.esp_linkage.matched_keys[0].value = raw.to_ascii_uppercase(); + let upper_token = redacted_export_projection(&mixed).esp_linkage.matched_keys[0] + .value + .clone(); + assert_eq!( + lower_token, upper_token, + "the same identifier in two casings must mask to one token at the matched_keys site" + ); +} + // ── Cross-cutting contract ────────────────────────────────────────────────── #[test] From 5ce8e2e5a137cd32c757c949df26b8abc19f685c Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 10:49:05 -0400 Subject: [PATCH 09/14] fix(intune): close the remaining Autopilot review should-fixes Four coordinated corrections from the PR #531 consolidated review: - distinct_values now groups case-insensitively with a deterministic representative casing (lexicographically smallest sighting). Serials and GUIDs are case-insensitive identities, and the redacted export masks the trimmed lowercased value, so a case-only difference exported as '2 distinct values' over two identical tokens -- a conclusion that changed under redaction (ADR-004). A case-only difference is no longer a conflict; genuinely distinct values still are, both pinned. - The shared-identifier next_evidence_request now survives the time gate: it is keyed on 'ESP facts supplied but not explicitly linked' (NotObserved or TimeOnlyCandidate) instead of on TimeOnlyCandidate alone, so narrowing the assessable overlap window can no longer erase the one step that advances the diagnosis. - classify_timezone: the windows_zone_re comment no longer claims every Windows zone id ends in 'Time' (UTC/UTC+12 are registry ids too, and localized StandardNames only degrade conservatively), and a small case-insensitive placeholder denylist (Local Time, Device Local Time, System Time) closes the placeholders the suffix anchor let through. - push_esp_link_conflicting acknowledges the legitimate two-real- sessions case: a reimaged or re-enrolled device provisions more than once, so the finding now says which situation the reader may be in and recommends per-attempt analysis before re-collection. Also appends the 153/172 application-evidence reasoning to the matching-autopilot-and-esp-session golden's assertions, the standard the PR sets for contested goldens. Co-Authored-By: Claude Fable 5 --- .../enrollment/windows/autopilot/reducer.rs | 46 +++++-- .../enrollment/windows/autopilot/rules.rs | 15 ++- .../enrollment/windows/autopilot/sources.rs | 38 ++++-- .../expected.json | 3 +- .../tests/intune_windows_autopilot.rs | 122 ++++++++++++++++++ 5 files changed, 198 insertions(+), 26 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs index 291c49cf9..b5c018bc5 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs @@ -108,7 +108,12 @@ pub fn reduce_autopilot_bundle(bundle: &AutopilotBundleInput) -> AutopilotSnapsh &ingest.sections, ); let confidence = reduce_confidence(outcome, capture_validated, time_basis, &coverage); - let next_evidence_requests = next_evidence_requests(outcome, &esp_linkage, &coverage); + let next_evidence_requests = next_evidence_requests( + outcome, + &esp_linkage, + !ingest.esp_sessions.is_empty(), + &coverage, + ); let unclassified_observation_ids = observations .iter() @@ -678,11 +683,21 @@ fn recorded_non_assessable_failure_sections( /// /// Returned sorted and deduplicated so that "more than one value" is a /// reproducible fact rather than an artifact of iteration order. +/// +/// Distinctness is case-insensitive: the identifiers this feeds -- serials, +/// GUIDs, tenant domains, device names -- are case-insensitive identities on +/// Windows, and the redacted export masks the trimmed, lowercased value +/// (see the redaction module contract). Treating two casings as two values +/// produced a conflict whose export said "2 distinct values" over two +/// identical tokens, changing the conclusion under redaction (ADR-004). Each +/// group is keyed by a deterministic representative casing -- the +/// lexicographically smallest sighting -- so permuting the input cannot change +/// the result (ADR-003). fn distinct_values( observations: &[AutopilotObservation], key: &str, ) -> BTreeMap> { - let mut values: BTreeMap> = BTreeMap::new(); + let mut groups: BTreeMap)> = BTreeMap::new(); for observation in observations.iter().filter(|obs| is_assessable(obs)) { let Some(value) = observation.named(key) else { continue; @@ -691,12 +706,15 @@ fn distinct_values( if value.is_empty() { continue; } - values - .entry(value.to_owned()) - .or_default() - .push(observation.evidence_ref()); + let group = groups + .entry(value.to_ascii_lowercase()) + .or_insert_with(|| (value.to_owned(), Vec::new())); + if value < group.0.as_str() { + group.0 = value.to_owned(); + } + group.1.push(observation.evidence_ref()); } - values + groups.into_values().collect() } fn single_value(observations: &[AutopilotObservation], key: &str) -> Option { @@ -1562,6 +1580,7 @@ fn reduce_confidence( fn next_evidence_requests( outcome: AutopilotOutcome, esp_linkage: &AutopilotEspLinkage, + esp_sessions_supplied: bool, coverage: &[IntuneArtifactCoverage], ) -> Vec { let mut requests: Vec = match outcome { @@ -1609,7 +1628,18 @@ fn next_evidence_requests( AutopilotOutcome::Completed => Vec::new(), }; - if esp_linkage.state == AutopilotEspLinkState::TimeOnlyCandidate { + // ESP facts were supplied but no explicit key bound them. The request for + // a shared identifier is keyed on that situation, not on the linkage state + // alone: the time gate can narrow the overlap window and turn + // `TimeOnlyCandidate` into `NotObserved`, and the guidance -- go find the + // identifier the two sides share -- must survive that downgrade rather + // than vanish with it. + if esp_sessions_supplied + && matches!( + esp_linkage.state, + AutopilotEspLinkState::TimeOnlyCandidate | AutopilotEspLinkState::NotObserved + ) + { requests.push( "An enrollment, correlation, or device identifier shared by the Autopilot and ESP evidence" .to_owned(), diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs index 33b8f2ab0..0b632f9c0 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs @@ -562,17 +562,20 @@ fn push_esp_link_conflicting(snapshot: &AutopilotSnapshot, findings: &mut Vec &'static Regex { /// /// 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. Every Windows time zone identifier ends in -/// `Time` (`W. Europe Standard Time`, `Coordinated Universal Time`); anchoring -/// on that suffix is what rejects collector placeholders such as `unknown` or -/// `not recorded`, which would otherwise pass as a declared timezone and let +/// downgraded the time basis. The ` Time` suffix anchor covers the common +/// English registry ids (`W. Europe Standard Time`, `Coordinated Universal +/// Time`) and rejects most collector placeholders (`unknown`, `not recorded`), +/// which would otherwise pass as a declared timezone and let /// `reduce_esp_linkage` offer a time-only candidate the module contract -/// forbids. `UTC` and offset forms are covered by `utc_offset_re`. Punctuation -/// beyond spaces, hyphens, and periods stays excluded, which still rejects an -/// annotated value like `Pacific Standard Time (device local)`. +/// forbids. The anchor is deliberately not a claim that every Windows zone id +/// ends in `Time`: `UTC` and `UTC+12` are registry ids too (covered by +/// `utc_offset_re`), and a localized `StandardName` will not match -- that +/// mismatch only degrades the time basis to `Unreliable`, the conservative +/// direction. Placeholders that happen to end in ` Time` are closed off by +/// [`WINDOWS_ZONE_PLACEHOLDERS`]. Punctuation beyond spaces, hyphens, and +/// periods stays excluded, which 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(|| { @@ -367,6 +372,10 @@ fn windows_zone_re() -> &'static Regex { }) } +/// Collector placeholders that pass the ` Time` shape check but declare no +/// timezone at all. Compared case-insensitively. +const WINDOWS_ZONE_PLACEHOLDERS: [&str; 3] = ["Local Time", "Device Local Time", "System Time"]; + /// Classify the declared collection timezone. /// /// The pure crate carries no timezone database, so this checks *shape* only: a @@ -385,10 +394,11 @@ pub fn classify_timezone(timezone: Option<&str>) -> AutopilotTimezoneState { && timezone .chars() .any(|character| character.is_ascii_alphanumeric()); - if looks_like_offset - || iana_zone_re().is_match(timezone) - || windows_zone_re().is_match(timezone) - { + let looks_like_windows_zone = windows_zone_re().is_match(timezone) + && !WINDOWS_ZONE_PLACEHOLDERS + .iter() + .any(|placeholder| placeholder.eq_ignore_ascii_case(timezone)); + if looks_like_offset || iana_zone_re().is_match(timezone) || looks_like_windows_zone { AutopilotTimezoneState::Declared } else { AutopilotTimezoneState::Invalid @@ -512,6 +522,12 @@ mod tests { "unknown", "not recorded", "Unavailable", + // Placeholders that happen to end in ` Time` and would otherwise + // sail through the Windows-zone shape check. + "Local Time", + "local time", + "Device Local Time", + "System Time", ] { assert_eq!( classify_timezone(Some(junk)), diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/matching-autopilot-and-esp-session/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/matching-autopilot-and-esp-session/expected.json index 796b129e3..40482d97f 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/matching-autopilot-and-esp-session/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/matching-autopilot-and-esp-session/expected.json @@ -1,6 +1,7 @@ { "assertions": [ - "one explicit shared identifier is enough, and is the only thing that is enough" + "one explicit shared identifier is enough, and is the only thing that is enough", + "the event 153 transition into ProfileState_Available counts as application evidence, not merely retrieval: event 172, its documented failure sibling, reads 'failed to set Autopilot profile as available', so setting the profile available IS the application step and 153-into-Available is its explicit success record; a 172 alongside a 153 reduces to profileApplicationFailure, never completed" ], "confidence": "high", "conflictIds": [], diff --git a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs index 50d0d3974..3f7bae106 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs @@ -1225,6 +1225,128 @@ fn a_schema_version_only_unknown_schema_is_explained_by_a_finding() { ); } +/// A case-only difference between two sightings of the same identifier is not +/// a conflict: serials and GUIDs are case-insensitive identities, and the +/// redacted export already masks the trimmed, lowercased value, so treating +/// casings as distinct produced "2 distinct values" rendered as two identical +/// tokens -- a self-contradictory export (ADR-004: redaction must not change +/// conclusions within one analysis). +#[test] +fn a_case_only_identifier_difference_is_not_a_conflict() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "case-e1", "case-channel", 1, 161, "available", "parsed", + json!([{ "name": "serialNumber", "value": "SYNTH-5CD1234ABC" }]), + "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "case-e2", "case-channel", 2, 153, "available", "parsed", + json!([{ "name": "serialNumber", "value": "synth-5cd1234abc" }]), + "AutopilotManager reported the state changed from ProfileState_Unknown to ProfileState_Available.", + ), + ] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![synthetic_source( + "case-channel", + "autopilotEvents", + &events, + )])); + assert!( + snapshot.conflicts.is_empty(), + "two casings of one serial are one identity, got {:?}", + snapshot.conflicts + ); + assert_ne!(snapshot.outcome, AutopilotOutcome::ContradictoryEvidence); + assert_eq!( + snapshot.identity.serial_number.as_deref(), + Some("SYNTH-5CD1234ABC"), + "the representative casing must be deterministic" + ); +} + +/// The control for the test above: genuinely different identifiers still +/// conflict, and the conflict still reports both values. +#[test] +fn genuinely_distinct_identifiers_still_conflict() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "real-e1", "real-channel", 1, 161, "available", "parsed", + json!([{ "name": "serialNumber", "value": "SYNTH-5CD1234ABC" }]), + "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "real-e2", "real-channel", 2, 153, "available", "parsed", + json!([{ "name": "serialNumber", "value": "SYNTH-5CD9999XYZ" }]), + "AutopilotManager reported the state changed from ProfileState_Unknown to ProfileState_Available.", + ), + ] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![synthetic_source( + "real-channel", + "autopilotEvents", + &events, + )])); + let conflict = snapshot + .conflicts + .iter() + .find(|conflict| conflict.conflict_id == "conflicting-serial-number") + .expect("two different serials must still conflict"); + assert_eq!(conflict.values.len(), 2); + assert_eq!(snapshot.outcome, AutopilotOutcome::ContradictoryEvidence); +} + +/// When ESP facts exist but share no explicit key, the guidance to go find a +/// shared identifier must survive whatever the time gate concludes. The +/// narrowed (assessable-only) overlap window can turn TimeOnlyCandidate into +/// NotObserved, and losing the next-evidence request with it would hide the +/// one step that advances the diagnosis. +#[test] +fn unlinked_esp_sessions_keep_the_shared_identifier_evidence_request() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [synthetic_event( + "unlinked-e1", "unlinked-channel", 1, 161, "available", "parsed", + json!([]), "AutopilotManager retrieve settings succeeded.", + )] + }); + let sessions = json!({ + "autopilotDocument": "autopilot.espSession", + "documentVersion": 1, + "sessions": [{ + "sessionId": "esp-session-a", + "enrollmentId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "correlationId": null, "activityId": null, + "entraDeviceId": null, "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:20Z", "phase": "deviceSetup", + "evidence": { "evidenceId": "unlinked-esp-a", "sourceArtifactId": "esp-session-facts" } + }] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![ + synthetic_source("unlinked-channel", "autopilotEvents", &events), + synthetic_source("esp-session-facts", "espSession", &sessions), + ])); + + assert_eq!( + snapshot.esp_linkage.state, + AutopilotEspLinkState::NotObserved, + "no key and no provable overlap window must stay NotObserved" + ); + assert!( + snapshot.next_evidence_requests.iter().any(|request| { + request.contains("identifier shared by the Autopilot and ESP evidence") + }), + "the shared-identifier request must survive the time gate, got {:?}", + snapshot.next_evidence_requests + ); +} + /// The native-event input path: events supplied directly on the bundle derive /// their artifact from the event's own provenance and produce observations /// without any document or source coverage entry. From 874d322b9b24be481641ee5406c9349dcd91b96d Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 12:32:49 -0400 Subject: [PATCH 10/14] fix(intune): let a recorded failure on a non-assessable event block Autopilot success The direction-aware assessability gate from the previous round covered report SECTIONS only. A capped or raw EVENT carrying a documented failure signal was still silently dropped by the is_assessable filters, so a bundle with an assessable success path (161 + 153-into-Available + an assessable espHandoff section) plus a capped event 172 reduced to a success-family outcome with no finding naming the failure. The class covers every documented failure signal: 171, 172, 807, 809, 815, 908. The event side now mirrors the section pattern exactly. The new recorded_non_assessable_failure_observations iterator selects the non-assessable observations whose signal is_terminal_failure -- the symmetry rule being that any record strong enough to fail the enrollment when readable is strong enough to block its success when unreadable -- and the success branch of reduce_outcome short-circuits to InsufficientEvidence over either record shape, never a terminal failure (ADR-001 cuts both ways). ProfilePolicyNotFound (100) stays out for the same reason the section iterator excludes NotFound/Retrying: documented transient, not a recorded failure. push_non_assessable_failure_recorded widens to cite both shapes, so the recorded failure is never silent; an assessable event 172 still produces the terminal ProfileApplicationFailure untouched. Co-Authored-By: Claude Fable 5 --- .../enrollment/windows/autopilot/reducer.rs | 35 +++++- .../enrollment/windows/autopilot/rules.rs | 41 ++++--- .../tests/intune_windows_autopilot.rs | 109 ++++++++++++++++++ 3 files changed, 166 insertions(+), 19 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs index b5c018bc5..37922dd24 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs @@ -679,6 +679,34 @@ fn recorded_non_assessable_failure_sections( }) } +/// Iterate the *non-assessable* observations that explicitly recorded a +/// documented failure signal. +/// +/// The event-side companion of +/// [`recorded_non_assessable_failure_sections`], with the same direction-aware +/// contract: a capped or unparsed event cannot PROVE the failure -- which is +/// why [`signal_observations`] and every other consumer stay gated on +/// [`is_assessable`], and why none of these observations ever produce a +/// terminal failure outcome -- but a failure that IS on the record must still +/// BLOCK a success conclusion. Consumers may only move the result in the +/// conservative direction. +/// +/// The class is [`AutopilotSignal::is_terminal_failure`]: exactly the signals +/// that would produce a terminal failure outcome if they were assessable +/// (171, 172, 807, 809, 815, 908). That symmetry is the rule -- any record +/// strong enough to fail the enrollment when readable is strong enough to +/// block its success when unreadable. `ProfilePolicyNotFound` (100) stays out +/// for the same reason the section iterator excludes `NotFound`/`Retrying`: +/// it is documented as transient, not a recorded failure. +fn recorded_non_assessable_failure_observations( + observations: &[AutopilotObservation], +) -> impl Iterator { + observations + .iter() + .filter(|observation| !is_assessable(observation)) + .filter(|observation| observation.signal.is_terminal_failure()) +} + /// Collect the distinct values a named key takes across every source. /// /// Returned sorted and deduplicated so that "more than one value" is a @@ -1514,10 +1542,15 @@ fn reduce_outcome( // reduction: the bundle cannot support success while a failure is on // record, and cannot prove the failure while its record is // non-assessable. The recorded failure is surfaced by its own finding - // rather than silently swallowed. + // rather than silently swallowed. The gate is symmetric across both + // record shapes: a report section that recorded Failed/Mismatch, and + // an event carrying a documented failure signal. if recorded_non_assessable_failure_sections(sections) .next() .is_some() + || recorded_non_assessable_failure_observations(observations) + .next() + .is_some() { return AutopilotOutcome::InsufficientEvidence; } diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs index 0b632f9c0..9d04ba454 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs @@ -709,14 +709,17 @@ fn push_coverage_gaps(snapshot: &AutopilotSnapshot, findings: &mut Vec, @@ -724,16 +727,17 @@ fn push_non_assessable_failure_recorded( let recorded = snapshot .observations .iter() - .filter(|observation| observation.signal == AutopilotSignal::ReportSection) .filter(|observation| { observation.context.access_state != IntuneAccessState::Available || observation.context.parse_state != IntuneParseState::Parsed }) .filter(|observation| { - matches!( - observation.section_outcome, - Some(AutopilotSectionOutcome::Failed) | Some(AutopilotSectionOutcome::Mismatch) - ) + let failed_section = observation.signal == AutopilotSignal::ReportSection + && matches!( + observation.section_outcome, + Some(AutopilotSectionOutcome::Failed) | Some(AutopilotSectionOutcome::Mismatch) + ); + failed_section || observation.signal.is_terminal_failure() }) .collect::>(); if recorded.is_empty() { @@ -749,19 +753,20 @@ fn push_non_assessable_failure_recorded( // exists so the recorded failure blocks success visibly instead of // silently. IntuneFindingConfidence::Low, - "A section that could not be fully read recorded a failure", + "A record that could not be fully read recorded a failure", &format!( - "{} report section(s) whose own capture or parse state is not assessable \ - explicitly recorded a failed or mismatched outcome. That record cannot prove the \ - failure, but it blocks any success conclusion until the section is re-collected \ - in a readable form.", + "{} record(s) whose own capture or parse state is not assessable explicitly \ + recorded a failure -- a failed or mismatched report section, or an event \ + carrying a documented failure signal. That record cannot prove the failure, but \ + it blocks any success conclusion until it is re-collected in a readable form.", recorded.len() ), &[ - "Re-collect the cited report section(s), complete and readable, before trusting \ - any success signal from this bundle." + "Re-collect the cited record(s), complete and readable, before trusting any \ + success signal from this bundle." .to_owned(), - "Compare the re-collected section's outcome against the sibling event evidence." + "Compare the re-collected record's outcome against the sibling assessable \ + evidence." .to_owned(), ], normalized_evidence( diff --git a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs index 3f7bae106..3600780df 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs @@ -959,6 +959,115 @@ fn an_assessable_failed_section_still_produces_the_terminal_failure() { ); } +/// ADR-001, direction-aware, event side: the same class as the capped-failed- +/// section fixture above, but the recorded failure arrives as a capped EVENT +/// (172, `ProfileApplicationFailed`) instead of a report section. The +/// assessable success path (161 + 153-into-Available + an assessable +/// espHandoff section) must not complete the enrollment over it. +#[test] +fn a_recorded_failure_on_a_non_assessable_event_blocks_success() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "evtblocked-e1", "evtblocked-channel", 1, 161, "available", "parsed", + json!([]), "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "evtblocked-e2", "evtblocked-channel", 2, 153, "available", "parsed", + json!([]), + "AutopilotManager reported the state changed from ProfileState_Unknown to ProfileState_Available.", + ), + synthetic_event( + "evtblocked-failure", "evtblocked-channel", 3, 172, "capped", "parsed", + json!([]), "Failed to set the Autopilot profile as available.", + ), + ] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![ + synthetic_source("evtblocked-channel", "autopilotEvents", &events), + synthetic_source( + "evtblocked-report", + "mdmReport", + &esp_handoff_report("evtblocked-report"), + ), + ])); + + assert_ne!( + snapshot.outcome, + AutopilotOutcome::Completed, + "a recorded failure on a capped event must block a completed outcome" + ); + assert_eq!( + snapshot.outcome, + AutopilotOutcome::InsufficientEvidence, + "non-assessable evidence proves neither the failure nor the success (ADR-001)" + ); + assert_ne!( + wire(&snapshot.confidence), + json!("high"), + "a success-path bundle carrying a recorded failure may not be presented at high confidence" + ); + let finding = snapshot + .findings + .iter() + .find(|finding| finding.finding_id == "autopilot-non-assessable-failure-recorded") + .unwrap_or_else(|| { + panic!( + "the recorded-but-unassessable failure must never be silent, got {:?}", + snapshot + .findings + .iter() + .map(|finding| finding.finding_id.as_str()) + .collect::>() + ) + }); + assert_eq!(wire(&finding.confidence), json!("low")); + assert!( + finding + .evidence + .iter() + .any(|evidence| evidence.evidence_id == "evtblocked-failure"), + "the finding must cite the capped event that recorded the failure" + ); + assert!(snapshot.findings_are_evidence_backed()); +} + +/// The symmetric control: the same event 172, assessable, is the real terminal +/// failure. The new event-side gate must not swallow it into a blocked +/// success, and the non-assessable finding must not fire. +#[test] +fn an_assessable_failed_event_still_produces_the_terminal_failure() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "evtterm-e1", "evtterm-channel", 1, 161, "available", "parsed", + json!([]), "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "evtterm-failure", "evtterm-channel", 2, 172, "available", "parsed", + json!([]), "Failed to set the Autopilot profile as available.", + ), + ] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![synthetic_source( + "evtterm-channel", + "autopilotEvents", + &events, + )])); + assert_eq!(snapshot.outcome, AutopilotOutcome::ProfileApplicationFailure); + assert!( + !snapshot + .findings + .iter() + .any(|finding| finding.finding_id == "autopilot-non-assessable-failure-recorded"), + "an assessable failure is not a non-assessable one" + ); +} + /// ADR-001, same class: a non-assessable record carrying identity keys must /// not inflate the phase to `IdentityObserved` through the evidence list. #[test] From b907c107453153455f94d7f70a8f702cc52aaa3d Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 21:14:07 -0400 Subject: [PATCH 11/14] fix(intune): honor the matched_keys contract and hardwareHash case-sensitivity Two CodeRabbit findings on the Conflicting linkage path and value grouping, both verified against the code before fixing: - The distinct-keys-to-distinct-sessions Conflicting return exported its detecting keys as matched_keys, but AutopilotEspLinkage documents matched_keys as empty for every non-Linked state. Detection is not a match; the keys stay internal so ambiguity evidence cannot be read as proof of a link. - distinct_values case-folded every named value, including hardwareHash, whose Base64 payload is case-sensitive. Two genuinely different hashes differing only by case collapsed into one group that single_value then published as corroborated identity. Case-insensitive grouping is now an explicit allowlist (CASE_INSENSITIVE_VALUE_KEYS) of verified case-insensitive Windows identities; every other key compares exactly, the conservative direction. All keys exported through detect_conflicts remain on the allowlist, preserving the ADR-004 redaction guarantee. Both fixes landed test-first: the new regression tests failed on the prior behavior for exactly the reported reasons. Co-Authored-By: Claude Fable 5 --- .../enrollment/windows/autopilot/reducer.rs | 55 +++++++-- .../tests/intune_windows_autopilot.rs | 107 ++++++++++++++++++ 2 files changed, 151 insertions(+), 11 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs index 37922dd24..eaf9311a8 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs @@ -707,24 +707,49 @@ fn recorded_non_assessable_failure_observations( .filter(|observation| observation.signal.is_terminal_failure()) } +/// Named-value keys whose values are case-insensitive identities on Windows: +/// serials, GUID-shaped identifiers, DNS domains, and host names. Only these +/// group case-insensitively in [`distinct_values`]; every other key -- notably +/// `hardwareHash`, whose Base64 payload is case-sensitive -- keeps its exact +/// value, so two values differing only by case stay two distinct values (the +/// conservative direction: `single_value` refuses to pick, it never merges). +/// Every key exported through `detect_conflicts` must stay in this list so a +/// redacted conflict cannot report "2 distinct values" over two identically +/// masked tokens (ADR-004; the export masks the trimmed, lowercased value). +const CASE_INSENSITIVE_VALUE_KEYS: [&str; 11] = [ + "serialNumber", + "productKeyId", + "ztdRegistrationId", + "entraDeviceId", + "managedDeviceId", + "tenantId", + "tenantDomain", + "deviceName", + "profileId", + "enrollmentId", + "correlationId", +]; + /// 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. /// -/// Distinctness is case-insensitive: the identifiers this feeds -- serials, -/// GUIDs, tenant domains, device names -- are case-insensitive identities on -/// Windows, and the redacted export masks the trimmed, lowercased value -/// (see the redaction module contract). Treating two casings as two values -/// produced a conflict whose export said "2 distinct values" over two -/// identical tokens, changing the conclusion under redaction (ADR-004). Each -/// group is keyed by a deterministic representative casing -- the -/// lexicographically smallest sighting -- so permuting the input cannot change -/// the result (ADR-003). +/// Distinctness is case-insensitive only for the keys in +/// [`CASE_INSENSITIVE_VALUE_KEYS`]. For those identities, treating two casings +/// as two values produced a conflict whose export said "2 distinct values" +/// over two identical tokens, changing the conclusion under redaction +/// (ADR-004). Each case-folded group is keyed by a deterministic +/// representative casing -- the lexicographically smallest sighting -- so +/// permuting the input cannot change the result (ADR-003). Any other key +/// compares exactly: folding a case-sensitive value such as a Base64 hardware +/// hash would merge two genuinely different values into one corroborated +/// identity. fn distinct_values( observations: &[AutopilotObservation], key: &str, ) -> BTreeMap> { + let case_insensitive = CASE_INSENSITIVE_VALUE_KEYS.contains(&key); let mut groups: BTreeMap)> = BTreeMap::new(); for observation in observations.iter().filter(|obs| is_assessable(obs)) { let Some(value) = observation.named(key) else { @@ -734,8 +759,13 @@ fn distinct_values( if value.is_empty() { continue; } + let group_key = if case_insensitive { + value.to_ascii_lowercase() + } else { + value.to_owned() + }; let group = groups - .entry(value.to_ascii_lowercase()) + .entry(group_key) .or_insert_with(|| (value.to_owned(), Vec::new())); if value < group.0.as_str() { group.0 = value.to_owned(); @@ -1347,7 +1377,10 @@ fn reduce_esp_linkage( return AutopilotEspLinkage { state: AutopilotEspLinkState::Conflicting, confidence: IntuneFindingConfidence::High, - matched_keys: detected_keys.into_iter().collect(), + // Detection is not a match. `matched_keys` is documented empty for + // every non-`Linked` state, so the detecting keys stay internal: a + // consumer must not read ambiguity evidence as proof of a link. + matched_keys: Vec::new(), esp_session_ids: detected_sessions.into_iter().collect(), evidence: normalized_evidence(detection_evidence), }; diff --git a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs index 3600780df..1ccefc76a 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs @@ -1253,6 +1253,113 @@ fn a_key_on_a_capped_observation_still_detects_a_second_esp_session() { assert!(snapshot.findings_are_evidence_backed()); } +/// `AutopilotEspLinkage` documents `matched_keys` as empty for every +/// non-`Linked` state. The distinct-keys-to-distinct-sessions `Conflicting` +/// path only DETECTED ambiguity; exporting its detecting keys as +/// `matched_keys` would hand a consumer match-shaped proof from the one state +/// whose meaning is that nothing was proven. +#[test] +fn a_conflicting_linkage_exports_no_matched_keys() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "nomk-e1", "nomk-channel", 1, 161, "available", "parsed", + json!([{ "name": "enrollmentId", "value": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }]), + "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "nomk-e2", "nomk-channel", 2, 153, "available", "parsed", + json!([{ "name": "correlationId", "value": "99999999-8888-7777-6666-555555555555" }]), + "AutopilotManager reported the state changed from ProfileState_Unknown to ProfileState_Available.", + ), + ] + }); + let sessions = json!({ + "autopilotDocument": "autopilot.espSession", + "documentVersion": 1, + "sessions": [ + { + "sessionId": "esp-session-a", + "enrollmentId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "correlationId": null, "activityId": null, + "entraDeviceId": null, "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:20Z", "phase": "deviceSetup", + "evidence": { "evidenceId": "nomk-esp-a", "sourceArtifactId": "esp-session-facts" } + }, + { + "sessionId": "esp-session-b", + "enrollmentId": null, + "correlationId": "99999999-8888-7777-6666-555555555555", + "activityId": null, "entraDeviceId": null, "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:25Z", "phase": "deviceSetup", + "evidence": { "evidenceId": "nomk-esp-b", "sourceArtifactId": "esp-session-facts" } + } + ] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![ + synthetic_source("nomk-channel", "autopilotEvents", &events), + synthetic_source("nomk-report", "mdmReport", &esp_handoff_report("nomk-report")), + synthetic_source("esp-session-facts", "espSession", &sessions), + ])); + + assert_eq!(snapshot.esp_linkage.state, AutopilotEspLinkState::Conflicting); + assert!( + snapshot.esp_linkage.matched_keys.is_empty(), + "matched_keys is documented empty for every non-Linked state, got {:?}", + snapshot.esp_linkage.matched_keys + ); +} + +/// `distinct_values` groups case-insensitively only for keys whose values are +/// case-insensitive identities on Windows. `hardwareHash` is not one: its +/// Base64 payload is case-sensitive, so two hashes differing only by case are +/// two different hashes. Folding them into one group let `single_value` +/// publish one of them as corroborated identity evidence. +#[test] +fn hardware_hashes_differing_only_by_case_stay_distinct() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [ + synthetic_event( + "hash-e1", "hash-channel", 1, 161, "available", "parsed", + json!([ + { "name": "hardwareHash", "value": "AAECAwQFBgcICQ==" }, + { "name": "serialNumber", "value": "SER-0001" }, + ]), + "AutopilotManager retrieve settings succeeded.", + ), + synthetic_event( + "hash-e2", "hash-channel", 2, 161, "available", "parsed", + json!([ + { "name": "hardwareHash", "value": "aaecawqfbgcicq==" }, + { "name": "serialNumber", "value": "ser-0001" }, + ]), + "AutopilotManager retrieve settings succeeded.", + ), + ] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![synthetic_source( + "hash-channel", + "autopilotEvents", + &events, + )])); + + assert_eq!( + snapshot.identity.hardware_hash, None, + "two hardware hashes differing only by case are two distinct hashes; \ + neither may be published as the single corroborated value" + ); + // Control: serial numbers ARE case-insensitive identities on Windows, so + // the same bundle still collapses the two serial casings into one value. + assert!( + snapshot.identity.serial_number.is_some(), + "case-insensitive identity keys must keep collapsing casings" + ); +} + /// The other half of the same rule: a linkage whose ONLY explicit key rides a /// non-assessable observation may not upgrade to a confident Linked. Detection /// may widen (conservative); proof may not. From ed8b50b9b745abb97e7440869a092212ef546c2c Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 21:19:06 -0400 Subject: [PATCH 12/14] ci(coderabbit): sync review config with main so the approve gate can run This branch forked before main's 96b841cc enabled reviews.request_changes_workflow, and CodeRabbit resolves the config from the PR branch: the @coderabbitai approve command on PR #531 reported "Approval skipped: request-changes workflow disabled". Copy main's .coderabbit.yaml verbatim so the file carries zero net diff against main and the formal approval node can be produced. Co-Authored-By: Claude Fable 5 --- .coderabbit.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index fade8c774..d40f314a4 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -13,7 +13,7 @@ tone_instructions: >- reviews: profile: assertive # Keep reviews advisory: CI gates (cargo clippy, cargo test, tsc) decide mergeability. - request_changes_workflow: false + request_changes_workflow: true high_level_summary: true changed_files_summary: true sequence_diagrams: true @@ -22,7 +22,7 @@ reviews: related_issues: true related_prs: true suggested_labels: true - auto_apply_labels: false + auto_apply_labels: true suggested_reviewers: false poem: false in_progress_fortune: false @@ -33,7 +33,7 @@ reviews: auto_review: enabled: true auto_incremental_review: true - drafts: false + drafts: true base_branches: - main # Dependency bumps and release automation are machine-generated; skip them. From 3d2ee13ddde05fed21486df477eb9ad8fe816b57 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 22:35:19 -0400 Subject: [PATCH 13/14] fix(intune): close three Hermes charter P1 findings on Autopilot Address the three blocking semantic findings from the Hermes charter review of PR #531, each TDD'd (RED first with Hermes's exact inputs). 1. ADR-001 finding side: `signal_evidence` (rules.rs) now requires `is_assessable`, so a capped/malformed event 171 can no longer promote `autopilot-identity-registration-mismatch` to a High/Blocker finding the reducer's own outcome gate already withholds. The assessability check is hoisted to a single `AutopilotObservation::is_assessable` method shared by the reducer's free fn and the finding-side helpers so the boundary cannot drift. Assessable-171 still fires (regression pinned). 2. ADR-004 redaction: `opaque_blob_re` drops its `\b` anchors. The Base64 alphabet includes `=`, `+`, `/` (non-word), so a trailing `\b` could not close a match on a padded/punctuation-terminated hardware hash and left its tail exposed. The greedy, leftmost, >=40 match now consumes the whole contiguous run while still excluding the 36-char GUID and short HRESULT, and the `[blob:...]` token cannot re-match (idempotent). 3. ADR-003 chronology: `reduce_profile` no longer lets an unlinked later success (161/153) silently erase an earlier explicit negative (815/809). Positive evidence and explicit negatives are tracked apart and reconciled from sets (never vector order); a negative is erased only when it shares an `activityId` retry-linkage key with an Available-raising success. Unlinked stays conservative (NoProfileCandidate, not Completed); linked completes. Gates: cargo test -p cmtraceopen-parser (0 failures), clippy -D warnings, cargo check --workspace, wasm32 check all clean. Co-Authored-By: Claude Opus 4.8 --- .../enrollment/windows/autopilot/models.rs | 20 +- .../enrollment/windows/autopilot/redaction.rs | 46 ++- .../enrollment/windows/autopilot/reducer.rs | 121 +++++++- .../enrollment/windows/autopilot/rules.rs | 22 +- .../tests/intune_windows_autopilot.rs | 263 ++++++++++++++++++ 5 files changed, 448 insertions(+), 24 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs index c0758a141..edbedce74 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs @@ -15,9 +15,9 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::intune::evidence::{ - intune_raw_preserving_string_enum, IntuneArtifactCoverage, IntuneErrorCode, IntuneEvidenceRef, - IntuneFinding, IntuneFindingConfidence, IntuneNamedValue, IntuneObservationContext, - IntuneParseState, + intune_raw_preserving_string_enum, IntuneAccessState, IntuneArtifactCoverage, IntuneErrorCode, + IntuneEvidenceRef, IntuneFinding, IntuneFindingConfidence, IntuneNamedValue, + IntuneObservationContext, IntuneParseState, }; /// Schema version of the Autopilot snapshot contract. @@ -241,6 +241,20 @@ impl AutopilotObservation { self.context.evidence_ref.clone() } + /// Whether this observation may support a conclusion at all. + /// + /// A record is assessable only when its own declared context is fully + /// available and cleanly parsed. Non-assessable evidence (capped, denied, + /// malformed) can BLOCK a success conclusion but can never PROVE one, so no + /// terminal outcome and no high-confidence terminal finding may be sourced + /// from it (ADR-001). This is the single definition consulted by both the + /// reducer's semantic gates and the finding-side evidence helpers, so the + /// boundary cannot drift between them. + pub fn is_assessable(&self) -> bool { + self.context.access_state == IntuneAccessState::Available + && self.context.parse_state == IntuneParseState::Parsed + } + /// Look up one named-data value, case-insensitively. pub fn named(&self, name: &str) -> Option<&str> { self.named_data diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs index 7a6bf2482..a39f55fd8 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs @@ -114,10 +114,20 @@ fn user_path_re() -> &'static Regex { /// Bounded at 40 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. +/// +/// No `\b` anchors. The Base64 alphabet includes `=`, `+`, and `/`, none of +/// which is a word character, so a trailing `\b` could not close a match on a +/// padded or punctuation-terminated hash and left its tail exposed. The match +/// is greedy and leftmost, so it consumes the whole contiguous Base64 run +/// wherever a >=40 run exists -- exactly the whole hash -- while the >=40 bound +/// still excludes the 36-char GUID (dashes break it into <=12-char runs) and +/// the short HRESULT. The `[blob:…]` token this produces contains `[`, `:`, and +/// `]`, which are outside the alphabet, so a token can never re-match: the +/// projection stays idempotent. 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") + Regex::new(r"[A-Za-z0-9+/=]{40,}").expect("opaque blob regex must compile") }) } @@ -304,6 +314,40 @@ mod tests { assert!(!masked.contains(&hash), "got {masked}"); } + /// A Base64 hardware hash may end in `=`, `==`, `+`, or `/` -- none of which + /// is a word character, so a trailing `\b` cannot close the match at them. + /// Every one of these must be masked in full, or raw identity material rides + /// out in the padding after a partial match (ADR-004: restricted values are + /// absent from export). + #[test] + fn a_base64_blob_ending_in_punctuation_is_masked_in_full() { + // Each blob is a >=40 char Base64 run terminated by a non-word char. + for blob in [ + format!("{}=", "A".repeat(43)), // single pad + format!("{}==", "A".repeat(42)), // double pad + format!("{}+", "A".repeat(43)), // plus terminal + format!("{}/", "A".repeat(43)), // slash terminal + ] { + let masked = redact_text(&format!("hardware hash {blob} was reported")); + assert!( + !masked.contains(&blob), + "the whole blob must be masked, got {masked}" + ); + // No fragment of the original run -- including the padding/punctuation + // tail -- may survive between the surrounding words. + assert!( + !masked.contains("AA"), + "no run of the blob may survive, got {masked}" + ); + for tail in ['=', '+', '/'] { + assert!( + !masked.contains(&format!("{tail} was")), + "a punctuation tail must not dangle after masking, got {masked}" + ); + } + } + } + #[test] fn an_already_masked_whole_value_is_left_alone() { let token = stable_token(VALUE_KIND, "abc"); diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs index eaf9311a8..10af4ec8d 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs @@ -602,8 +602,7 @@ fn sort_time(observation: &AutopilotObservation, basis: AutopilotTimeBasis) -> O /// conservative direction: a non-assessable record with an unnormalized /// timestamp downgrades the basis, and gating it would upgrade it. fn is_assessable(observation: &AutopilotObservation) -> bool { - observation.context.access_state == IntuneAccessState::Available - && observation.context.parse_state == IntuneParseState::Parsed + observation.is_assessable() } /// A report section is assessable under the same rule as an observation: its @@ -877,7 +876,18 @@ fn reduce_profile( sections: &[AutopilotReportSection], ) -> AutopilotProfileState { let mut evidence = Vec::new(); - let mut candidate = AutopilotProfileCandidateState::Unknown; + // `raised` is the candidate the positive evidence alone supports; it never + // encodes a negative, so it is a pure order-independent maximum. The + // explicit negatives are tracked apart and reconciled against it below, + // because whether a later success may erase an earlier negative is a + // linkage question, not an ordering one (ADR-003). + let mut raised = AutopilotProfileCandidateState::Unknown; + let mut negative: Option = None; + // Activity ids (the module's retry/session key) carried by the explicit + // negatives and by the successes that reached `Available`. A negative may + // be erased only when it shares one with a success. + let mut negative_links: Vec = Vec::new(); + let mut available_success_links: Vec = Vec::new(); let mut retrieved = false; let mut applied = false; let mut error: Option = None; @@ -889,13 +899,14 @@ fn reduce_profile( | AutopilotSignal::ProfilePolicyNotFound | AutopilotSignal::NetworkAvailableForDownload => { evidence.push(observation.evidence_ref()); - candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Pending); + raised = raise_candidate(raised, AutopilotProfileCandidateState::Pending); } AutopilotSignal::ProfileRetrieveSucceeded | AutopilotSignal::ProfileSettingsRetrieved => { evidence.push(observation.evidence_ref()); retrieved = true; - candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Available); + raised = raise_candidate(raised, AutopilotProfileCandidateState::Available); + push_link(&mut available_success_links, observation.activity_id.as_deref()); } AutopilotSignal::ProfileStateChanged => { evidence.push(observation.evidence_ref()); @@ -912,8 +923,8 @@ fn reduce_profile( | Some(AutopilotProfileStateToken::Provisioned) ) { applied = true; - candidate = - raise_candidate(candidate, AutopilotProfileCandidateState::Available); + raised = raise_candidate(raised, AutopilotProfileCandidateState::Available); + push_link(&mut available_success_links, observation.activity_id.as_deref()); } if token.is_some() { last_state_token = token; @@ -922,15 +933,24 @@ fn reduce_profile( AutopilotSignal::DeviceAlreadyProvisioned => { evidence.push(observation.evidence_ref()); applied = true; - candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Available); + raised = raise_candidate(raised, AutopilotProfileCandidateState::Available); + push_link(&mut available_success_links, observation.activity_id.as_deref()); } AutopilotSignal::NoAssignedProfile => { evidence.push(observation.evidence_ref()); - candidate = AutopilotProfileCandidateState::NoneAssigned; + negative = Some(merge_negative( + negative, + AutopilotProfileCandidateState::NoneAssigned, + )); + push_link(&mut negative_links, observation.activity_id.as_deref()); } AutopilotSignal::AssignedProfileMissing => { evidence.push(observation.evidence_ref()); - candidate = AutopilotProfileCandidateState::AssignedButMissing; + negative = Some(merge_negative( + negative, + AutopilotProfileCandidateState::AssignedButMissing, + )); + push_link(&mut negative_links, observation.activity_id.as_deref()); } AutopilotSignal::ProfileApplicationFailed => { evidence.push(observation.evidence_ref()); @@ -945,13 +965,18 @@ fn reduce_profile( match section.outcome { AutopilotSectionOutcome::Succeeded => { retrieved = true; - candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Available); + raised = raise_candidate(raised, AutopilotProfileCandidateState::Available); + push_link(&mut available_success_links, section_activity_id(section)); } AutopilotSectionOutcome::NotFound => { - candidate = AutopilotProfileCandidateState::NoneAssigned; + negative = Some(merge_negative( + negative, + AutopilotProfileCandidateState::NoneAssigned, + )); + push_link(&mut negative_links, section_activity_id(section)); } AutopilotSectionOutcome::Retrying => { - candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Pending); + raised = raise_candidate(raised, AutopilotProfileCandidateState::Pending); } _ => {} } @@ -965,6 +990,8 @@ fn reduce_profile( error = error.or_else(|| section.error.clone()); } + let candidate = reconcile_candidate(raised, negative, &negative_links, &available_success_links); + AutopilotProfileState { profile_id: single_value(observations, "profileId"), profile_name: single_value(observations, "profileName"), @@ -979,6 +1006,74 @@ fn reduce_profile( } } +/// The `activityId` a report section carried in its values, lowercased so retry +/// linkage compares case-insensitively like the ESP correlation keys. +fn section_activity_id(section: &AutopilotReportSection) -> Option<&str> { + section + .values + .iter() + .find(|value| value.name.eq_ignore_ascii_case("activityId")) + .map(|value| value.value.as_str()) +} + +/// Record one retry-linkage key, lowercased. Absent keys contribute nothing: +/// a success with no activity id can never be *proven* to be the same attempt +/// as an earlier negative, so it must not count as linkage. +fn push_link(links: &mut Vec, activity: Option<&str>) { + if let Some(activity) = activity { + links.push(activity.to_ascii_lowercase()); + } +} + +/// Combine two explicit negatives deterministically. Both rank equally and both +/// reduce to [`AutopilotOutcome::NoProfileCandidate`]; `AssignedButMissing` is +/// the more specific statement, so it wins when a bundle somehow carries both. +/// The choice is permutation-invariant, unlike the old last-writer assignment. +fn merge_negative( + current: Option, + incoming: AutopilotProfileCandidateState, +) -> AutopilotProfileCandidateState { + match current { + Some(AutopilotProfileCandidateState::AssignedButMissing) => { + AutopilotProfileCandidateState::AssignedButMissing + } + _ => incoming, + } +} + +/// Reconcile the positive-evidence candidate against an explicit negative. +/// +/// ADR-003: a later success may replace an earlier explicit negative only when +/// retry linkage is explicit. Linkage here is a shared `activityId` between the +/// negative and a success that reached `Available`. Without it the negative +/// stands and the reducer stays conservative -- an unrelated success cannot +/// silently complete an enrollment the service explicitly said had no profile. +/// With it, the success is a proven retry of the same attempt and the candidate +/// rises to `Available`. The decision reads only sets, never vector order, so +/// permuting the input cannot change it. +fn reconcile_candidate( + raised: AutopilotProfileCandidateState, + negative: Option, + negative_links: &[String], + available_success_links: &[String], +) -> AutopilotProfileCandidateState { + let Some(negative) = negative else { + return raised; + }; + let raised_to_available = raised == AutopilotProfileCandidateState::Available; + let retry_linked = negative_links + .iter() + .any(|link| available_success_links.contains(link)); + if raised_to_available && retry_linked { + // The success is a proven retry of the same attempt; it may erase the + // negative. + return raised; + } + // No proven retry link: the explicit negative wins over both an unlinked + // success and any weaker positive (Pending/Unknown). + negative +} + /// Raise a candidate state, never lowering one that was explicitly proven. /// /// `NoneAssigned` and `AssignedButMissing` are assigned directly rather than diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs index 9d04ba454..796175676 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs @@ -10,8 +10,8 @@ //! the whole MDM bundle" is not an answer when one event ID would do. use crate::intune::evidence::{ - IntuneAccessState, IntuneArtifactStatus, IntuneEvidenceRef, IntuneFinding, - IntuneFindingConfidence, IntuneFindingSeverity, IntuneParseState, + IntuneArtifactStatus, IntuneEvidenceRef, IntuneFinding, IntuneFindingConfidence, + IntuneFindingSeverity, IntuneParseState, }; use super::models::*; @@ -727,10 +727,7 @@ fn push_non_assessable_failure_recorded( let recorded = snapshot .observations .iter() - .filter(|observation| { - observation.context.access_state != IntuneAccessState::Available - || observation.context.parse_state != IntuneParseState::Parsed - }) + .filter(|observation| !observation.is_assessable()) .filter(|observation| { let failed_section = observation.signal == AutopilotSignal::ReportSection && matches!( @@ -854,6 +851,17 @@ fn terminal_semantics_withheld(snapshot: &AutopilotSnapshot) -> bool { snapshot.outcome == AutopilotOutcome::UnknownSchema } +/// Evidence refs for every *assessable* observation carrying `signal`. +/// +/// The `is_assessable` gate is the finding-side companion of the reducer's +/// [`AutopilotOutcome`] gate (ADR-001). A capped, denied, or malformed failure +/// event cannot produce a terminal outcome, and it must not be able to promote +/// a rule into a high-confidence terminal FINDING either -- otherwise a +/// non-assessable event 171 would emit an `autopilot-identity-registration-mismatch` +/// blocker the outcome itself correctly withheld. A recorded failure on a +/// non-assessable record is not silenced: it is surfaced by +/// [`push_non_assessable_failure_recorded`] at low confidence, which is the +/// honest strength for evidence that cannot prove its own claim. fn signal_evidence( snapshot: &AutopilotSnapshot, signal: AutopilotSignal, @@ -861,7 +869,7 @@ fn signal_evidence( snapshot .observations .iter() - .filter(|observation| observation.signal == signal) + .filter(|observation| observation.is_assessable() && observation.signal == signal) .map(AutopilotObservation::evidence_ref) .collect() } diff --git a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs index 1ccefc76a..1bc74f32f 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs @@ -1631,6 +1631,269 @@ fn native_events_are_absorbed_with_their_provenance_artifact() { assert!(snapshot.profile.retrieved); } +/// One profile-channel event with a caller-set `activityId`, the module's +/// retry/session correlation key. `synthetic_event` hardcodes a null +/// `activityId`, so the linkage cases override it here. +fn profile_event(record: u64, event_id: u32, activity: Option<&str>, message: &str) -> Value { + let mut event = synthetic_event( + &format!("prof-{event_id}-{record}"), + "prof-channel", + record, + event_id, + "available", + "parsed", + json!([]), + message, + ); + event["activityId"] = match activity { + Some(value) => json!(value), + None => Value::Null, + }; + event +} + +/// An ESP session fact keyed only on `activityId`, so it links to whichever +/// profile events carry the same activity id. +fn esp_session_on_activity(activity: &str) -> Value { + json!({ + "autopilotDocument": "autopilot.espSession", + "documentVersion": 1, + "sessions": [{ + "sessionId": "esp-session-1", + "enrollmentId": null, "correlationId": null, + "activityId": activity, + "entraDeviceId": null, "managedDeviceId": null, + "startedAtUtc": "2026-07-31T09:00:20Z", "phase": "deviceSetup", + "evidence": { "evidenceId": "esp-retry-1", "sourceArtifactId": "esp-facts" } + }] + }) +} + +/// Reduce a bundle whose profile events appear in `events_in_order`, paired +/// with an observed ESP handoff and an ESP session on `success_activity`. Every +/// completion gate except the profile candidate is left open, so the outcome +/// turns solely on whether the later success is allowed to erase the earlier +/// explicit negative. +fn reduce_profile_retry(events_in_order: Vec, success_activity: &str) -> AutopilotSnapshot { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": events_in_order, + }); + reduce_autopilot_bundle(&synthetic_bundle(vec![ + synthetic_source("prof-channel", "autopilotEvents", &events), + synthetic_source("prof-report", "mdmReport", &esp_handoff_report("prof-report")), + synthetic_source("esp-facts", "espSession", &esp_session_on_activity(success_activity)), + ])) +} + +const PROFILE_NEGATIVE_MESSAGE: &str = + "ZtdDeviceHasNoAssignedProfile - No profile assigned to the device and no default profile."; +const PROFILE_RETRIEVE_MESSAGE: &str = "AutopilotManager retrieve settings succeeded."; +const PROFILE_STATE_MESSAGE: &str = + "AutopilotManager reported the state changed from ProfileState_Available to ProfileState_Provisioned."; + +/// ADR-003: an explicit negative profile signal (815, NoAssignedProfile) must +/// not be silently erased by a later success (161 + 153) that shares no retry +/// linkage with it. Here the negative carries no activity id while the +/// successes and the ESP session share `attempt-1`, so the ESP handoff links +/// and every completion gate except the profile candidate is open. Without an +/// explicit retry link the reducer must stay conservative: the recorded +/// negative stands and the enrollment is not Completed. +#[test] +fn an_unlinked_success_cannot_erase_an_earlier_no_profile_negative() { + let snapshot = reduce_profile_retry( + vec![ + profile_event(1, 815, None, PROFILE_NEGATIVE_MESSAGE), + profile_event(2, 161, Some("attempt-1"), PROFILE_RETRIEVE_MESSAGE), + profile_event(3, 153, Some("attempt-1"), PROFILE_STATE_MESSAGE), + ], + "attempt-1", + ); + + // Sanity: the ESP handoff path is genuinely open, so the only thing that can + // hold the outcome back from Completed is the unlinked profile negative. + assert_eq!( + snapshot.esp_linkage.state, + AutopilotEspLinkState::Linked, + "the ESP session must link so this test isolates the profile-linkage gate" + ); + assert_ne!( + snapshot.outcome, + AutopilotOutcome::Completed, + "an unlinked later success must not complete over an explicit negative" + ); + assert_eq!( + snapshot.outcome, + AutopilotOutcome::NoProfileCandidate, + "the conservative result is the explicit negative that was recorded, not success" + ); +} + +/// The linkage-permitted companion: when the negative and the successes share +/// the same activity id, the success is a proven retry of the same attempt and +/// may raise the candidate to Available, reaching Completed. +#[test] +fn a_retry_linked_success_completes_over_an_earlier_negative() { + let snapshot = reduce_profile_retry( + vec![ + profile_event(1, 815, Some("attempt-1"), PROFILE_NEGATIVE_MESSAGE), + profile_event(2, 161, Some("attempt-1"), PROFILE_RETRIEVE_MESSAGE), + profile_event(3, 153, Some("attempt-1"), PROFILE_STATE_MESSAGE), + ], + "attempt-1", + ); + + assert_eq!( + snapshot.outcome, + AutopilotOutcome::Completed, + "a retry-linked success may replace the earlier negative and complete" + ); +} + +/// ADR-003: input vector order is not chronology. The unlinked negative-then- +/// success verdict must not change when the events are permuted. +#[test] +fn the_profile_linkage_verdict_is_invariant_under_input_order() { + let forward = reduce_profile_retry( + vec![ + profile_event(1, 815, None, PROFILE_NEGATIVE_MESSAGE), + profile_event(2, 161, Some("attempt-1"), PROFILE_RETRIEVE_MESSAGE), + profile_event(3, 153, Some("attempt-1"), PROFILE_STATE_MESSAGE), + ], + "attempt-1", + ); + let reversed = reduce_profile_retry( + vec![ + profile_event(3, 153, Some("attempt-1"), PROFILE_STATE_MESSAGE), + profile_event(2, 161, Some("attempt-1"), PROFILE_RETRIEVE_MESSAGE), + profile_event(1, 815, None, PROFILE_NEGATIVE_MESSAGE), + ], + "attempt-1", + ); + + assert_eq!( + forward.outcome, reversed.outcome, + "permuting non-ordered input must not change the outcome" + ); + assert_eq!(forward.outcome, AutopilotOutcome::NoProfileCandidate); +} + +/// ADR-001, finding side: a non-assessable failure event cannot produce a +/// high-confidence terminal FINDING, exactly as it cannot produce a terminal +/// OUTCOME. A capped event 171 (`TpmIdentityFailed`) with no assessable +/// identity-mismatch evidence must not emit the `Blocker`/`High` +/// `autopilot-identity-registration-mismatch` finding. The recorded failure is +/// still surfaced -- by the low-confidence non-assessable finding -- so it is +/// blocked visibly rather than silently. +#[test] +fn a_non_assessable_tpm_failure_cannot_emit_a_high_confidence_blocker() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [synthetic_event( + "tpm-capped", "tpm-channel", 1, 171, "capped", "parsed", + json!([]), + "AutopilotManager failed to confirm the TPM identity for this device.", + )] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![synthetic_source( + "tpm-channel", + "autopilotEvents", + &events, + )])); + + assert_ne!( + snapshot.outcome, + AutopilotOutcome::IdentityRegistrationMismatch, + "a capped 171 cannot prove the terminal outcome" + ); + assert!( + !snapshot.findings.iter().any(|finding| { + finding.finding_id == "autopilot-identity-registration-mismatch" + }), + "a non-assessable 171 must not emit the high-confidence identity-mismatch blocker, got {:?}", + snapshot + .findings + .iter() + .map(|finding| &finding.finding_id) + .collect::>() + ); + // The recorded failure must still be visible, just not as a terminal claim. + assert!( + snapshot.findings.iter().any(|finding| { + finding.finding_id == "autopilot-non-assessable-failure-recorded" + }), + "the recorded failure must be surfaced by its own low-confidence finding" + ); +} + +/// The regression companion: an *assessable* event 171 must still emit the +/// `autopilot-identity-registration-mismatch` blocker. The assessability +/// boundary must gate non-assessable evidence without silencing the real +/// terminal finding. +#[test] +fn an_assessable_tpm_failure_still_emits_the_identity_mismatch_blocker() { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [synthetic_event( + "tpm-ok", "tpm-channel", 1, 171, "available", "parsed", + json!([]), + "AutopilotManager failed to confirm the TPM identity for this device.", + )] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![synthetic_source( + "tpm-channel", + "autopilotEvents", + &events, + )])); + + let finding = snapshot + .findings + .iter() + .find(|finding| finding.finding_id == "autopilot-identity-registration-mismatch") + .expect("an assessable 171 must still emit the identity-mismatch blocker"); + assert_eq!(wire(&finding.severity), json!("blocker")); + assert_eq!(wire(&finding.confidence), json!("high")); +} + +/// ADR-004: a Base64 hardware hash quoted in an observation message must be +/// absent from the exported projection even when it ends in a non-word Base64 +/// character (`=`, `==`, `+`, `/`). Free-text redaction, not whole-value +/// masking, owns this path, so the export is the honest place to pin it. +#[test] +fn a_base64_hash_in_an_observation_message_never_survives_the_export() { + for blob in [ + format!("{}=", "Q".repeat(43)), + format!("{}==", "Q".repeat(42)), + format!("{}+", "Q".repeat(43)), + format!("{}/", "Q".repeat(43)), + ] { + let events = json!({ + "autopilotDocument": "autopilot.events", + "documentVersion": 1, + "events": [synthetic_event( + "blob-e1", "blob-channel", 1, 161, "available", "parsed", + json!([]), + &format!("AutopilotManager reported hardware hash {blob} for this device."), + )] + }); + let snapshot = reduce_autopilot_bundle(&synthetic_bundle(vec![synthetic_source( + "blob-channel", + "autopilotEvents", + &events, + )])); + let redacted = redacted_export_projection(&snapshot); + let text = + serde_json::to_string(&wire(&redacted)).expect("redacted export must serialize"); + assert!( + !text.contains(&blob), + "the Base64 hash {blob:?} survived the exported projection: {text}" + ); + } +} + // ── Golden maintenance ────────────────────────────────────────────────────── /// Rewrite every scenario's `findings` golden from the current reducer output. From ae6e2ac6352838fae79f6be1daf53031f4fcb8ec Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 22:40:15 -0400 Subject: [PATCH 14/14] docs(intune): correct the section_activity_id doc and tighten the blob test CodeRabbit's two trivial findings on the Hermes P1 round, both valid. The section_activity_id doc claimed it lowercased its return value; it returns verbatim and push_link owns the lowercasing, so the doc now says where the normalization lives. The Base64 export test asserted only that the whole blob was absent, which would have passed while a masked body left its punctuation tail behind - the exact partial match the old word-boundary pattern produced - so it now asserts the body and the dangling tail are gone too. Verified: cargo test -p cmtraceopen-parser 2150 passed 0 failed (autopilot suite 47 passed), clippy -D warnings clean. Refs #362 Co-Authored-By: Claude Fable 5 --- .../intune/enrollment/windows/autopilot/reducer.rs | 8 ++++++-- .../tests/intune_windows_autopilot.rs | 13 +++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs index 10af4ec8d..0833a1fb0 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs @@ -1006,8 +1006,12 @@ fn reduce_profile( } } -/// The `activityId` a report section carried in its values, lowercased so retry -/// linkage compares case-insensitively like the ESP correlation keys. +/// The `activityId` a report section carried in its values, verbatim. +/// +/// The value name is matched case-insensitively; the value itself is returned +/// unchanged. [`push_link`] owns the lowercasing that makes retry linkage +/// compare case-insensitively like the ESP correlation keys, so every key +/// reaches that one place in its original form. fn section_activity_id(section: &AutopilotReportSection) -> Option<&str> { section .values diff --git a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs index 1bc74f32f..c97198b50 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs @@ -1891,6 +1891,19 @@ fn a_base64_hash_in_an_observation_message_never_survives_the_export() { !text.contains(&blob), "the Base64 hash {blob:?} survived the exported projection: {text}" ); + // The whole-blob check alone would pass while a masked body left its + // punctuation tail behind, which is the exact partial match the old + // word-boundary pattern produced. Assert the tail is gone too. + let body = &blob[..blob.len() - 1]; + assert!( + !text.contains(body), + "the Base64 body {body:?} survived the exported projection: {text}" + ); + let tail = &blob[blob.len() - 1..]; + assert!( + !text.contains(&format!("] {tail}")) && !text.contains(&format!("]{tail}")), + "the Base64 tail {tail:?} dangled after the mask token: {text}" + ); } }