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:
Oliver Kieselbach
2026-07-16 17:09:40 +02:00
parent 3ebeb644eb
commit 95126876ac
6 changed files with 241 additions and 4 deletions
@@ -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);
}
}
@@ -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";
@@ -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>
)}