mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+111
-29
@@ -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();
|
||||
|
||||
+6
-20
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user