From 26cbc897c57d4af94df89d9d23154e8304bee512 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 17 Aug 2026 21:28:43 +0200 Subject: [PATCH] WDP: unify enrollment_type_detected with detector + export locked evtx Two fixes from the a11102f4 diag analysis: - enrollment_type_detected re-implemented the old CloudAssigned*-only detection and kept reporting "Autopilot v1 (Classic ESP)" on WDP devices while the session row already said v2. DeviceInfoCollector now uses EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule() - the same detector as registration - and the event names the rule that fired. - Tenant-configured .evtx paths never made it into the diagnostics ZIP: active event-log channels hold their file exclusively locked. The package service now exports the channel via wevtutil epl (channel name derived from the %4-encoded file name) and streams the temp export. Co-Authored-By: Claude Fable 5 --- .../Runtime/DiagnosticsPackageService.cs | 140 ++++++++++++++---- .../DeviceInfoCollector.NetworkAndSecurity.cs | 26 +--- .../Security/EnrollmentRegistryDetector.cs | 21 ++- 3 files changed, 132 insertions(+), 55 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs index d9dd9390..38f3f133 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs @@ -525,40 +525,69 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime continue; } - if (length > tracker.Budget.MaxSingleFileBytes) + // Active event-log channels hold their .evtx exclusively locked — a raw + // FileStream open fails with a sharing violation. Export the channel via + // wevtutil instead and stream the export (deleted below). Session a11102f4: + // the tenant-configured BootstrapperAgent channel never made it into the ZIP. + string streamSource = file; + string tempExport = null; + if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase)) { - _logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}"); - tracker.RecordSkip(file, "size", length); - continue; - } - if (tracker.WouldExceedCount()) - { - _logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}"); - tracker.RecordSkip(file, "count", length); - continue; - } - if (tracker.WouldExceedTotal(length)) - { - _logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}"); - tracker.RecordSkip(file, "total", length); - continue; + tempExport = TryExportEventLogChannel(file); + if (tempExport == null) + { + tracker.RecordSkip(file, "evtx-export", length); + continue; + } + streamSource = tempExport; + try { length = new FileInfo(tempExport).Length; } + catch { /* keep original length estimate */ } } - // Preserve subfolder structure in the ZIP when includeSubfolders is enabled - var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar); - var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}"; - - var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); - // Stream directly from disk into the entry — no per-file MemoryStream/byte[] - // copy. FileShare.ReadWrite avoids locking conflicts with active log writers. - using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - using (var entryStream = entry.Open()) + try { - fs.CopyTo(entryStream); - } + if (length > tracker.Budget.MaxSingleFileBytes) + { + _logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}"); + tracker.RecordSkip(file, "size", length); + continue; + } + if (tracker.WouldExceedCount()) + { + _logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}"); + tracker.RecordSkip(file, "count", length); + continue; + } + if (tracker.WouldExceedTotal(length)) + { + _logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}"); + tracker.RecordSkip(file, "total", length); + continue; + } - tracker.RecordIncluded(length); - _logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)"); + // Preserve subfolder structure in the ZIP when includeSubfolders is enabled + var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar); + var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}"; + + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + // Stream directly from disk into the entry — no per-file MemoryStream/byte[] + // copy. FileShare.ReadWrite avoids locking conflicts with active log writers. + using (var fs = new FileStream(streamSource, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var entryStream = entry.Open()) + { + fs.CopyTo(entryStream); + } + + tracker.RecordIncluded(length); + _logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)"); + } + finally + { + if (tempExport != null) + { + try { File.Delete(tempExport); } catch { } + } + } } catch (Exception ex) { @@ -567,6 +596,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime } } + /// + /// Exports the event-log channel behind a winevt .evtx file to a temp copy via + /// wevtutil epl. Channel name is derived from the file name (%4 decodes to '/', + /// e.g. "Microsoft-Autopilot-BootstrapperAgent%4BootstrapperAgentServiceLogProvider.evtx" + /// → "Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider"). + /// Returns the temp file path (caller deletes) or null when the export failed. + /// + private string TryExportEventLogChannel(string evtxPath) + { + string tempPath = null; + try + { + var channel = Path.GetFileNameWithoutExtension(evtxPath).Replace("%4", "/"); + tempPath = Path.Combine(Path.GetTempPath(), $"am-evtx-{Guid.NewGuid():N}.evtx"); + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = Path.Combine(Environment.SystemDirectory, "wevtutil.exe"), + Arguments = $"epl \"{channel}\" \"{tempPath}\" /ow:true", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + }; + using (var process = System.Diagnostics.Process.Start(psi)) + { + if (process == null) return null; + if (!process.WaitForExit(30000)) + { + try { process.Kill(); } catch { } + _logger.Warning($"wevtutil export timed out for channel '{channel}'."); + try { File.Delete(tempPath); } catch { } + return null; + } + if (process.ExitCode != 0) + { + var stderr = process.StandardError.ReadToEnd(); + _logger.Warning($"wevtutil export failed for channel '{channel}' (exit {process.ExitCode}): {stderr}"); + try { File.Delete(tempPath); } catch { } + return null; + } + } + return File.Exists(tempPath) ? tempPath : null; + } + catch (Exception ex) + { + _logger.Warning($"Event log export failed for {evtxPath}: {ex.Message}"); + if (tempPath != null) + { + try { File.Delete(tempPath); } catch { } + } + return null; + } + } + private static void WriteTruncatedMarker(ZipArchive archive, BudgetTracker tracker) { var sb = new StringBuilder(); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs index bf73e678..95d394ff 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs @@ -156,7 +156,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo /// private (string enrollmentType, bool isHybridJoin, int? autopilotMode) CollectAutopilotProfile() { - var detectedType = "v1"; + // Single source of truth for the rail classification — the SAME detector the + // session registration uses (incl. the WDP DevicePreparation subkey probe). The + // previous inline CloudAssigned*-only re-implementation kept reporting "v1" on + // Device Preparation devices while the session row already said "v2" (a11102f4). + var (detectedType, detectionRule) = AutopilotMonitor.Agent.V2.Core.Security.EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule(); var isHybridJoin = false; int? detectedAutopilotMode = null; @@ -188,20 +192,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo } } - // Extract key values for enrollment type detection - var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration") - ? data["CloudAssignedDeviceRegistration"]?.ToString() - : null; - var espEnabled = data.ContainsKey("CloudAssignedEspEnabled") - ? data["CloudAssignedEspEnabled"]?.ToString() - : null; - - // Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled - if (deviceReg == "2" || espEnabled == "0") - detectedType = "v2"; - else - detectedType = "v1"; - // --- Interpret AutopilotMode (profile capability, not live state) --- // 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove) var autopilotModeStr = data.ContainsKey("AutopilotMode") @@ -377,11 +367,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo { "enrollmentType", detectedType }, { "CloudAssignedDeviceRegistration", data.ContainsKey("CloudAssignedDeviceRegistration") ? data["CloudAssignedDeviceRegistration"] : "n/a" }, { "CloudAssignedEspEnabled", data.ContainsKey("CloudAssignedEspEnabled") ? data["CloudAssignedEspEnabled"] : "n/a" }, - { "detectionRule", detectedType == "v2" - ? (data.ContainsKey("CloudAssignedDeviceRegistration") && data["CloudAssignedDeviceRegistration"]?.ToString() == "2" - ? "CloudAssignedDeviceRegistration=2" - : "CloudAssignedEspEnabled=0") - : "default (no v2 indicators)" } + { "detectionRule", detectionRule } }); _logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}"); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs index 6de5c353..73b173bc 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs @@ -186,7 +186,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security /// Defaults to "v1" on any error so a flaky registry does not misclassify a /// classic session as WDP. /// - public static string DetectEnrollmentType() + public static string DetectEnrollmentType() => DetectEnrollmentTypeWithRule().enrollmentType; + + /// + /// Same as but also returns WHICH rule fired, so the + /// enrollment_type_detected event can name its evidence instead of re-implementing the + /// detection (the duplicate inline logic in DeviceInfoCollector missed the WDP subkey + /// rule and kept reporting "v1" on Device Preparation devices — session a11102f4). + /// + public static (string enrollmentType, string detectionRule) DetectEnrollmentTypeWithRule() { try { @@ -194,25 +202,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Security AutopilotSettingsKey + @"\DevicePreparation\BootstrapperAgent")) { var executionContext = wdpKey?.GetValue("ExecutionContext")?.ToString(); - if (!string.IsNullOrEmpty(executionContext)) return "v2"; + if (!string.IsNullOrEmpty(executionContext)) + return ("v2", @"DevicePreparation\BootstrapperAgent\ExecutionContext present"); } using (var key = Registry.LocalMachine.OpenSubKey(AutopilotSettingsKey)) { - if (key == null) return "v1"; + if (key == null) return ("v1", "AutopilotSettings key missing"); var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString(); - if (deviceReg == "2") return "v2"; + if (deviceReg == "2") return ("v2", "CloudAssignedDeviceRegistration=2"); var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString(); - if (espEnabled == "0") return "v2"; + if (espEnabled == "0") return ("v2", "CloudAssignedEspEnabled=0"); } } catch { // Fall through to v1 default. } - return "v1"; + return ("v1", "default (no v2 indicators)"); } } }