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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-17 21:28:43 +02:00
parent c88629dcf2
commit 26cbc897c5
3 changed files with 132 additions and 55 deletions
@@ -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
}
}
/// <summary>
/// Exports the event-log channel behind a winevt .evtx file to a temp copy via
/// <c>wevtutil epl</c>. 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.
/// </summary>
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();
@@ -156,7 +156,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
/// </summary>
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}");
@@ -186,7 +186,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// Defaults to <c>"v1"</c> on any error so a flaky registry does not misclassify a
/// classic session as WDP.
/// </summary>
public static string DetectEnrollmentType()
public static string DetectEnrollmentType() => DetectEnrollmentTypeWithRule().enrollmentType;
/// <summary>
/// Same as <see cref="DetectEnrollmentType"/> 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).
/// </summary>
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)");
}
}
}