diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DeviceInfo/DeviceInfoCollectorAutopilotProfileMissingTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DeviceInfo/DeviceInfoCollectorAutopilotProfileMissingTests.cs new file mode 100644 index 00000000..a669670d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DeviceInfo/DeviceInfoCollectorAutopilotProfileMissingTests.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo; + +/// +/// Pins the "no Autopilot profile" detection that drives the autopilot_profile_missing +/// warning event. Evidence-based: only an explicit ProfileAvailable=0 counts as missing — +/// an absent value is no evidence either way and must NOT trigger the event (a device +/// with a real profile has ProfileAvailable=1; older builds may not write the value). +/// Payload sample below mirrors the real-world stub Windows caches when the ZTD service +/// returns no profile (session 423b5360: empty ZeroTouchConfig, no tenant domain). +/// +public class DeviceInfoCollectorAutopilotProfileMissingTests +{ + // Real-world AutopilotPolicyCache stub of a device without an Autopilot profile: + // PolicyJsonCache contains CloudAssignedAadServerData as an escaped JSON string + // whose ZeroTouchConfig has an empty CloudAssignedTenantDomain. + private const string PolicyJsonCacheWithoutProfile = + "{\r\n \"AutopilotCreationDate\": \"2026-07-16T13:48:31Z\",\r\n \"AutopilotUpdateTimeout\": 1800000,\r\n \"AutopilotMode\": 0,\r\n \"AutopilotCorrelationVector\": \"W1D16N3gSUORhUV2.0\",\r\n \"CloudAssignedAadServerData\": \"{\\u0022ZeroTouchConfig\\u0022:{\\u0022CloudAssignedTenantDomain\\u0022:\\u0022\\u0022,\\u0022CloudAssignedTenantUpn\\u0022:\\u0022\\u0022,\\u0022ForcedEnrollment\\u0022:0}}\"\r\n}"; + + private const string PolicyJsonCacheWithProfile = + "{\"AutopilotMode\": 0, \"CloudAssignedAadServerData\": \"{\\u0022ZeroTouchConfig\\u0022:{\\u0022CloudAssignedTenantDomain\\u0022:\\u0022contoso.com\\u0022,\\u0022ForcedEnrollment\\u0022:1}}\"}"; + + [Fact] + public void IsAutopilotProfileMissing_ProfileAvailableZero_ReturnsTrue() + { + var data = new Dictionary + { + { "ProfileAvailable", "0" }, + { "PolicyJsonCache", PolicyJsonCacheWithoutProfile } + }; + + Assert.True(DeviceInfoCollector.IsAutopilotProfileMissing(data)); + } + + [Fact] + public void IsAutopilotProfileMissing_ProfileAvailableOne_ReturnsFalse() + { + var data = new Dictionary { { "ProfileAvailable", "1" } }; + + Assert.False(DeviceInfoCollector.IsAutopilotProfileMissing(data)); + } + + [Fact] + public void IsAutopilotProfileMissing_ValueAbsent_ReturnsFalse() + { + // No ProfileAvailable value = no evidence — must not claim the profile is missing. + var data = new Dictionary { { "PolicyJsonCache", PolicyJsonCacheWithProfile } }; + + Assert.False(DeviceInfoCollector.IsAutopilotProfileMissing(data)); + } + + [Fact] + public void IsAutopilotProfileMissing_NullData_ReturnsFalse() + { + Assert.False(DeviceInfoCollector.IsAutopilotProfileMissing(null)); + } + + [Fact] + public void TryExtractZeroTouchTenantDomain_EmbeddedInPolicyJsonCache_EmptyDomain() + { + var data = new Dictionary { { "PolicyJsonCache", PolicyJsonCacheWithoutProfile } }; + + Assert.True(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(data, out var domain)); + Assert.Equal(string.Empty, domain); + } + + [Fact] + public void TryExtractZeroTouchTenantDomain_EmbeddedInPolicyJsonCache_RealDomain() + { + var data = new Dictionary { { "PolicyJsonCache", PolicyJsonCacheWithProfile } }; + + Assert.True(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(data, out var domain)); + Assert.Equal("contoso.com", domain); + } + + [Fact] + public void TryExtractZeroTouchTenantDomain_TopLevelServerData_Wins() + { + // When the registry exposes CloudAssignedAadServerData as its own value it is + // preferred over the PolicyJsonCache-embedded copy. + var data = new Dictionary + { + { "CloudAssignedAadServerData", "{\"ZeroTouchConfig\":{\"CloudAssignedTenantDomain\":\"fabrikam.com\"}}" }, + { "PolicyJsonCache", PolicyJsonCacheWithProfile } + }; + + Assert.True(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(data, out var domain)); + Assert.Equal("fabrikam.com", domain); + } + + [Theory] + [InlineData("not json at all")] + [InlineData("{\"CloudAssignedAadServerData\": 42}")] // wrong type + [InlineData("{\"SomethingElse\": true}")] // property absent + public void TryExtractZeroTouchTenantDomain_UnusablePolicyJsonCache_ReturnsFalse(string policyJsonCache) + { + var data = new Dictionary { { "PolicyJsonCache", policyJsonCache } }; + + Assert.False(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(data, out var domain)); + Assert.Null(domain); + } + + [Fact] + public void TryExtractZeroTouchTenantDomain_NullData_ReturnsFalse() + { + Assert.False(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(null, out var domain)); + Assert.Null(domain); + } +} 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 bd7bfce6..1334c9c4 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 @@ -286,6 +286,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data); + // ProfileAvailable=0 means the ZTD service returned no profile for this device + // during OOBE (not Autopilot-registered, profile assignment not propagated yet, + // or device deleted from Autopilot). Such a device still enrolls via manual + // Entra join + MDM auto-enrollment and would otherwise look like a regular + // Autopilot session — surface the edge case explicitly. + if (IsAutopilotProfileMissing(data)) + { + var missingData = new Dictionary + { + { "profileAvailable", "0" }, + { "likelyCauses", "not_registered_in_autopilot,profile_assignment_not_propagated,deleted_from_autopilot" } + }; + if (TryExtractZeroTouchTenantDomain(data, out var ztdTenantDomain)) + { + missingData["zeroTouchTenantDomain"] = ztdTenantDomain ?? ""; + missingData["zeroTouchTenantDomainEmpty"] = string.IsNullOrEmpty(ztdTenantDomain); + } + + EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfileMissing, + "No Autopilot profile cached on this device (ProfileAvailable=0) — the device was likely not Autopilot-registered when OOBE ran; this enrollment appears to be a manual Entra ID join, not an Autopilot deployment.", + missingData, + EventSeverity.Warning); + + _logger.Warning("EnrollmentTracker: no Autopilot profile cached (ProfileAvailable=0) — non-Autopilot OOBE enrollment suspected"); + } + // Emit dedicated enrollment_type_detected event for easy filtering. Routed through // EmitDeviceInfoEvent (same Severity/Source/Phase) so the restart dedup applies. EmitDeviceInfoEvent(Constants.EventTypes.EnrollmentTypeDetected, @@ -314,6 +340,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo return (detectedType, isHybridJoin, detectedAutopilotMode); } + /// + /// True when the AutopilotPolicyCache explicitly recorded that no Autopilot profile + /// is available (ProfileAvailable=0). Windows writes this when the ZTD service + /// returned no profile for the device's hardware hash during OOBE. An absent + /// ProfileAvailable value is NOT treated as missing (no evidence either way). + /// + internal static bool IsAutopilotProfileMissing(Dictionary data) + { + return data != null + && data.TryGetValue("ProfileAvailable", out var profileAvailable) + && profileAvailable?.ToString() == "0"; + } + + /// + /// Extracts ZeroTouchConfig.CloudAssignedTenantDomain from the cached profile data. + /// The value lives in the CloudAssignedAadServerData JSON — either as a top-level + /// registry value or embedded as a string property inside PolicyJsonCache. + /// Returns false when it cannot be located (missing values, malformed JSON). + /// + internal static bool TryExtractZeroTouchTenantDomain(Dictionary data, out string tenantDomain) + { + tenantDomain = null; + if (data == null) return false; + + try + { + string serverDataJson = null; + if (data.TryGetValue("CloudAssignedAadServerData", out var topLevel)) + { + serverDataJson = topLevel?.ToString(); + } + else if (data.TryGetValue("PolicyJsonCache", out var policyCache)) + { + var policyRaw = policyCache?.ToString(); + if (!string.IsNullOrWhiteSpace(policyRaw)) + { + using (var pDoc = System.Text.Json.JsonDocument.Parse(policyRaw)) + { + if (pDoc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object && + pDoc.RootElement.TryGetProperty("CloudAssignedAadServerData", out var serverDataProp) && + serverDataProp.ValueKind == System.Text.Json.JsonValueKind.String) + { + serverDataJson = serverDataProp.GetString(); + } + } + } + } + + if (string.IsNullOrWhiteSpace(serverDataJson)) return false; + + using (var doc = System.Text.Json.JsonDocument.Parse(serverDataJson)) + { + if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object && + doc.RootElement.TryGetProperty("ZeroTouchConfig", out var ztc) && + ztc.ValueKind == System.Text.Json.JsonValueKind.Object && + ztc.TryGetProperty("CloudAssignedTenantDomain", out var domainProp) && + domainProp.ValueKind == System.Text.Json.JsonValueKind.String) + { + tenantDomain = domainProp.GetString(); + return true; + } + } + } + catch (Exception) + { + // Malformed cache JSON — treat as not extractable. + } + + return false; + } + private void CollectSecureBootStatus() { try @@ -768,7 +865,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo } } - private void EmitDeviceInfoEvent(string eventType, string message, Dictionary data) + private void EmitDeviceInfoEvent(string eventType, string message, Dictionary data, + EventSeverity severity = EventSeverity.Info) { if (!ShouldEmitDeviceInfoEvent(eventType, data)) return; @@ -777,7 +875,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo SessionId = _sessionId, TenantId = _tenantId, EventType = eventType, - Severity = EventSeverity.Info, + Severity = severity, Source = "EnrollmentTracker", Phase = EnrollmentPhase.Unknown, Message = message, diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 7ae5be65..3f3c9366 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -1329 +1344 diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index c1fb26d8..5c389773 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -94,6 +94,7 @@ export const EVENT_TYPES_CATALOG = { 'secureboot_status', 'bitlocker_status', 'autopilot_profile', + 'autopilot_profile_missing', 'enrollment_type_detected', 'aad_join_status', 'cert_validation', @@ -257,6 +258,11 @@ export const DEVICE_PROPERTIES_CATALOG = { 'autopilot_profile.CloudAssignedDeviceName': { type: 'string', description: 'Cloud-assigned device name template' }, 'autopilot_profile.CloudAssignedLanguage': { type: 'string', description: 'Cloud-assigned language/locale' }, }, + autopilot_profile_missing: { + 'autopilot_profile_missing.profileAvailable': { type: 'string', description: 'Always "0" — Windows recorded that no Autopilot profile was available during OOBE' }, + 'autopilot_profile_missing.zeroTouchTenantDomain': { type: 'string', description: 'CloudAssignedTenantDomain from the cached ZeroTouchConfig (empty when no profile was downloaded)' }, + 'autopilot_profile_missing.likelyCauses': { type: 'string', description: 'Comma-separated candidate causes (not_registered_in_autopilot, profile_assignment_not_propagated, deleted_from_autopilot)' }, + }, hardware_spec: { 'hardware_spec.cpuName': { type: 'string', description: 'CPU model name (e.g. "Intel Core i7-1265U")' }, 'hardware_spec.cpuCores': { type: 'number', description: 'Number of physical CPU cores' }, diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 5f5b1dbe..9f54cf10 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -436,6 +436,7 @@ namespace AutopilotMonitor.Shared public const string DnsConfiguration = "dns_configuration"; public const string ProxyConfiguration = "proxy_configuration"; public const string AutopilotProfile = "autopilot_profile"; + public const string AutopilotProfileMissing = "autopilot_profile_missing"; public const string EnrollmentTypeDetected = "enrollment_type_detected"; public const string SecureBootStatus = "secureboot_status"; public const string BitLockerStatus = "bitlocker_status"; diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx index f284ce9e..410670bc 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx @@ -147,6 +147,11 @@ export default function DeviceDetailsCard({ events, latestAgentVersion }: { even const networkInterfaceInfo = getEventData("network_interface_info"); const wifiSignalInfo = getEventData("wifi_signal_info"); const autopilotProfile = normalizeAutopilotProfile(getEventData("autopilot_profile")); + // Explicit agent signal (new sessions) OR ProfileAvailable=0 in the cached profile + // (also covers sessions recorded before autopilot_profile_missing existed). + const autopilotProfileMissing = + getEventData("autopilot_profile_missing") !== null || + (autopilotProfile !== null && `${autopilotProfile.ProfileAvailable}` === "0"); const aadJoinStatus = getEventData("aad_join_status"); const imeVersion = getEventData("ime_agent_version"); const realmJoinInfo = getEventData("realmjoin_detected"); @@ -379,6 +384,21 @@ export default function DeviceDetailsCard({ events, latestAgentVersion }: { even {/* Autopilot Profile */} {autopilotProfile && ( + {autopilotProfileMissing && ( +
+
+ + + + + No Autopilot profile found on this device.{" "} + It was likely not registered for Windows Autopilot when OOBE ran — this + enrollment appears to be a manual Entra ID join during OOBE, not an + Autopilot deployment. + +
+
+ )} {hasValue(autopilotProfile.CloudAssignedTenantDomain) && } {hasValue(autopilotProfile.DeploymentProfileName) && } {hasValue(autopilotProfile.CloudAssignedTenantId) && } @@ -428,7 +448,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion }: { even )} {hasValue(autopilotProfile.AutopilotCreationDate) && } {hasValue(autopilotProfile.ProfileAvailable) && ( - + )}
)}