<#
.SYNOPSIS
Exports Windows Autopatch device status and alerts to CSV.
.DESCRIPTION
Reads the Autopatch reporting API the Intune portal uses (services.autopatch.microsoft.com - undocumented,
may change). The token comes from an Azure CLI sign-in for the "Modern Workplace Customer APIs" resource,
so no DevTools token copying is needed.
Requirements:
- Azure CLI (az). The sign-in uses the Windows broker, so run this in a normal visible window.
- An active Intune role (e.g. Intune Administrator via PIM) when you sign in - without it the API
returns 403. If you activate the role after signing in, run: az logout --username <upn> then rerun.
.PARAMETER TenantId
Optional. Defaults to the tenant of the account you sign in with.
.PARAMETER OutputFolder
Where the CSVs are written. Defaults to the script folder.
.PARAMETER Token
Optional fallback: a bearer token copied from Edge DevTools (Network tab, any services.autopatch.microsoft.com
request, "authorization" header). Never save it in this file.
#>
[CmdletBinding()]
param(
[string]$TenantId,
[string]$OutputFolder = $PSScriptRoot,
[string]$Token
)
$ErrorActionPreference = 'Stop'
# ?? Token (Azure CLI sign-in) ?????????????????????????????????????????????????
$AutopatchResource = 'c9d36ed4-91b3-4c87-b8d7-68d92826c96c' # Modern Workplace Customer APIs (Autopatch backend)
if (-not $Token) {
if (-not (Get-Command az -ErrorAction SilentlyContinue)) { throw 'Azure CLI (az) not found - install it or pass -Token.' }
$tenantArgs = if ($TenantId) { @('--tenant', $TenantId) } else { @() }
$tokJson = az account get-access-token --resource $AutopatchResource @tenantArgs --only-show-errors 2>$null
if ($LASTEXITCODE) {
Write-Host 'Sign in to the customer tenant (activate your Intune role first)...' -ForegroundColor Cyan
az login @tenantArgs --allow-no-subscriptions --only-show-errors --output none
if ($LASTEXITCODE) { throw 'Azure CLI sign-in failed or was cancelled.' }
$tokJson = az account get-access-token --resource $AutopatchResource @tenantArgs --only-show-errors
if ($LASTEXITCODE) { throw "Could not get an Autopatch API token: $tokJson" }
}
$tokInfo = $tokJson | ConvertFrom-Json
$Token = $tokInfo.accessToken
Write-Host "Signed in as $(az account show --query user.name -o tsv) | Tenant $($tokInfo.tenant)" -ForegroundColor Green
}
$autopatchToken = 'Bearer ' + ($Token -replace '^Bearer\s+', '')
$stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
# ?? Alert name friendly mapping ???????????????????????????????????????????????
$alertMap = @{
"SecureBootCertificateUpdateRequired" = "Secure Boot - Certificate update required"
"DeviceTelemetryNotReceived" = "Device diagnostic data not received"
"DeviceDiagnosticDataNotReceived" = "Device diagnostic data not received"
"HotpatchLCUBaselineMissing" = "Hotpatch - Baseline missing to Hotpatch"
"HotpatchOSIncompatible" = "Hotpatch - OS not compatible"
"MultipleSecurityUpdatesMissing" = "Multiple security updates missing"
"DownloadIssue" = "Update Download Failed"
"DiskFull" = "Insufficient disk space for update"
"InstallCancelled" = "Update installation canceled"
"DownloadCancelled" = "Update download canceled"
"FileNotFound" = "Update files not found"
"AdminIPU" = "Repair devices"
"InstallIssue" = "Update installation failed"
"WindowsComponentCorruption" = "Windows component corruption"
# Seen 2026-11-07 - descriptive names, not necessarily the portal's wording
"AUOptionSettingsConflict" = "Automatic Updates setting conflicts with policy"
"EligibleAdminIPU" = "Eligible for repair (admin in-place upgrade)"
"HotpatchCHPEEnabledOnARM64" = "Hotpatch - CHPE enabled on ARM64"
"HotpatchVBSDisabled" = "Hotpatch - VBS disabled"
}
$headers = @{
Authorization = $autopatchToken
"client-request-id" = [guid]::NewGuid().ToString()
Accept = "application/json;odata.metadata=minimal"
}
# ?? Fetch alert summary ???????????????????????????????????????????????????????
Write-Host "Fetching Autopatch alert summary..." -ForegroundColor Cyan
try {
$alertResponse = Invoke-RestMethod -Method GET `
-Uri "https://services.autopatch.microsoft.com/unified-reporting/odata/1.0/DeviceAlertList" `
-Headers $headers
$alerts = $alertResponse.value
Write-Host "Alert types found: $($alerts.Count)" -ForegroundColor Green
Write-Host "`n=== Alert Summary ===" -ForegroundColor Yellow
$alerts | Select-Object `
@{N="Alert Name"; E={ $alertMap[$_.alertGroupName] ?? $_.alertGroupName }},
@{N="Severity"; E={$_.alertGroupSeverity}},
@{N="Update Type"; E={($_.alertGroupAffectedUpdateTypes | ConvertFrom-Json) -join ", "}},
@{N="Device Count"; E={$_.impactDeviceCount}},
@{N="State"; E={$_.alertGroupState}},
@{N="Latest Date"; E={$_.alertGroupLatestDateTime}} |
Sort-Object "Device Count" -Descending |
Format-Table -AutoSize
$alertSummaryPath = Join-Path $OutputFolder "AutopatchAlertSummary_$stamp.csv"
$alerts | Select-Object `
@{N="AlertName"; E={ $alertMap[$_.alertGroupName] ?? $_.alertGroupName }},
@{N="AlertNameRaw"; E={$_.alertGroupName}},
@{N="Severity"; E={$_.alertGroupSeverity}},
@{N="State"; E={$_.alertGroupState}},
@{N="AffectedUpdateType";E={$_.alertGroupAffectedUpdateTypes}},
@{N="DeviceCount"; E={$_.impactDeviceCount}},
@{N="RemediationAction"; E={$_.alertGroupRemediationAction}},
@{N="LatestDateTime"; E={$_.alertGroupLatestDateTime}} |
Export-Csv $alertSummaryPath -NoTypeInformation -Encoding UTF8
Write-Host "Alert summary exported: $alertSummaryPath" -ForegroundColor Green
} catch {
Write-Host "Alert summary failed: $_" -ForegroundColor Red
if ("$_" -match '403|Forbidden') { Write-Host 'No access - activate an Intune role (e.g. Intune Administrator), then: az logout --username <upn> and rerun.' -ForegroundColor Yellow }
}
# ?? Fetch per-device status ???????????????????????????????????????????????????
Write-Host "`nFetching device management status..." -ForegroundColor Cyan
try {
$deviceStatus = @()
$uri = "https://services.autopatch.microsoft.com/unified-reporting/odata/1.0/AutopatchManagementStatusDetails?`$count=true"
do {
$response = Invoke-RestMethod -Method GET -Uri $uri -Headers $headers
$deviceStatus += $response.value
$uri = $response.'@odata.nextLink'
Write-Host " Retrieved $($deviceStatus.Count) devices..." -ForegroundColor Gray
} while ($uri)
$withAlerts = $deviceStatus | Where-Object { $_.alertCount -gt 0 }
Write-Host "`n================================================" -ForegroundColor Yellow
Write-Host " Total devices : $($deviceStatus.Count)" -ForegroundColor White
Write-Host " Devices with alerts: $($withAlerts.Count)" -ForegroundColor Red
Write-Host "================================================" -ForegroundColor Yellow
# Show devices with alerts and friendly alert names
Write-Host "`n=== Devices with Alerts ===" -ForegroundColor Yellow
$withAlerts | ForEach-Object {
$friendlyAlerts = ($_.alertNames | ForEach-Object { $alertMap[$_] ?? $_ }) -join "; "
[PSCustomObject]@{
DeviceName = $_.deviceName
UpdateStatus = $_.updateStatus
OSVersion = $_.osVersion
Alerts = $friendlyAlerts
AlertCount = $_.alertCount
LastSync = $_.lastIntuneSyncDateTimeUTC
}
} | Sort-Object AlertCount -Descending | Format-Table -AutoSize -Wrap
# Export full device status
$devicePath = Join-Path $OutputFolder "AutopatchBrokenDevices_$stamp.csv"
$withAlerts | ForEach-Object {
$friendlyAlerts = ($_.alertNames | ForEach-Object { $alertMap[$_] ?? $_ }) -join "; "
[PSCustomObject]@{
DeviceName = $_.deviceName
SerialNumber = $_.serialNumber
Model = $_.model
OSVersion = $_.osVersion
UpdateStatus = $_.updateStatus
AlertCount = $_.alertCount
Alerts = $friendlyAlerts
LastIntuneSync = $_.lastIntuneSyncDateTimeUTC
}
} | Sort-Object AlertCount -Descending |
Export-Csv $devicePath -NoTypeInformation -Encoding UTF8
Write-Host "Exported $($withAlerts.Count) broken devices to: $devicePath" -ForegroundColor Green
# Breakdown by alert type
Write-Host "`nAlert breakdown:" -ForegroundColor Cyan
$deviceStatus | ForEach-Object {
$_.alertNames | ForEach-Object { $alertMap[$_] ?? $_ }
} | Group-Object | Sort-Object Count -Descending | Format-Table Name, Count -AutoSize
} catch {
Write-Host "Device status failed: $_" -ForegroundColor Red
}