mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(agent): surface missing Autopilot profile as explicit warning signal
Devices that reach OOBE without an Autopilot profile (ProfileAvailable=0 in AutopilotPolicyCache — not registered, assignment not propagated, or deleted from Autopilot) still enroll via manual Entra join + MDM auto-enrollment and previously looked like regular Autopilot sessions with an inexplicably empty profile section (session 423b5360). - Agent: CollectAutopilotProfile emits autopilot_profile_missing (Warning) on explicit ProfileAvailable=0 evidence, with ZeroTouch tenant-domain extraction as supporting data; EmitDeviceInfoEvent gains an optional severity parameter - Web: DeviceDetailsCard shows a callout in the Autopilot Profile section (also retroactively for old sessions via ProfileAvailable=0) and renders Profile Available 0 as "No" - MCP: resource-catalog gains the new event type + field docs - Tests: 11 unit tests for the detection/extraction helpers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+112
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<string, object>
|
||||
{
|
||||
{ "ProfileAvailable", "0" },
|
||||
{ "PolicyJsonCache", PolicyJsonCacheWithoutProfile }
|
||||
};
|
||||
|
||||
Assert.True(DeviceInfoCollector.IsAutopilotProfileMissing(data));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAutopilotProfileMissing_ProfileAvailableOne_ReturnsFalse()
|
||||
{
|
||||
var data = new Dictionary<string, object> { { "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<string, object> { { "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<string, object> { { "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<string, object> { { "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<string, object>
|
||||
{
|
||||
{ "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<string, object> { { "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);
|
||||
}
|
||||
}
|
||||
+100
-2
@@ -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<string, object>
|
||||
{
|
||||
{ "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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
internal static bool IsAutopilotProfileMissing(Dictionary<string, object> data)
|
||||
{
|
||||
return data != null
|
||||
&& data.TryGetValue("ProfileAvailable", out var profileAvailable)
|
||||
&& profileAvailable?.ToString() == "0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
internal static bool TryExtractZeroTouchTenantDomain(Dictionary<string, object> 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<string, object> data)
|
||||
private void EmitDeviceInfoEvent(string eventType, string message, Dictionary<string, object> 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,
|
||||
|
||||
@@ -1 +1 @@
|
||||
1329
|
||||
1344
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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";
|
||||
|
||||
+21
-1
@@ -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 && (
|
||||
<DetailSection title="Autopilot Profile">
|
||||
{autopilotProfileMissing && (
|
||||
<div className="my-1 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs text-amber-800">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
<span className="font-semibold">No Autopilot profile found on this device.</span>{" "}
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasValue(autopilotProfile.CloudAssignedTenantDomain) && <DetailRow label="Tenant Domain" value={`${autopilotProfile.CloudAssignedTenantDomain}`} />}
|
||||
{hasValue(autopilotProfile.DeploymentProfileName) && <DetailRow label="Profile Name" value={`${autopilotProfile.DeploymentProfileName}`} />}
|
||||
{hasValue(autopilotProfile.CloudAssignedTenantId) && <DetailRow label="Tenant ID" value={`${autopilotProfile.CloudAssignedTenantId}`} />}
|
||||
@@ -428,7 +448,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion }: { even
|
||||
)}
|
||||
{hasValue(autopilotProfile.AutopilotCreationDate) && <DetailRow label="Autopilot Created" value={new Date(autopilotProfile.AutopilotCreationDate).toLocaleString()} />}
|
||||
{hasValue(autopilotProfile.ProfileAvailable) && (
|
||||
<DetailRow label="Profile Available" value={`${autopilotProfile.ProfileAvailable}` === "1" ? "Yes" : `${autopilotProfile.ProfileAvailable}`} />
|
||||
<DetailRow label="Profile Available" value={`${autopilotProfile.ProfileAvailable}` === "1" ? "Yes" : `${autopilotProfile.ProfileAvailable}` === "0" ? "No" : `${autopilotProfile.ProfileAvailable}`} />
|
||||
)}
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user