From 99ec25175dd4f8b1c13a45530867fa650781959a Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 30 Apr 2026 19:13:59 +0200 Subject: [PATCH] feat(agent): backport V1 TenantIdResolver with CloudDomainJoin fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the V2 resolver shipped in fc950757 to V1 so the legacy rail gets the same OOBE-race protection. Probe order: Enrollments registry → CloudDomainJoin\TenantInfo → CloudDomainJoin\JoinInfo, source-tagged log line on hit, full diagnostic block on miss (which roots exist, which enrollment GUIDs were seen with their EnrollmentType / EnrollmentState, which JoinInfo thumbprints). Removes the inline GetTenantIdFromRegistry helper from V1 Program.cs and routes all three callsites (LoadConfiguration, await-enrollment re-resolve, pre-flight bail) through the new resolver. The bail site re-probes with a logger when the await branch was skipped, so the diagnostic block lands in the agent log on a real miss instead of just the generic "registry empty" error. Updates a stale doc comment in V2 Program.GatherRulesMode.cs that referenced the removed V1 method name. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Security/TenantIdResolver.cs | 281 ++++++++++++++++++ .../Program.GatherRulesMode.cs | 4 +- .../buildcounter.txt | 2 +- .../Program.Configuration.cs | 40 +-- src/Agent/AutopilotMonitor.Agent/Program.cs | 24 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- 6 files changed, 307 insertions(+), 46 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs new file mode 100644 index 00000000..36c5551a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.Core.Logging; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.Core.Security +{ + /// + /// Resolves the device's AAD TenantId from one of several Windows registry signals. + /// V1 backport of the V2 resolver — same probe order, same diagnostic logging. + /// + /// Probe order, first non-empty hit wins: + /// + /// + /// + /// HKLM\SOFTWARE\Microsoft\Enrollments\{guid} with EnrollmentType=6 + /// (Intune MDM) and AADTenantID. Authoritative once MDM enrollment finished. + /// + /// + /// HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo\{TenantId} + /// — the sub-key name itself is the TenantId GUID. Written by AAD join, which in + /// Autopilot/OOBE happens before MDM enrollment. + /// + /// + /// HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\{thumbprint} + /// value TenantId. Same AAD-join phase as TenantInfo, used as a secondary + /// reading because some flows populate one but not the other. + /// + /// + /// + /// Returns null when no source carries a TenantId. Never throws. On miss the + /// resolver dumps a diagnostic summary of which keys/values it saw so the bootstrap + /// log carries enough evidence to classify "device truly not enrolled" vs. "agent + /// fired before enrollment finished writing". + /// + /// + public static class TenantIdResolver + { + private const string EnrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; + private const string CloudDomainJoinTenantInfoPath = @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo"; + private const string CloudDomainJoinJoinInfoPath = @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"; + private const int IntuneMdmEnrollmentType = 6; + + public const string SourceEnrollmentsRegistry = "enrollments_registry"; + public const string SourceCloudDomainJoinTenantInfo = "cloud_domain_join_tenant_info"; + public const string SourceCloudDomainJoinJoinInfo = "cloud_domain_join_join_info"; + + /// + /// Tries each probe in order and returns the first non-empty TenantId. + /// Logs the winning source at Info level. On full miss, logs a diagnostic + /// summary at Warning level. Returns null when nothing resolves. + /// + public static string Resolve(AgentLogger logger = null) + { + var diagnostics = new ResolverDiagnostics(); + + var fromEnrollments = TryEnrollmentsRegistry(diagnostics); + if (!string.IsNullOrEmpty(fromEnrollments)) + { + logger?.Info($"TenantIdResolver: resolved TenantId={fromEnrollments} from {SourceEnrollmentsRegistry}."); + return fromEnrollments; + } + + var fromTenantInfo = TryCloudDomainJoinTenantInfo(diagnostics); + if (!string.IsNullOrEmpty(fromTenantInfo)) + { + logger?.Info($"TenantIdResolver: resolved TenantId={fromTenantInfo} from {SourceCloudDomainJoinTenantInfo} (Enrollments registry had no Intune MDM hit)."); + return fromTenantInfo; + } + + var fromJoinInfo = TryCloudDomainJoinJoinInfo(diagnostics); + if (!string.IsNullOrEmpty(fromJoinInfo)) + { + logger?.Info($"TenantIdResolver: resolved TenantId={fromJoinInfo} from {SourceCloudDomainJoinJoinInfo} (Enrollments registry + CloudDomainJoin\\TenantInfo had no hit)."); + return fromJoinInfo; + } + + LogResolutionMiss(logger, diagnostics); + return null; + } + + private static string TryEnrollmentsRegistry(ResolverDiagnostics diag) + { + try + { + using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(EnrollmentsKeyPath)) + { + if (enrollmentsKey == null) + { + diag.EnrollmentsRootMissing = true; + return null; + } + + foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames()) + { + using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid)) + { + if (enrollmentKey == null) continue; + + var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); + int? typeValue = null; + if (enrollmentType != null) + { + try { typeValue = Convert.ToInt32(enrollmentType); } + catch { /* unparseable — record as null */ } + } + + var aadTenantId = enrollmentKey.GetValue("AADTenantID")?.ToString(); + var enrollmentState = enrollmentKey.GetValue("EnrollmentState")?.ToString(); + + diag.EnrollmentsSeen.Add(new EnrollmentSnapshot + { + Guid = enrollmentGuid, + EnrollmentType = typeValue, + EnrollmentState = enrollmentState, + HasAadTenantId = !string.IsNullOrEmpty(aadTenantId), + }); + + if (typeValue == IntuneMdmEnrollmentType && !string.IsNullOrEmpty(aadTenantId)) + return aadTenantId; + } + } + } + } + catch (Exception ex) + { + diag.EnrollmentsProbeError = ex.GetType().Name + ": " + ex.Message; + } + + return null; + } + + private static string TryCloudDomainJoinTenantInfo(ResolverDiagnostics diag) + { + try + { + using (var tenantInfoKey = Registry.LocalMachine.OpenSubKey(CloudDomainJoinTenantInfoPath)) + { + if (tenantInfoKey == null) + { + diag.TenantInfoRootMissing = true; + return null; + } + + var subKeys = tenantInfoKey.GetSubKeyNames(); + diag.TenantInfoSubKeys.AddRange(subKeys); + + foreach (var subKey in subKeys) + { + if (Guid.TryParse(subKey, out _)) + return subKey; + } + } + } + catch (Exception ex) + { + diag.TenantInfoProbeError = ex.GetType().Name + ": " + ex.Message; + } + + return null; + } + + private static string TryCloudDomainJoinJoinInfo(ResolverDiagnostics diag) + { + try + { + using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(CloudDomainJoinJoinInfoPath)) + { + if (joinInfoKey == null) + { + diag.JoinInfoRootMissing = true; + return null; + } + + foreach (var thumbprint in joinInfoKey.GetSubKeyNames()) + { + using (var entry = joinInfoKey.OpenSubKey(thumbprint)) + { + if (entry == null) continue; + + var tenantId = entry.GetValue("TenantId")?.ToString(); + diag.JoinInfoEntries.Add(new JoinInfoSnapshot + { + Thumbprint = thumbprint, + HasTenantId = !string.IsNullOrEmpty(tenantId), + }); + + if (!string.IsNullOrEmpty(tenantId)) + return tenantId; + } + } + } + } + catch (Exception ex) + { + diag.JoinInfoProbeError = ex.GetType().Name + ": " + ex.Message; + } + + return null; + } + + private static void LogResolutionMiss(AgentLogger logger, ResolverDiagnostics diag) + { + if (logger == null) return; + + logger.Warning("TenantIdResolver: no TenantId resolvable from registry — device likely not yet AAD-joined / MDM-enrolled, or enrollment write hasn't completed."); + + // Enrollments root. + if (diag.EnrollmentsProbeError != null) + logger.Warning($"TenantIdResolver: Enrollments probe failed: {diag.EnrollmentsProbeError}"); + else if (diag.EnrollmentsRootMissing) + logger.Warning($"TenantIdResolver: HKLM\\{EnrollmentsKeyPath} not found."); + else if (diag.EnrollmentsSeen.Count == 0) + logger.Warning($"TenantIdResolver: HKLM\\{EnrollmentsKeyPath} present but empty."); + else + { + logger.Warning($"TenantIdResolver: HKLM\\{EnrollmentsKeyPath} carries {diag.EnrollmentsSeen.Count} enrollment(s) but none was Intune MDM (Type=6) with AADTenantID:"); + foreach (var e in diag.EnrollmentsSeen) + { + logger.Warning( + $" - {e.Guid}: EnrollmentType={(e.EnrollmentType?.ToString() ?? "")}, " + + $"EnrollmentState={e.EnrollmentState ?? ""}, " + + $"AADTenantID={(e.HasAadTenantId ? "" : "")}"); + } + } + + // CloudDomainJoin\TenantInfo. + if (diag.TenantInfoProbeError != null) + logger.Warning($"TenantIdResolver: TenantInfo probe failed: {diag.TenantInfoProbeError}"); + else if (diag.TenantInfoRootMissing) + logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinTenantInfoPath} not found (device not AAD-joined yet)."); + else if (diag.TenantInfoSubKeys.Count == 0) + logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinTenantInfoPath} present but no sub-keys."); + else + logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinTenantInfoPath} sub-keys: [{string.Join(", ", diag.TenantInfoSubKeys)}] — none parsed as a GUID."); + + // CloudDomainJoin\JoinInfo. + if (diag.JoinInfoProbeError != null) + logger.Warning($"TenantIdResolver: JoinInfo probe failed: {diag.JoinInfoProbeError}"); + else if (diag.JoinInfoRootMissing) + logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinJoinInfoPath} not found."); + else if (diag.JoinInfoEntries.Count == 0) + logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinJoinInfoPath} present but no thumbprint sub-keys."); + else + { + logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinJoinInfoPath} carries {diag.JoinInfoEntries.Count} entry/entries but none had a non-empty TenantId value:"); + foreach (var j in diag.JoinInfoEntries) + logger.Warning($" - thumbprint={j.Thumbprint}, TenantId={(j.HasTenantId ? "" : "")}"); + } + } + + private sealed class ResolverDiagnostics + { + public bool EnrollmentsRootMissing; + public string EnrollmentsProbeError; + public readonly List EnrollmentsSeen = new List(); + + public bool TenantInfoRootMissing; + public string TenantInfoProbeError; + public readonly List TenantInfoSubKeys = new List(); + + public bool JoinInfoRootMissing; + public string JoinInfoProbeError; + public readonly List JoinInfoEntries = new List(); + } + + private struct EnrollmentSnapshot + { + public string Guid; + public int? EnrollmentType; + public string EnrollmentState; + public bool HasAadTenantId; + } + + private struct JoinInfoSnapshot + { + public string Thumbprint; + public bool HasTenantId; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs index 6d51cf3d..cf5a6cef 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs @@ -21,8 +21,8 @@ namespace AutopilotMonitor.Agent.V2 /// /// Fetches remote config, executes all startup gather rules against a freshly-registered /// ephemeral session, uploads the collected events, and exits. Parity with Legacy - /// Program.GatherRulesMode.cs — the V2 differences are minimal (V2 TenantIdResolver - /// instead of GetTenantIdFromRegistry, V2 BackendApiClient ctor). + /// Program.GatherRulesMode.cs — the V2 differences are minimal (V2 BackendApiClient + /// ctor; both rails now share the same TenantIdResolver shape). /// /// public static partial class Program diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 3091e8ee..80e3e6ea 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -504 +506 diff --git a/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs b/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs index 7543c0fb..39bf82f9 100644 --- a/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs +++ b/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs @@ -5,6 +5,7 @@ using System.Reflection; using AutopilotMonitor.Agent.Core.Configuration; using AutopilotMonitor.Agent.Core.Logging; using AutopilotMonitor.Agent.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.Core.Security; using AutopilotMonitor.Shared; using Microsoft.Win32; using AutopilotMonitor.Agent.Core.Monitoring.Enrollment; @@ -17,39 +18,6 @@ namespace AutopilotMonitor.Agent { partial class Program { - static string GetTenantIdFromRegistry() - { - try - { - const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; - - using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath)) - { - if (enrollmentsKey == null) - return null; - - foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames()) - { - using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid)) - { - if (enrollmentKey == null) continue; - - var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); - if (enrollmentType != null && Convert.ToInt32(enrollmentType) == 6) - { - var tenantId = enrollmentKey.GetValue("AADTenantID"); - if (tenantId != null) - return tenantId.ToString(); - } - } - } - } - } - catch { } - - return null; - } - static AgentConfiguration LoadConfiguration(string[] args = null) { var noCleanup = args?.Contains("--no-cleanup") ?? false; @@ -119,8 +87,10 @@ namespace AutopilotMonitor.Agent selfDestructOnComplete = false; } - // Determine TenantId from registry (or bootstrap-config.json fallback below) - string tenantId = GetTenantIdFromRegistry(); + // Determine TenantId from registry (or bootstrap-config.json fallback below). + // No logger available at config-load time — the bail-site below re-probes with + // a logger so the diagnostic block lands in the agent log on a real miss. + string tenantId = TenantIdResolver.Resolve(logger: null); var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); var sessionPersist = new SessionPersistence(dataDirectory); diff --git a/src/Agent/AutopilotMonitor.Agent/Program.cs b/src/Agent/AutopilotMonitor.Agent/Program.cs index f92ec6ac..d6da06b8 100644 --- a/src/Agent/AutopilotMonitor.Agent/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent/Program.cs @@ -220,20 +220,20 @@ namespace AutopilotMonitor.Agent if (consoleMode) Console.WriteLine($"MDM certificate found: {cert.Thumbprint}"); - // Re-read TenantId from registry — enrollment creates the registry key - // alongside the certificate, so it should be available now. + // Re-read TenantId — enrollment creates the registry key alongside the + // certificate, so it should be available now. TenantIdResolver also probes + // CloudDomainJoin as a fallback and emits a source-tagged log line on hit. if (string.IsNullOrEmpty(config.TenantId)) { - config.TenantId = GetTenantIdFromRegistry(); + config.TenantId = TenantIdResolver.Resolve(logger); if (!string.IsNullOrEmpty(config.TenantId)) { - logger.Info($"Await-enrollment: TenantId discovered from registry: {config.TenantId}"); if (consoleMode) Console.WriteLine($"Tenant ID: {config.TenantId}"); } else { - logger.Warning("Await-enrollment: MDM certificate found but TenantId not yet in registry"); + logger.Warning("Await-enrollment: MDM certificate found but TenantId still not resolvable."); } } @@ -257,8 +257,18 @@ namespace AutopilotMonitor.Agent // Pre-flight: bail cleanly if TenantId is missing instead of crashing inside // MonitoringService.IsValid(). Mirrors V2's AgentBootstrap guard. Common cause: - // agent scheduled before MDM enrollment populated HKLM\SOFTWARE\Microsoft\Enrollments - // and no bootstrap-config.json present. The Scheduled Task will retry on next trigger. + // agent scheduled before MDM enrollment / AAD join populated the registry and + // no bootstrap-config.json present. The Scheduled Task will retry on next trigger. + // + // Re-probe with the logger if we never went through the await-enrollment branch + // (LoadConfiguration ran without a logger, so its initial probe was silent). The + // re-probe either resolves the race (registry caught up between config load and + // here) or emits the diagnostic block so the agent log captures registry state. + if (string.IsNullOrEmpty(config.TenantId) && !config.AwaitEnrollment) + { + config.TenantId = TenantIdResolver.Resolve(logger); + } + if (string.IsNullOrEmpty(config.TenantId)) { logger.Error("Agent cannot start: TenantId not available (registry empty + no bootstrap config). " + diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 4f720374..fe1b0608 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1227 +1228